Fix deleted account issues

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-09 00:40:04 +03:00
Unverified
parent 2050c9d8b2
commit 74e1126bc4
36 changed files with 987 additions and 250 deletions
@@ -3,16 +3,10 @@ package ru.fromchat.api
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.call.body
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.config.ServerConfig
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -34,14 +28,8 @@ private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutin
private suspend fun postFcmToken(token: String): Boolean { private suspend fun postFcmToken(token: String): Boolean {
return runCatching { return runCatching {
val suffix = token.takeLast(8) ApiClient.registerFcmToken(token)
ApiClient.http Logger.i("FcmReg", "Uploaded FCM token to server: ...${token.takeLast(8)}")
.post("${ServerConfig.apiBaseUrl}/push/register") {
header("Content-Type", "application/json")
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
}
.body<SimpleStatusResponse>()
Logger.i("FcmReg", "Uploaded FCM token to server: ...$suffix")
true true
}.getOrElse { e -> }.getOrElse { e ->
Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e) Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e)
@@ -53,8 +41,11 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
try { try {
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "") val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) { if (ApiClient.token.isNullOrEmpty()) {
Logger.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload") Logger.d("FcmReg", "Auth token missing; deferring FCM token upload")
return@withContext
}
if (pending.isBlank()) {
return@withContext return@withContext
} }
@@ -102,22 +93,21 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc
return@withContext false return@withContext false
} }
val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim() val storedToken = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim()
val token = storedToken.ifBlank {
runCatching { fetchCurrentFcmToken()?.trim().orEmpty() }.getOrDefault("")
}
Logger.i("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}") Logger.i("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
return@withContext runCatching { return@withContext runCatching {
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") { ApiClient.unregisterFcmToken(token.takeIf { it.isNotBlank() })
header("Content-Type", "application/json")
if (token.isNotEmpty()) {
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
}
}
settings.remove(PENDING_FCM_TOKEN_KEY) settings.remove(PENDING_FCM_TOKEN_KEY)
if (token.isNotBlank()) { settings.remove(CURRENT_FCM_TOKEN_KEY)
settings.remove(CURRENT_FCM_TOKEN_KEY)
}
true true
}.getOrElse { e -> }.getOrElse { e ->
Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}") Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
false false
} }
} }
actual suspend fun isFcmPushRegisteredLocally(): Boolean =
settings.getString(CURRENT_FCM_TOKEN_KEY, "").isNotBlank()
@@ -142,6 +142,7 @@ import ru.fromchat.notif_call_ongoing_title
import ru.fromchat.notif_screenshare_text import ru.fromchat.notif_screenshare_text
import ru.fromchat.notif_screenshare_title import ru.fromchat.notif_screenshare_title
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.profile.avatarLabelForInitials
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.sqrt import kotlin.math.sqrt
@@ -731,7 +732,8 @@ private fun SoloCallParticipantVideos(
val lcRef = localCamRefs.firstOrNull() val lcRef = localCamRefs.firstOrNull()
val self = ApiClient.user val self = ApiClient.user
val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture
val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() val selfAvatarLabel = self?.displayName?.trim().orEmpty()
val peerAvatarLabel = ProfileCache.get(session.peerUserId)?.avatarLabelForInitials(self?.id).orEmpty()
val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture
Box( Box(
Modifier Modifier
@@ -753,7 +755,7 @@ private fun SoloCallParticipantVideos(
} }
lcRef != null && !localCamOn -> { lcRef != null && !localCamOn -> {
RemoteVideoOffPlaceholder( RemoteVideoOffPlaceholder(
displayName = selfName, displayName = selfAvatarLabel,
profilePictureUrl = selfPic, profilePictureUrl = selfPic,
audioLevel = localLevel, audioLevel = localLevel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -761,7 +763,7 @@ private fun SoloCallParticipantVideos(
} }
else -> { else -> {
RemoteVideoOffPlaceholder( RemoteVideoOffPlaceholder(
displayName = session.peerDisplayName, displayName = peerAvatarLabel,
profilePictureUrl = peerPic, profilePictureUrl = peerPic,
audioLevel = 0f, audioLevel = 0f,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -884,7 +886,8 @@ private fun DuoCallParticipantVideos(
val selfId = self?.id val selfId = self?.id
val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture } val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture }
?: self?.profile_picture ?: self?.profile_picture
val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() val selfAvatarLabel = self?.displayName?.trim().orEmpty()
val peerAvatarLabel = ProfileCache.get(session.peerUserId)?.avatarLabelForInitials(selfId).orEmpty()
val youLabel = stringResource(Res.string.message_sender_you) val youLabel = stringResource(Res.string.message_sender_you)
val controlsReserve = if (showInCallControls) 168.dp else 0.dp val controlsReserve = if (showInCallControls) 168.dp else 0.dp
val screenShareMainBottomPad = controlsReserve val screenShareMainBottomPad = controlsReserve
@@ -893,7 +896,7 @@ private fun DuoCallParticipantVideos(
VideoSlot.RemoteScreen, VideoSlot.RemoteCam -> VideoSlot.RemoteScreen, VideoSlot.RemoteCam ->
CallOwnerUi( CallOwnerUi(
name = session.peerDisplayName, name = session.peerDisplayName,
avatarName = session.peerDisplayName, avatarName = peerAvatarLabel,
pictureUrl = peerPic, pictureUrl = peerPic,
level = remoteLevel, level = remoteLevel,
isSelf = false, isSelf = false,
@@ -901,7 +904,7 @@ private fun DuoCallParticipantVideos(
VideoSlot.LocalCam, VideoSlot.LocalScreen -> VideoSlot.LocalCam, VideoSlot.LocalScreen ->
CallOwnerUi( CallOwnerUi(
name = youLabel, name = youLabel,
avatarName = selfName, avatarName = selfAvatarLabel,
pictureUrl = selfPic, pictureUrl = selfPic,
level = localLevel, level = localLevel,
isSelf = true, isSelf = true,
@@ -950,7 +953,11 @@ private fun DuoCallParticipantVideos(
} }
slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> { slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> {
RemoteVideoOffPlaceholder( RemoteVideoOffPlaceholder(
displayName = session.peerDisplayName, displayName = if (slot == VideoSlot.RemoteCam) {
peerAvatarLabel
} else {
selfAvatarLabel
},
profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic, profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic,
audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel, audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel,
modifier = baseModifier, modifier = baseModifier,
@@ -958,7 +965,7 @@ private fun DuoCallParticipantVideos(
} }
else -> { else -> {
RemoteVideoOffPlaceholder( RemoteVideoOffPlaceholder(
displayName = session.peerDisplayName, displayName = peerAvatarLabel,
profilePictureUrl = peerPic, profilePictureUrl = peerPic,
audioLevel = remoteLevel, audioLevel = remoteLevel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -110,6 +110,9 @@
<string name="notif_media_upload_text">Загрузка продолжается в фоне</string> <string name="notif_media_upload_text">Загрузка продолжается в фоне</string>
<string name="message_sender_you">Вы</string> <string name="message_sender_you">Вы</string>
<string name="user_fallback">Человек %1$d</string> <string name="user_fallback">Человек %1$d</string>
<string name="deleted_account">Удалённый аккаунт</string>
<string name="account_suspended">Аккаунт заблокирован</string>
<string name="action_delete_chat">Удалить чат</string>
<string name="message_corrupted">Это сообщение не удалось показать.</string> <string name="message_corrupted">Это сообщение не удалось показать.</string>
<string name="message_edited_suffix">(изменено)</string> <string name="message_edited_suffix">(изменено)</string>
<string name="message_replying_to">Ответ %1$s</string> <string name="message_replying_to">Ответ %1$s</string>
@@ -178,9 +181,11 @@
<string name="verify">Сделать официальным</string> <string name="verify">Сделать официальным</string>
<string name="unverify">Снять официальный статус</string> <string name="unverify">Снять официальный статус</string>
<string name="cd_verified_account">Официальный аккаунт</string> <string name="cd_verified_account">Официальный аккаунт</string>
<string name="cd_account_blocked">Аккаунт заблокирован</string>
<string name="cd_similar_verified">Похож на официальный аккаунт</string> <string name="cd_similar_verified">Похож на официальный аккаунт</string>
<string name="presence_online">В сети</string> <string name="presence_online">В сети</string>
<string name="presence_recently">Недавно заходил</string> <string name="presence_recently">Недавно заходил</string>
<string name="presence_long_ago">был(а) давно</string>
<string name="presence_today_at">Сегодня в %1$s</string> <string name="presence_today_at">Сегодня в %1$s</string>
<string name="presence_yesterday_at">Вчера в %1$s</string> <string name="presence_yesterday_at">Вчера в %1$s</string>
<string name="presence_weekday_at">%1$s в %2$s</string> <string name="presence_weekday_at">%1$s в %2$s</string>
@@ -383,6 +388,8 @@
<string name="logs_file_size_kb">%1$d КБ</string> <string name="logs_file_size_kb">%1$d КБ</string>
<string name="logs_file_size_mb">%1$s МБ</string> <string name="logs_file_size_mb">%1$s МБ</string>
<string name="logs_scroll_to_bottom_cd">Прокрутить к последним записям</string> <string name="logs_scroll_to_bottom_cd">Прокрутить к последним записям</string>
<string name="logs_search">Поиск</string>
<string name="logs_search_hint">Поиск по записям журнала</string>
<string name="settings_account_title">Аккаунт</string> <string name="settings_account_title">Аккаунт</string>
<string name="settings_account_logout_confirm_title">Выйти?</string> <string name="settings_account_logout_confirm_title">Выйти?</string>
@@ -413,6 +420,7 @@
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string> <string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
<string name="error_unknown">Что-то пошло не так. Попробуйте ещё раз.</string> <string name="error_unknown">Что-то пошло не так. Попробуйте ещё раз.</string>
<string name="typing_single">%1$s печатает…</string> <string name="typing_single">%1$s печатает…</string>
<string name="typing_alone">печатает…</string>
<string name="typing_two">%1$s и %2$s печатают…</string> <string name="typing_two">%1$s и %2$s печатают…</string>
<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>
@@ -122,6 +122,9 @@
<!-- Messages --> <!-- Messages -->
<string name="message_sender_you">You</string> <string name="message_sender_you">You</string>
<string name="user_fallback">Person %1$d</string> <string name="user_fallback">Person %1$d</string>
<string name="deleted_account">Deleted account</string>
<string name="account_suspended">Account suspended</string>
<string name="action_delete_chat">Delete chat</string>
<string name="message_corrupted">This message could not be shown.</string> <string name="message_corrupted">This message could not be shown.</string>
<string name="message_edited_suffix">(edited)</string> <string name="message_edited_suffix">(edited)</string>
<string name="message_replying_to">Reply to %1$s</string> <string name="message_replying_to">Reply to %1$s</string>
@@ -200,11 +203,13 @@
<!-- Status badge --> <!-- Status badge -->
<string name="cd_verified_account">Verified account</string> <string name="cd_verified_account">Verified account</string>
<string name="cd_account_blocked">Account blocked</string>
<string name="cd_similar_verified">May be a verified account</string> <string name="cd_similar_verified">May be a verified account</string>
<!-- Presence / last seen --> <!-- Presence / last seen -->
<string name="presence_online">Online</string> <string name="presence_online">Online</string>
<string name="presence_recently">Active recently</string> <string name="presence_recently">Active recently</string>
<string name="presence_long_ago">last seen a long time ago</string>
<string name="presence_today_at">Today at %1$s</string> <string name="presence_today_at">Today at %1$s</string>
<string name="presence_yesterday_at">Yesterday at %1$s</string> <string name="presence_yesterday_at">Yesterday at %1$s</string>
<string name="presence_weekday_at">%1$s at %2$s</string> <string name="presence_weekday_at">%1$s at %2$s</string>
@@ -409,6 +414,8 @@
<string name="logs_file_size_kb">%1$d KB</string> <string name="logs_file_size_kb">%1$d KB</string>
<string name="logs_file_size_mb">%1$s MB</string> <string name="logs_file_size_mb">%1$s MB</string>
<string name="logs_scroll_to_bottom_cd">Scroll to latest logs</string> <string name="logs_scroll_to_bottom_cd">Scroll to latest logs</string>
<string name="logs_search">Search</string>
<string name="logs_search_hint">Search log entries</string>
<string name="settings_account_title">Account</string> <string name="settings_account_title">Account</string>
<string name="settings_account_logout_confirm_title">Log out?</string> <string name="settings_account_logout_confirm_title">Log out?</string>
@@ -443,6 +450,7 @@
<!-- Typing Indicators --> <!-- Typing Indicators -->
<string name="typing_single">%1$s is typing…</string> <string name="typing_single">%1$s is typing…</string>
<string name="typing_alone">typing…</string>
<string name="typing_two">%1$s and %2$s are typing…</string> <string name="typing_two">%1$s and %2$s are typing…</string>
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string> <string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
@@ -232,7 +232,7 @@ object ApiClient {
if (response.status.value == 401) { if (response.status.value == 401) {
val path = response.call.request.url.encodedPath val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register") val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
if (!isCredentialCheck) { if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch { MainScope().launch {
runCatching { WebSocketManager.disconnect() } runCatching { WebSocketManager.disconnect() }
runCatching { clearLocalSession() } runCatching { clearLocalSession() }
@@ -408,6 +408,9 @@ object ApiClient {
@Volatile @Volatile
var user: User? = null var user: User? = null
@Volatile
private var logoutInProgress = false
var onAuthError: (() -> Unit)? = null var onAuthError: (() -> Unit)? = null
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? = private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? =
@@ -1406,13 +1409,20 @@ object ApiClient {
} }
suspend fun logout() { suspend fun logout() {
runCatching { if (logoutInProgress) return
http.get("${ServerConfig.apiBaseUrl}/logout") logoutInProgress = true
}.onFailure { e -> try {
ru.fromchat.Logger.e("ApiClient", "Server logout failed", e) runCatching { WebSocketManager.disconnect() }
runCatching { unregisterFcmTokenFromServer() }
runCatching {
http.get("${ServerConfig.apiBaseUrl}/logout")
}.onFailure { e ->
ru.fromchat.Logger.e("ApiClient", "Server logout failed", e)
}
clearLocalSession()
} finally {
logoutInProgress = false
} }
runCatching { unregisterFcmTokenFromServer() }
clearLocalSession()
} }
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
@@ -0,0 +1,96 @@
package ru.fromchat.api
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
/**
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
* message for list previews (without opening each chat first).
*/
object ChatListSync {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var started = false
fun ensureStarted() {
if (started) return
started = true
WebSocketManager.addSessionReadyHandler {
scope.launch { syncFromNetwork() }
}
WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage)
}
fun resetOnLogout() {
started = false
}
suspend fun syncFromNetwork() {
if (!canSync()) return
syncDmConversations()
syncPublicChatPreview()
}
private fun canSync(): Boolean {
if (ApiClient.token.isNullOrEmpty()) return false
if (CacheContext.activeInstanceId.value.trim().isEmpty()) return false
return true
}
private suspend fun syncDmConversations() {
val previewStrings = MessageCacheStore.listPreviewStrings ?: return
runCatching {
val conversations = ApiClient.getDmConversations()
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageRepository.replaceDmConversations(conversations, previewStrings)
}
}
private suspend fun syncPublicChatPreview() {
runCatching {
val response = ApiClient.getMessages(limit = 1)
val latest = response.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
MessageRepository.upsertPublicMessage(latest)
}
}
private fun handleWebSocketMessage(message: WebSocketMessage) {
when (message.type) {
"updates" -> {
val data = message.data ?: return
val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return
updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
}
}
"newMessage" -> message.data?.let { element ->
scope.launch { ingestPublicMessage(element) }
}
}
}
private suspend fun ingestPublicMessage(element: JsonElement) {
val newMsg = runCatching {
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
}.getOrNull() ?: return
ProfileCache.mergePreviewFromPublicMessage(newMsg)
MessageRepository.upsertPublicMessage(newMsg)
}
}
@@ -12,4 +12,7 @@ expect suspend fun ensureFcmTokenRegistered(): Boolean
* Unregisters the local FCM token from the server for this user. * Unregisters the local FCM token from the server for this user.
* Returns true when the unregister request succeeds. * Returns true when the unregister request succeeds.
*/ */
expect suspend fun unregisterFcmTokenFromServer(): Boolean expect suspend fun unregisterFcmTokenFromServer(): Boolean
/** Whether this device has an FCM token registered with the server for the current session. */
expect suspend fun isFcmPushRegisteredLocally(): Boolean
@@ -7,6 +7,7 @@ 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 ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.local.db.store.InstanceRegistryStore import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
@@ -38,6 +39,7 @@ private suspend fun activateInstance(instanceId: String) {
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
PublicChatProfileSync.ensureStarted() PublicChatProfileSync.ensureStarted()
ChatListSync.ensureStarted()
scheduleOutboxProcessing(instanceId) scheduleOutboxProcessing(instanceId)
scheduleAttachmentResumeAfterSession() scheduleAttachmentResumeAfterSession()
ApiClient.user?.id?.let { userId -> ApiClient.user?.id?.let { userId ->
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.db
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedFileCache import ru.fromchat.api.local.cache.DecryptedFileCache
@@ -48,6 +49,7 @@ suspend fun wipeLocalCacheOnDisk() {
suspend fun clearAccountCacheOnLogout(instanceId: String) { suspend fun clearAccountCacheOnLogout(instanceId: String) {
val id = instanceId.trim() val id = instanceId.trim()
PublicChatProfileSync.resetOnLogout() PublicChatProfileSync.resetOnLogout()
ChatListSync.resetOnLogout()
if (id.isNotEmpty()) { if (id.isNotEmpty()) {
cancelOutboxProcessing(id) cancelOutboxProcessing(id)
runCatching { MessageRepository.purgeAllPendingForInstance() } runCatching { MessageRepository.purgeAllPendingForInstance() }
@@ -99,11 +99,11 @@ object MessageCacheStore {
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> { fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
if (instanceId.isBlank()) return emptyList() if (instanceId.isBlank()) return emptyList()
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
val raw = db.messageDatabaseQueries val raw = hydrateReplyReferences(
.selectRecentMessagesByConversation(instanceId, convId, limit) db.messageDatabaseQueries
.executeAsList() .selectRecentMessagesByConversation(instanceId, convId, limit)
.map { row: DbMessage -> row.toAppMessage() } .executeAsList(),
.reversed() ).reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
return ProfileCache.enrichPublicMessagesForDisplay( return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
@@ -345,8 +345,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val upserts = conversations.map { conv -> val upserts = conversations.map { conv ->
val conversationId = conversationIdForDm(conv.user.id) val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } val displayLabel = when {
?: conv.user.username.trim() conv.user.deleted == true -> ""
else -> conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim()
}
val localUnread = db.messageDatabaseQueries val localUnread = db.messageDatabaseQueries
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong()) .countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
.executeAsOne() .executeAsOne()
@@ -388,6 +391,7 @@ object MessageCacheStore {
pruneEmptyConversationsLocked(iid) pruneEmptyConversationsLocked(iid)
} }
} }
DmConversationListNotifier.notifyChanged()
} }
private data class UpsertDmConversationRow( private data class UpsertDmConversationRow(
@@ -853,10 +857,10 @@ object MessageCacheStore {
val iid = instanceId() val iid = instanceId()
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid) OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid)
return withContext(Dispatchers.Default) { return withContext(Dispatchers.Default) {
val raw = db.messageDatabaseQueries val rows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
.map { row: DbMessage -> row.toAppMessage() } val raw = rows.map { it.toAppMessage() }
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded) purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
@@ -873,11 +877,10 @@ object MessageCacheStore {
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> { private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> {
val iid = instanceId() val iid = instanceId()
return withContext(Dispatchers.Default) { return withContext(Dispatchers.Default) {
db.messageDatabaseQueries val rows = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, conversationId, limit) .selectRecentMessagesByConversation(iid, conversationId, limit)
.executeAsList() .executeAsList()
.map { row: DbMessage -> row.toAppMessage() } rows.map { it.toAppMessage() }.reversed()
.reversed()
} }
} }
@@ -983,6 +986,19 @@ object MessageCacheStore {
} }
} }
private fun hydrateReplyReferences(rows: List<DbMessage>): List<Message> {
val messages = rows.map { it.toAppMessage() }
val byId = messages.associateBy { it.id }
return rows.zip(messages).map { (row, message) ->
val replyId = row.replyToId?.toInt()
if (replyId != null) {
message.copy(reply_to = byId[replyId])
} else {
message
}
}
}
private fun DbMessage.toAppMessage(): Message { private fun DbMessage.toAppMessage(): Message {
val uid = userId.toInt() val uid = userId.toInt()
val self = ApiClient.user val self = ApiClient.user
@@ -19,7 +19,10 @@ import kotlin.concurrent.Volatile
* to the UI (for suspended or deleted users), except for the current user. * to the UI (for suspended or deleted users), except for the current user.
*/ */
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean = fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
id != currentUserId && (deleted == true || suspended == true) id != currentUserId && (deleted == true || isDeletedPlaceholderUsername(username))
private fun isDeletedPlaceholderUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true
fun UserProfile.visibleUsername(currentUserId: Int? = null): String? = fun UserProfile.visibleUsername(currentUserId: Int? = null): String? =
if (shouldHideUsername(currentUserId)) { if (shouldHideUsername(currentUserId)) {
@@ -71,18 +74,23 @@ object ProfileCache {
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() } val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() } ?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() } val isDeleted = existing?.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
?: existing?.displayName?.takeIf { it.isNotBlank() } val incomingDisplayName = if (isDeleted) {
?: incomingUsername null
} else {
displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
}
if (incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
put( put(
UserProfile( UserProfile(
id = id, id = id,
username = incomingUsername.orEmpty(), username = incomingUsername.orEmpty(),
displayName = incomingDisplayName, displayName = incomingDisplayName,
profilePicture = profilePicture?.takeIf { it.isNotBlank() } profilePicture = if (isDeleted) null else profilePicture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
online = existing?.online ?: false, online = existing?.online ?: false,
@@ -92,7 +100,7 @@ object ProfileCache {
verificationStatus = verificationStatus ?: existing?.verificationStatus, verificationStatus = verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended, suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason, suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted, deleted = isDeleted,
isClientPreviewOnly = true, isClientPreviewOnly = true,
), ),
) )
@@ -152,16 +160,20 @@ object ProfileCache {
val incomingUsername = user.username.trim() val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return if (incomingUsername.isEmpty()) return
val incomingDisplayName = val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) {
null
} else {
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
}
put( put(
UserProfile( UserProfile(
id = user.id, id = user.id,
username = incomingUsername, username = incomingUsername,
displayName = existing?.displayName?.takeIf { it.isNotBlank() } displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingDisplayName, ?: incomingDisplayName,
profilePicture = user.profile_picture?.takeIf { it.isNotBlank() } profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
online = user.online, online = user.online,
@@ -169,9 +181,9 @@ object ProfileCache {
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt, createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt,
verified = user.verified ?: existing?.verified, verified = user.verified ?: existing?.verified,
verificationStatus = user.verificationStatus ?: existing?.verificationStatus, verificationStatus = user.verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended, suspended = user.suspended ?: existing?.suspended,
suspensionReason = existing?.suspensionReason, suspensionReason = user.suspensionReason ?: existing?.suspensionReason,
deleted = existing?.deleted, deleted = isDeleted,
isClientPreviewOnly = true, isClientPreviewOnly = true,
), ),
) )
@@ -185,8 +197,10 @@ object ProfileCache {
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() } val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return if (uname.isBlank()) return
val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture
put( put(
UserProfile( UserProfile(
@@ -202,7 +216,7 @@ object ProfileCache {
verificationStatus = message.verificationStatus ?: existing?.verificationStatus, verificationStatus = message.verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended, suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason, suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted, deleted = isDeleted,
isClientPreviewOnly = true, isClientPreviewOnly = true,
), ),
) )
@@ -30,6 +30,23 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
internal fun parseMessageTimestampMillis(timestamp: String): Long? = internal fun parseMessageTimestampMillis(timestamp: String): Long? =
parseMessageInstant(timestamp)?.toEpochMilliseconds() parseMessageInstant(timestamp)?.toEpochMilliseconds()
/** HH:mm today; date + time when the message is from another day (bubble footer). */
internal fun formatMessageBubbleTimeLocal(timestamp: String): String {
val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return ""
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val hour = local.hour.toString().padStart(2, '0')
val minute = local.minute.toString().padStart(2, '0')
val time = "$hour:$minute"
if (local.date == now.date) return time
val day = local.day.toString().padStart(2, '0')
val month = local.month.number.toString().padStart(2, '0')
return if (local.year == now.year) {
"$day.$month $time"
} else {
"$day.$month.${local.year} $time"
}
}
/** HH:mm in the device time zone (bubble footer). */ /** HH:mm in the device time zone (bubble footer). */
internal fun formatMessageTimeLocal(timestamp: String): String { internal fun formatMessageTimeLocal(timestamp: String): String {
val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return "" val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return ""
@@ -19,5 +19,6 @@ data class User(
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null, @SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val suspended: Boolean? = null, val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null, @SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null,
) )
@@ -13,6 +13,9 @@ enum class VerificationStatus {
@SerialName("none") @SerialName("none")
None, None,
@SerialName("blocked")
Blocked,
} }
fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus = fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus =
@@ -165,7 +165,6 @@ private fun handleAccountLifecycleEvent(message: WebSocketMessage) {
MainScope().launch { MainScope().launch {
ApiClient.logout() ApiClient.logout()
} }
WebSocketManager.disconnect()
} }
} }
} }
@@ -32,7 +32,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.CallUiState import ru.fromchat.api.calls.CallUiState
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.ui.profile.displayNameForUi
import ru.fromchat.ui.profile.DisplayName import ru.fromchat.ui.profile.DisplayName
import ru.fromchat.ui.profile.effectiveVerificationStatus import ru.fromchat.ui.profile.effectiveVerificationStatus
import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -94,7 +94,7 @@ fun CallOverlay(modifier: Modifier = Modifier) {
val me = ApiClient.user?.id val me = ApiClient.user?.id
val cached = ProfileCache.get(s.fromUserId) val cached = ProfileCache.get(s.fromUserId)
val title = val title =
cached?.visibleDisplayName(me)?.takeIf { it.isNotBlank() } cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() } ?: cached?.username?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.user_fallback, s.fromUserId) ?: stringResource(Res.string.user_fallback, s.fromUserId)
Box( Box(
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
@@ -108,6 +109,7 @@ import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
private val IMAGE_SIZE = 160.dp private val IMAGE_SIZE = 160.dp
private val IMAGE_MAX_HEIGHT = 240.dp
private const val BLUR_FADE_MS = 450 private const val BLUR_FADE_MS = 450
internal fun isImageFilename(name: String): Boolean = internal fun isImageFilename(name: String): Boolean =
@@ -210,11 +212,12 @@ fun AttachmentPreview(
fileAspectRatio != null && fileAspectRatio > 0f, fileAspectRatio != null && fileAspectRatio > 0f,
`if` = { `if` = {
Modifier Modifier
.aspectRatio(fileAspectRatio!!) .heightIn(max = IMAGE_MAX_HEIGHT)
.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) .widthIn(max = IMAGE_SIZE)
.aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true)
}, },
`else` = { `else` = {
Modifier.size(IMAGE_SIZE) Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT)
} }
) )
.clip(attachmentImageCornerShape(isAuthor)) .clip(attachmentImageCornerShape(isAuthor))
@@ -4,6 +4,9 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonOff
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -29,8 +32,35 @@ import ru.fromchat.ui.chat.components.getInitials
fun Avatar( fun Avatar(
profilePictureUrl: String?, profilePictureUrl: String?,
displayName: String, displayName: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isDeletedUser: Boolean = false,
userId: Int? = null,
) { ) {
if (isDeletedUser) {
val gradientSeed = userId?.toString() ?: displayName
val gradient = remember(gradientSeed) { generateGradientFromName(gradientSeed) }
Box(
modifier = modifier.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = size.minDimension / 2f
drawCircle(
brush = gradient,
radius = radius,
center = center,
)
}
Icon(
imageVector = Icons.Outlined.PersonOff,
contentDescription = displayName,
modifier = Modifier.fillMaxSize(0.55f),
tint = Color.White,
)
}
return
}
var imageLoadFailed by remember { mutableStateOf(false) } var imageLoadFailed by remember { mutableStateOf(false) }
val gradient = remember(displayName) { generateGradientFromName(displayName) } val gradient = remember(displayName) { generateGradientFromName(displayName) }
@@ -99,4 +129,3 @@ fun Avatar(
} }
} }
} }
@@ -321,25 +321,35 @@ abstract class ChatPanel(
pending?.first?.cancel() pending?.first?.cancel()
updateState { currentState -> updateState { currentState ->
val withoutDupReal = if (confirmedMessage.id > 0) { val optimistic = currentState.messages.find { it.client_message_id == tempId }
currentState.messages.filter { it.id != confirmedMessage.id } val resolvedConfirmed = if (confirmedMessage.reply_to == null) {
val reply = optimistic?.reply_to
if (reply != null) confirmedMessage.copy(reply_to = reply) else confirmedMessage
} else {
confirmedMessage
}
val withoutDupReal = if (resolvedConfirmed.id > 0) {
currentState.messages.filter { it.id != resolvedConfirmed.id }
} else { } else {
currentState.messages currentState.messages
} }
val hadTemp = withoutDupReal.any { it.client_message_id == tempId } val hadTemp = withoutDupReal.any { it.client_message_id == tempId }
val mapped = withoutDupReal.map { msg -> val mapped = withoutDupReal.map { msg ->
if (msg.client_message_id == tempId) confirmedMessage else msg if (msg.client_message_id == tempId) resolvedConfirmed else msg
} }
val messages = when { val messages = when {
hadTemp -> mapped hadTemp -> mapped
confirmedMessage.id > 0 && mapped.none { it.id == confirmedMessage.id } -> resolvedConfirmed.id > 0 && mapped.none { it.id == resolvedConfirmed.id } ->
mapped + confirmedMessage mapped + resolvedConfirmed
else -> mapped else -> mapped
} }
currentState.copy(messages = sortMessagesForChatDisplay(messages)) currentState.copy(messages = sortMessagesForChatDisplay(messages))
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) } val resolved = _state.messages.find { it.client_message_id == tempId }
?: _state.messages.find { it.id == confirmedMessage.id }
val toPersist = resolved ?: confirmedMessage
runCatching { onOptimisticMessageConfirmed(tempId, toPersist) }
} }
} }
@@ -20,6 +20,9 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -69,6 +72,7 @@ import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.download.SavableMessageImage import ru.fromchat.api.local.download.SavableMessageImage
@@ -94,6 +98,8 @@ import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.action_delete_chat
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.cd_call import ru.fromchat.cd_call
import ru.fromchat.chat_group_label import ru.fromchat.chat_group_label
import ru.fromchat.status_connecting import ru.fromchat.status_connecting
@@ -104,6 +110,7 @@ import ru.fromchat.ui.chat.utils.getImageAspectRatio
import ru.fromchat.ui.chat.utils.getImageDimensions import ru.fromchat.ui.chat.utils.getImageDimensions
import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
@@ -167,6 +174,24 @@ fun ChatScreen(
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val suspensionState by ApiClient.suspensionState.collectAsState() val suspensionState by ApiClient.suspensionState.collectAsState()
val isReadOnly = suspensionState.isSuspended val isReadOnly = suspensionState.isSuspended
val dmRecipientId = panel.getRecipientId()
var peerDeleted by remember(dmRecipientId) { mutableStateOf(false) }
val deleteChatLabel = stringResource(Res.string.action_delete_chat)
LaunchedEffect(dmRecipientId) {
val userId = dmRecipientId ?: return@LaunchedEffect
peerDeleted = peerIsDeleted(userId = userId, currentUserId = currentUserId)
if (!peerDeleted) {
runCatching { ApiClient.getProfileById(userId) }.onSuccess { profile ->
ProfileCache.put(profile)
peerDeleted = peerIsDeleted(
userId = userId,
currentUserId = currentUserId,
deleted = profile.deleted,
username = profile.username,
)
}
}
}
val lastSeenFormat = rememberLastSeenFormatStrings() val lastSeenFormat = rememberLastSeenFormatStrings()
var showSuspendedSupportSheet by remember { mutableStateOf(false) } var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val statusConnecting = stringResource(Res.string.status_connecting) val statusConnecting = stringResource(Res.string.status_connecting)
@@ -507,6 +532,36 @@ fun ChatScreen(
) )
} }
) { ) {
if (peerDeleted && dmRecipientId != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
) {
Button(
onClick = {
scope.launch {
val messages = runCatching {
MessageRepository.loadDmMessages(dmRecipientId)
}.getOrDefault(emptyList()).filter { it.id > 0 }
messages.forEach { msg ->
runCatching { ApiClient.deleteDm(msg.id, dmRecipientId) }
}
runCatching { MessageRepository.deleteDmConversation(dmRecipientId) }
navController.popBackStack()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(deleteChatLabel)
}
}
} else {
ChatInput( ChatInput(
text = inputText, text = inputText,
onTextChange = { inputText = it }, onTextChange = { inputText = it },
@@ -632,6 +687,7 @@ fun ChatScreen(
} }
} }
) )
}
} }
} }
) { innerPadding -> ) { innerPadding ->
@@ -775,6 +831,7 @@ fun ChatScreen(
sharedAvatarKey = sharedAvatarKey, sharedAvatarKey = sharedAvatarKey,
subtitleKey = subtitleKey, subtitleKey = subtitleKey,
currentTypingUsers = currentTypingUsers, currentTypingUsers = currentTypingUsers,
typingShowsUsernames = panel.usesPublicGroupSubtitle,
statusConnecting = statusConnecting, statusConnecting = statusConnecting,
statusUpdating = statusUpdating, statusUpdating = statusUpdating,
chatGroupLabel = chatGroupLabel, chatGroupLabel = chatGroupLabel,
@@ -65,7 +65,10 @@ import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.tan import kotlin.math.tan
@@ -85,11 +88,19 @@ fun ChatTopBarInner(
sharedAvatarKey: Any?, sharedAvatarKey: Any?,
subtitleKey: String, subtitleKey: String,
currentTypingUsers: List<TypingUser>, currentTypingUsers: List<TypingUser>,
typingShowsUsernames: Boolean = true,
statusConnecting: String, statusConnecting: String,
statusUpdating: String, statusUpdating: String,
chatGroupLabel: String, chatGroupLabel: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isDeletedPeer = profileUserId?.let { userId ->
peerIsDeleted(
userId = userId,
currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title,
)
} == true
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@@ -118,6 +129,8 @@ fun ChatTopBarInner(
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
) )
.size(40.dp), .size(40.dp),
isDeletedUser = isDeletedPeer,
userId = profileUserId,
) )
} }
@@ -130,6 +143,8 @@ fun ChatTopBarInner(
profilePictureUrl = avatar.profilePictureUrl, profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName, displayName = avatar.displayName,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
isDeletedUser = isDeletedPeer,
userId = profileUserId,
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
@@ -244,6 +259,7 @@ fun ChatTopBarInner(
key == "typing" -> { key == "typing" -> {
TypingIndicator( TypingIndicator(
typingUsers = currentTypingUsers.map { it.username }, typingUsers = currentTypingUsers.map { it.username },
showUsernames = typingShowsUsernames,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
} }
@@ -6,37 +6,41 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.message_sender_you import ru.fromchat.message_sender_you
import ru.fromchat.user_fallback import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.isDeletedAccountUsername
import ru.fromchat.ui.profile.peerIsDeleted
private val userIdUsernamePattern = Regex("^User (\\d+)$") private val userIdUsernamePattern = Regex("^User (\\d+)$")
/** /**
* Resolves [Message.username] for display: localized «Вы», «Пользователь N», or server-provided name. * Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
*/ */
@Composable @Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String { 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) ProfileCache.get(message.user_id)?.let { profile ->
val isCachedUserHidden = cachedProfile?.let { if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) {
it.id != currentUserId && (it.deleted == true || it.suspended == true) return deletedUserDisplayNameForUi()
} == true }
if (isCachedUserHidden) {
return stringResource(Res.string.user_fallback, message.user_id)
} }
val cachedUsername = cachedProfile?.visibleUsername(currentUserId) if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi()
}
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
if (cachedUsername != null) return cachedUsername if (cachedUsername != null) return cachedUsername
if (message.username.equals("deleted", ignoreCase = true)) { if (message.username.equals("deleted", ignoreCase = true)) {
return stringResource(Res.string.user_fallback, message.user_id) return deletedUserDisplayNameForUi()
} }
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()
if (id != null) return stringResource(Res.string.user_fallback, id) if (id != null) return deletedUserDisplayNameForUi()
} }
return message.username return message.username
} }
@@ -45,6 +49,9 @@ fun messageSenderProfilePicture(
message: Message, message: Message,
currentUserId: Int? = ApiClient.user?.id, currentUserId: Int? = ApiClient.user?.id,
): String? { ): String? {
if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) {
return null
}
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
return message.profile_picture?.takeIf { it.isNotBlank() } return message.profile_picture?.takeIf { it.isNotBlank() }
?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() } ?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() }
@@ -53,14 +60,24 @@ fun messageSenderProfilePicture(
?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() } ?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() }
} }
fun messageSenderIsDeleted(message: Message, currentUserId: Int? = ApiClient.user?.id): Boolean =
peerIsDeleted(
userId = message.user_id,
currentUserId = currentUserId,
username = message.username,
)
fun messageSenderAvatarLabel( fun messageSenderAvatarLabel(
message: Message, message: Message,
currentUserId: Int? = ApiClient.user?.id, currentUserId: Int? = ApiClient.user?.id,
): String { ): String {
if (messageSenderIsDeleted(message, currentUserId)) return ""
ProfileCache.get(message.user_id)
?.avatarLabelForInitials(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty() return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim().ifBlank {
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty()
} }
return message.username.trim()
} }
@@ -8,7 +8,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -57,8 +56,8 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.ui.chat.messageSenderAvatarLabel
import ru.fromchat.api.local.messages.formatMessageTimeLocal import ru.fromchat.api.local.messages.formatMessageBubbleTimeLocal
import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.message_corrupted import ru.fromchat.message_corrupted
@@ -69,6 +68,7 @@ import ru.fromchat.ui.chat.components.getReplyMessageGradient
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.isAppInDarkTheme
import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -119,7 +119,7 @@ fun MessageItem(
isMessageCorrupted(message) isMessageCorrupted(message)
} }
val formattedTime = remember(message.timestamp) { val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp) formatMessageBubbleTimeLocal(message.timestamp)
} }
val corruptedBody = stringResource(Res.string.message_corrupted) val corruptedBody = stringResource(Res.string.message_corrupted)
val editedSuffix = stringResource(Res.string.message_edited_suffix) val editedSuffix = stringResource(Res.string.message_edited_suffix)
@@ -128,9 +128,9 @@ fun MessageItem(
val senderProfile = ProfileCache.get(message.user_id) val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture ?: message.profile_picture
val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() } val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
?: message.username
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to val replyRef = message.reply_to
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first // No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
@@ -193,7 +193,9 @@ fun MessageItem(
Avatar( Avatar(
profilePictureUrl = avatarPictureUrl, profilePictureUrl = avatarPictureUrl,
displayName = avatarDisplayName, displayName = avatarDisplayName,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp),
isDeletedUser = isDeletedSender,
userId = message.user_id,
) )
} }
@@ -210,7 +212,7 @@ fun MessageItem(
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
) { ) {
// Message bubble // Message bubble
val isDark = isSystemInDarkTheme() val isDark = isAppInDarkTheme()
val pendingIsImage = when { val pendingIsImage = when {
message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename)
message.pendingFileUri != null -> isImageFilename( message.pendingFileUri != null -> isImageFilename(
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.typing_alone
import ru.fromchat.typing_many import ru.fromchat.typing_many
import ru.fromchat.typing_single import ru.fromchat.typing_single
import ru.fromchat.typing_two import ru.fromchat.typing_two
@@ -29,6 +30,7 @@ import ru.fromchat.typing_two
@Composable @Composable
fun TypingIndicator( fun TypingIndicator(
typingUsers: List<String>, typingUsers: List<String>,
showUsernames: Boolean = true,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (typingUsers.isEmpty()) return if (typingUsers.isEmpty()) return
@@ -40,7 +42,7 @@ fun TypingIndicator(
TypingDots() TypingDots()
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = formatTypingText(typingUsers), text = formatTypingText(typingUsers, showUsernames),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -48,11 +50,13 @@ fun TypingIndicator(
} }
@Composable @Composable
private fun formatTypingText(typingUsers: List<String>): String { private fun formatTypingText(typingUsers: List<String>, showUsernames: Boolean): String {
return when (typingUsers.size) { return when {
0 -> "" typingUsers.isEmpty() -> ""
1 -> stringResource(Res.string.typing_single, typingUsers[0]) !showUsernames && typingUsers.size == 1 ->
2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1]) stringResource(Res.string.typing_alone)
typingUsers.size == 1 -> stringResource(Res.string.typing_single, typingUsers[0])
typingUsers.size == 2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1])
else -> stringResource( else -> stringResource(
Res.string.typing_many, Res.string.typing_many,
typingUsers[0], typingUsers[0],
@@ -28,7 +28,8 @@ import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.DmDeletedData import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.ui.profile.displayNameText
import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
@@ -106,42 +107,55 @@ class DmPanel(
if (_state.title.isBlank()) { if (_state.title.isBlank()) {
loadPeerTitleFromConversationCache() loadPeerTitleFromConversationCache()
} }
runCatching { try {
ApiClient.getProfileById(otherUserId) val profile = ApiClient.getProfileById(otherUserId)
}.onSuccess { profile -> if (
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { !profile.isDeletedAccount(ApiClient.user?.id) &&
profile.username.isBlank() &&
profile.displayName.isNullOrBlank()
) {
ProfileCache.evictUnusableClientPreview(otherUserId) ProfileCache.evictUnusableClientPreview(otherUserId)
if (_state.title.isBlank()) { if (_state.title.isBlank()) {
withContext(Dispatchers.Main) {
updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
}
}
return@launch
}
ProfileCache.put(profile)
val displayName = profile.displayNameText(ApiClient.user?.id)
if (displayName.isNotBlank()) {
withContext(Dispatchers.Main) {
applyPeerTitle(displayName, profile.profilePicture)
}
}
} catch (_: Throwable) {
ProfileCache.evictUnusableClientPreview(otherUserId)
if (_state.title.isBlank()) {
withContext(Dispatchers.Main) {
updateState { updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
} }
} }
return@onSuccess
}
ProfileCache.put(profile)
val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty()
if (displayName.isNotBlank()) {
applyPeerTitle(displayName, profile.profilePicture)
}
}.onFailure {
ProfileCache.evictUnusableClientPreview(otherUserId)
if (_state.title.isBlank()) {
updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
} }
} }
} }
} }
private fun applyCachedPeerProfileOrReset() { private fun applyCachedPeerProfileOrReset() {
val cached = ProfileCache.get(otherUserId) scope.launch(Dispatchers.Default) {
val displayName = cached?.visibleDisplayName(ApiClient.user?.id).orEmpty() val cached = ProfileCache.get(otherUserId)
if (displayName.isNotBlank()) { val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
applyPeerTitle(displayName, cached?.profilePicture) withContext(Dispatchers.Main) {
} else { if (displayName.isNotBlank()) {
updateState { applyPeerTitle(displayName, cached?.profilePicture)
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) } else {
updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
}
} }
} }
} }
@@ -423,6 +437,9 @@ class DmPanel(
pendingFileAspectRatio = aspect, pendingFileAspectRatio = aspect,
fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) }, fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) },
fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions, fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions,
reply_to = envelope.replyToId?.let { replyId ->
_state.messages.find { it.id == replyId }
} ?: stateSourceBeforeMerge?.reply_to,
) )
val mergedForPersistence = merged.copy(pendingFilename = null) val mergedForPersistence = merged.copy(pendingFilename = null)
AttachmentMediaLog.persist( AttachmentMediaLog.persist(
@@ -461,10 +478,6 @@ class DmPanel(
val deduped = dedupeMessagesByClientId(newMessages) val deduped = dedupeMessagesByClientId(newMessages)
currentState.copy(messages = deduped) currentState.copy(messages = deduped)
} }
if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
}
} }
if (cid.isNotEmpty()) { if (cid.isNotEmpty()) {
@@ -87,6 +87,9 @@ import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.unread_count_badge import ru.fromchat.unread_count_badge
import ru.fromchat.unread_count_overflow import ru.fromchat.unread_count_overflow
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.displayNameForUi
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.user_fallback import ru.fromchat.user_fallback
internal object ChatListLayout { internal object ChatListLayout {
@@ -394,12 +397,33 @@ internal fun SearchConversationsList(
resultIndex++ resultIndex++
item { item {
val cached = ProfileCache.get(user.id) val cached = ProfileCache.get(user.id)
val avatarUrl = cached?.profilePicture ?: user.profile_picture val isPeerDeleted = peerIsDeleted(
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } userId = user.id,
?: user.displayName?.takeIf { it.isNotBlank() } currentUserId = ApiClient.user?.id,
?: cached?.visibleUsername(ApiClient.user?.id) deleted = user.deleted ?: cached?.deleted,
?: user.username username = user.username,
val username = cached?.visibleUsername(ApiClient.user?.id) ?: user.username )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
val peerTitle = if (isPeerDeleted) {
deletedUserDisplayNameForUi()
} else {
cached?.displayName?.takeIf { it.isNotBlank() }
?: user.displayName?.takeIf { it.isNotBlank() }
?: cached?.visibleUsername(ApiClient.user?.id)
?: user.username
}
val avatarInitialsLabel = if (isPeerDeleted) {
deletedUserDisplayNameForUi()
} else {
cached?.displayName?.takeIf { it.isNotBlank() }
?: user.displayName?.takeIf { it.isNotBlank() }
?: ""
}
val username = if (isPeerDeleted) {
deletedUserDisplayNameForUi()
} else {
cached?.visibleUsername(ApiClient.user?.id) ?: user.username
}
ChatRowScaleContainer( ChatRowScaleContainer(
listItemPosition = position, listItemPosition = position,
@@ -418,11 +442,13 @@ internal fun SearchConversationsList(
leadingContent = { leadingContent = {
ChatRowAvatar( ChatRowAvatar(
profilePictureUrl = avatarUrl, profilePictureUrl = avatarUrl,
displayNameForInitials = peerTitle, displayNameForInitials = avatarInitialsLabel,
enabled = false, enabled = false,
onPressStart = {}, onPressStart = {},
onPressEnd = {}, onPressEnd = {},
onLongPress = {}, onLongPress = {},
isDeletedUser = isPeerDeleted,
userId = user.id,
) )
}, },
) )
@@ -503,6 +529,8 @@ internal fun ChatRowAvatar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showOnlineIndicator: Boolean = false, showOnlineIndicator: Boolean = false,
onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
isDeletedUser: Boolean = false,
userId: Int? = null,
) { ) {
Box( Box(
modifier modifier
@@ -526,6 +554,8 @@ internal fun ChatRowAvatar(
profilePictureUrl = profilePictureUrl, profilePictureUrl = profilePictureUrl,
displayName = displayNameForInitials, displayName = displayNameForInitials,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
isDeletedUser = isDeletedUser,
userId = userId,
) )
if (showOnlineIndicator) { if (showOnlineIndicator) {
Box( Box(
@@ -830,13 +860,27 @@ internal fun DmConversationRowContent(
onBodyLongPress: () -> Unit, onBodyLongPress: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentUserId = ApiClient.user?.id
val cached = ProfileCache.get(conversation.otherUserId) val cached = ProfileCache.get(conversation.otherUserId)
val avatarUrl = cached?.profilePicture val isPeerDeleted = peerIsDeleted(
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } userId = conversation.otherUserId,
?: cached?.visibleUsername(ApiClient.user?.id) currentUserId = currentUserId,
?: conversation.displayName.ifBlank { deleted = cached?.deleted,
stringResource(Res.string.user_fallback, conversation.otherUserId) username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
} )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
val peerTitle = when {
isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
conversation.displayName.isNotBlank() -> conversation.displayName
else -> stringResource(Res.string.user_fallback, conversation.otherUserId)
}
val avatarInitialsLabel = when {
isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
conversation.displayName.isNotBlank() -> conversation.displayName
else -> ""
}
val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage } val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage }
val status = statusMap[conversation.otherUserId] val status = statusMap[conversation.otherUserId]
val typingUsers = status?.typingUsernames.orEmpty() val typingUsers = status?.typingUsernames.orEmpty()
@@ -869,13 +913,15 @@ internal fun DmConversationRowContent(
) )
ChatRowAvatar( ChatRowAvatar(
profilePictureUrl = avatarUrl, profilePictureUrl = avatarUrl,
displayNameForInitials = peerTitle, displayNameForInitials = avatarInitialsLabel,
enabled = avatarEnabled, enabled = avatarEnabled,
onPressStart = onAvatarPressStart, onPressStart = onAvatarPressStart,
onPressEnd = onAvatarPressEnd, onPressEnd = onAvatarPressEnd,
onLongPress = onAvatarLongPress, onLongPress = onAvatarLongPress,
showOnlineIndicator = isOnline, showOnlineIndicator = isOnline,
onlineIndicatorBorderColor = listSurfaceColor, onlineIndicatorBorderColor = listSurfaceColor,
isDeletedUser = isPeerDeleted,
userId = conversation.otherUserId,
) )
} }
}, },
@@ -30,6 +30,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.rounded.Block
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.ListItemPosition
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -112,8 +115,7 @@ import ru.fromchat.public_chat
import ru.fromchat.search_title import ru.fromchat.search_title
import ru.fromchat.status_connecting import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
import ru.fromchat.suspend_chat_banner_message import ru.fromchat.account_suspended
import ru.fromchat.suspended_default_reason
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BackHandler
@@ -122,8 +124,7 @@ import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.PredictiveBackHandler import ru.fromchat.ui.components.PredictiveBackHandler
import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.SuspendedAccountBannerStyle import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.components.SuspendedAccountNoticeHost
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -250,7 +251,7 @@ private fun ChatsSelectionTopBar(
Box { Box {
IconButton(onClick = { overflowOpen = true }) { IconButton(onClick = { overflowOpen = true }) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Filled.MoreVert,
contentDescription = moreActionsCd, contentDescription = moreActionsCd,
) )
} }
@@ -330,6 +331,7 @@ fun ChatsTab(
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 suspensionState by ApiClient.suspensionState.collectAsState()
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
@@ -519,7 +521,7 @@ fun ChatsTab(
} }
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) { LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
runCatching { runCatching {
ApiClient.getDmConversations() ApiClient.getDmConversations()
@@ -546,8 +548,7 @@ fun ChatsTab(
val updatingTitle = stringResource(Res.string.status_updating) val updatingTitle = stringResource(Res.string.status_updating)
val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0 val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount) val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) val accountSuspendedTitle = stringResource(Res.string.account_suspended)
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason)
val publicChatFallbackTitle = stringResource(Res.string.public_chat) val publicChatFallbackTitle = stringResource(Res.string.public_chat)
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() } val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() } ?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
@@ -696,15 +697,25 @@ fun ChatsTab(
} }
} }
SuspendedAccountNoticeHost( if (suspensionState.isSuspended) {
isSuspended = suspensionState.isSuspended, ListItem(
reason = suspensionState.reason, headline = accountSuspendedTitle,
fallbackReason = suspendDefaultReason, position = ListItemPosition.START,
bannerTitle = suspendBannerTitle, groupItemCount = 1,
style = SuspendedAccountBannerStyle.Tabs, divider = false,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), leadingContent = {
) Icon(
imageVector = Icons.Rounded.Block,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { showSuspendedSupportSheet = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
} else {
ChatConversationsList( ChatConversationsList(
listState = tabListState, listState = tabListState,
listFilter = ChatListFilter.Active, listFilter = ChatListFilter.Active,
@@ -797,6 +808,7 @@ fun ChatsTab(
} }
}, },
) )
}
} }
} }
@@ -903,6 +915,11 @@ fun ChatsTab(
} }
} }
} }
SuspendedAccountSupportSheet(
isVisible = showSuspendedSupportSheet,
onDismissRequest = { showSuspendedSupportSheet = false },
)
} }
} }
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -37,6 +38,9 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -54,6 +58,7 @@ import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.automirrored.filled.Subject import androidx.compose.material.icons.automirrored.filled.Subject
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
@@ -98,7 +103,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@@ -108,12 +116,17 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -158,6 +171,8 @@ import ru.fromchat.logs_rotate
import ru.fromchat.logs_rotate_confirm_body import ru.fromchat.logs_rotate_confirm_body
import ru.fromchat.logs_rotate_confirm_title import ru.fromchat.logs_rotate_confirm_title
import ru.fromchat.logs_scroll_to_bottom_cd import ru.fromchat.logs_scroll_to_bottom_cd
import ru.fromchat.logs_search
import ru.fromchat.logs_search_hint
import ru.fromchat.logs_selected_count import ru.fromchat.logs_selected_count
import ru.fromchat.logs_share import ru.fromchat.logs_share
import ru.fromchat.logs_share_compressed import ru.fromchat.logs_share_compressed
@@ -167,6 +182,7 @@ import ru.fromchat.logs_share_uncompressed
import ru.fromchat.logs_share_uncompressed_desc import ru.fromchat.logs_share_uncompressed_desc
import ru.fromchat.logs_title import ru.fromchat.logs_title
import ru.fromchat.more import ru.fromchat.more
import ru.fromchat.search_not_found
import ru.fromchat.logging.AppLogEntry import ru.fromchat.logging.AppLogEntry
import ru.fromchat.logging.AppLogLevel import ru.fromchat.logging.AppLogLevel
import ru.fromchat.logging.AppLogStore import ru.fromchat.logging.AppLogStore
@@ -218,6 +234,8 @@ fun LogsScreen() {
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val density = LocalDensity.current val density = LocalDensity.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val topAppBarState = rememberTopAppBarState() val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -246,7 +264,21 @@ fun LogsScreen() {
var listMode by remember { mutableStateOf(LogsListMode.Normal) } var listMode by remember { mutableStateOf(LogsListMode.Normal) }
var selectedEntryIds by remember { mutableStateOf<Set<Long>>(emptySet()) } var selectedEntryIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
var searchMode by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
val listEntries = remember(displayEntries, searchMode, searchQuery) {
if (!searchMode || searchQuery.isBlank()) {
displayEntries
} else {
val needle = searchQuery.lowercase()
displayEntries.filter { entry ->
entry.displayText().lowercase().contains(needle)
}
}
}
val selectionTransitionProgress = remember { Animatable(0f) } val selectionTransitionProgress = remember { Animatable(0f) }
val searchTransitionProgress = remember { Animatable(0f) }
val searchFocusRequester = remember { FocusRequester() }
val gestureState = rememberLogsListGestureState() val gestureState = rememberLogsListGestureState()
var dragAnchorIndex by remember { mutableIntStateOf(-1) } var dragAnchorIndex by remember { mutableIntStateOf(-1) }
var dragLastY by remember { mutableFloatStateOf(0f) } var dragLastY by remember { mutableFloatStateOf(0f) }
@@ -273,13 +305,22 @@ fun LogsScreen() {
val shareTitle = stringResource(Res.string.logs_title) val shareTitle = stringResource(Res.string.logs_title)
val selectionMode = listMode == LogsListMode.Selecting val selectionMode = listMode == LogsListMode.Selecting
val selectionProgress = selectionTransitionProgress.value val selectionProgress = selectionTransitionProgress.value
val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode val searchProgress = searchTransitionProgress.value
val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode && !searchMode
val showScrollToBottomFab = isViewingCurrent && val showScrollToBottomFab = isViewingCurrent &&
!selectionMode && !selectionMode &&
!searchMode &&
displayEntries.isNotEmpty() && displayEntries.isNotEmpty() &&
!isAtBottom && !isAtBottom &&
!isProgrammaticScroll !isProgrammaticScroll
val scrollToBottomCd = stringResource(Res.string.logs_scroll_to_bottom_cd) val scrollToBottomCd = stringResource(Res.string.logs_scroll_to_bottom_cd)
val logsSearchLabel = stringResource(Res.string.logs_search)
val logsSearchHint = stringResource(Res.string.logs_search_hint)
val searchNotFoundLabel = stringResource(Res.string.search_not_found)
val hideIme: () -> Unit = {
resetFocus(keyboardController, focusManager)
}
val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedEntryIds.size) val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedEntryIds.size)
val closeSelectionCd = stringResource(Res.string.cd_close_selection) val closeSelectionCd = stringResource(Res.string.cd_close_selection)
@@ -287,6 +328,20 @@ fun LogsScreen() {
val shareLabel = stringResource(Res.string.logs_share) val shareLabel = stringResource(Res.string.logs_share)
val deleteLabel = stringResource(Res.string.action_delete) val deleteLabel = stringResource(Res.string.action_delete)
fun exitSearchMode() {
searchQuery = ""
searchMode = false
hideIme()
scope.launch { searchTransitionProgress.snapTo(0f) }
}
fun requestExitSearchMode() {
scope.launch {
searchTransitionProgress.animateTo(0f, ChatSelectionTransitionSpring)
exitSearchMode()
}
}
fun scrollToLatestLogs() { fun scrollToLatestLogs() {
if (displayEntries.isEmpty()) return if (displayEntries.isEmpty()) return
scope.launch { scope.launch {
@@ -339,6 +394,14 @@ fun LogsScreen() {
} }
} }
fun enterSearchMode() {
if (selectionMode) {
exitEntrySelection()
}
scope.launch { searchTransitionProgress.snapTo(0f) }
searchMode = true
}
fun performShare(compression: LogShareCompression) { fun performShare(compression: LogShareCompression) {
val request = pendingShareRequest ?: return val request = pendingShareRequest ?: return
scope.launch { scope.launch {
@@ -361,19 +424,20 @@ fun LogsScreen() {
} }
fun applyDragSelectionRange(toIndex: Int) { fun applyDragSelectionRange(toIndex: Int) {
if (searchMode) return
val anchor = dragAnchorIndex val anchor = dragAnchorIndex
if (anchor < 0 || toIndex < 0) return if (anchor < 0 || toIndex < 0) return
val start = minOf(anchor, toIndex) val start = minOf(anchor, toIndex)
val end = maxOf(anchor, toIndex) val end = maxOf(anchor, toIndex)
selectedEntryIds = displayEntries.subList(start, end + 1).map { it.id }.toSet() selectedEntryIds = listEntries.subList(start, end + 1).map { it.id }.toSet()
} }
fun beginDragSelection(index: Int) { fun beginDragSelection(index: Int) {
if (index !in displayEntries.indices) return if (searchMode || index !in listEntries.indices) return
gestureState.onDragSelectionStart() gestureState.onDragSelectionStart()
dragAnchorIndex = index dragAnchorIndex = index
if (!selectionMode) { if (!selectionMode) {
enterEntrySelection(displayEntries[index].id) enterEntrySelection(listEntries[index].id)
} else { } else {
applyDragSelectionRange(index) applyDragSelectionRange(index)
} }
@@ -414,11 +478,12 @@ fun LogsScreen() {
} }
} }
LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode) { LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode, searchMode) {
if ( if (
isViewingCurrent && isViewingCurrent &&
displayEntries.isNotEmpty() && displayEntries.isNotEmpty() &&
!selectionMode && !selectionMode &&
!searchMode &&
followLatest followLatest
) { ) {
isProgrammaticScroll = true isProgrammaticScroll = true
@@ -462,8 +527,8 @@ fun LogsScreen() {
} }
} }
LaunchedEffect(selectionMode) { LaunchedEffect(selectionMode, searchMode) {
if (selectionMode) { if (selectionMode || searchMode) {
followLatest = false followLatest = false
} else if (isAtBottom) { } else if (isAtBottom) {
followLatest = true followLatest = true
@@ -476,6 +541,13 @@ fun LogsScreen() {
} }
} }
LaunchedEffect(searchMode) {
if (searchMode) {
searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
searchFocusRequester.requestFocus()
}
}
LaunchedEffect(selectedEntryIds, listMode) { LaunchedEffect(selectedEntryIds, listMode) {
if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) { if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) {
requestExitEntrySelection() requestExitEntrySelection()
@@ -483,7 +555,10 @@ fun LogsScreen() {
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { exitEntrySelection() } onDispose {
exitEntrySelection()
exitSearchMode()
}
} }
if (showDecompressDialog) { if (showDecompressDialog) {
@@ -606,6 +681,7 @@ fun LogsScreen() {
} }
BackHandler(enabled = selectionMode) { requestExitEntrySelection() } BackHandler(enabled = selectionMode) { requestExitEntrySelection() }
BackHandler(enabled = searchMode && !selectionMode) { requestExitSearchMode() }
PredictiveBackHandler( PredictiveBackHandler(
enabled = selectionMode, enabled = selectionMode,
onProgress = { backProgress -> onProgress = { backProgress ->
@@ -622,7 +698,26 @@ fun LogsScreen() {
} }
}, },
) )
PredictiveBackHandler(
enabled = searchMode && !selectionMode,
onProgress = { backProgress ->
scope.launch {
searchTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f))
}
},
onCommit = { requestExitSearchMode() },
onCancel = {
if (searchMode) {
scope.launch {
searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
}
}
},
)
if (searchMode) {
ToggleNavScrimEffect()
}
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
@@ -632,7 +727,7 @@ fun LogsScreen() {
contentWindowInsets = WindowInsets.navigationBars, contentWindowInsets = WindowInsets.navigationBars,
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
val fabReveal = (1f - selectionProgress).coerceIn(0f, 1f) val fabReveal = ((1f - selectionProgress) * (1f - searchProgress)).coerceIn(0f, 1f)
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -657,11 +752,13 @@ fun LogsScreen() {
topBar = { topBar = {
Box { Box {
TopAppBar( TopAppBar(
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, modifier = Modifier.graphicsLayer {
alpha = (1f - selectionProgress) * (1f - searchProgress)
},
navigationIcon = { navigationIcon = {
IconButton( IconButton(
onClick = { navController.navigateUp() }, onClick = { navController.navigateUp() },
enabled = selectionProgress < 1f, enabled = selectionProgress < 1f && searchProgress < 1f,
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
@@ -682,7 +779,7 @@ fun LogsScreen() {
} }
}, },
actions = { actions = {
if (isViewingCurrent && !selectionMode) { if (isViewingCurrent && !selectionMode && searchProgress < 1f) {
IconButton( IconButton(
onClick = { onClick = {
pendingShareRequest = LogsShareRequest( pendingShareRequest = LogsShareRequest(
@@ -699,7 +796,10 @@ fun LogsScreen() {
} }
} }
Box { Box {
IconButton(onClick = { menuExpanded = true }) { IconButton(
onClick = { menuExpanded = true },
enabled = searchProgress < 1f,
) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(Res.string.more), contentDescription = stringResource(Res.string.more),
@@ -709,6 +809,16 @@ fun LogsScreen() {
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }, onDismissRequest = { menuExpanded = false },
) { ) {
DropdownMenuItem(
text = { Text(logsSearchLabel) },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
onClick = {
menuExpanded = false
enterSearchMode()
},
)
if (isViewingCurrent) { if (isViewingCurrent) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(Res.string.logs_rotate)) }, text = { Text(stringResource(Res.string.logs_rotate)) },
@@ -750,6 +860,66 @@ fun LogsScreen() {
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
if (searchMode || searchProgress > 0f) {
TopAppBar(
modifier = Modifier.graphicsLayer { alpha = searchProgress },
colors = logsTransparentTopAppBarColors(),
navigationIcon = {
IconButton(
onClick = { requestExitSearchMode() },
enabled = searchProgress > 0f,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
)
}
},
title = {
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.titleLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { hideIme() }),
modifier = Modifier
.fillMaxWidth()
.focusRequester(searchFocusRequester),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (searchQuery.isEmpty()) {
Text(
text = logsSearchHint,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
innerTextField()
}
},
)
},
actions = {
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { searchQuery = "" },
enabled = searchProgress > 0f,
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(Res.string.cancel),
)
}
}
},
)
}
if (selectionMode || selectionProgress > 0f) { if (selectionMode || selectionProgress > 0f) {
TopAppBar( TopAppBar(
modifier = Modifier.graphicsLayer { alpha = selectionProgress }, modifier = Modifier.graphicsLayer { alpha = selectionProgress },
@@ -806,11 +976,13 @@ fun LogsScreen() {
} }
}, },
) { innerPadding -> ) { innerPadding ->
if (displayEntries.isEmpty()) { when {
displayEntries.isEmpty() -> {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@@ -820,7 +992,26 @@ fun LogsScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} else { }
searchMode && searchQuery.isNotBlank() && listEntries.isEmpty() -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.imePadding(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = searchNotFoundLabel,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> {
val listContent: @Composable () -> Unit = { val listContent: @Composable () -> Unit = {
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -837,7 +1028,7 @@ fun LogsScreen() {
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
itemsIndexed( itemsIndexed(
items = displayEntries, items = listEntries,
key = { _, entry -> entry.id }, key = { _, entry -> entry.id },
) { index, entry -> ) { index, entry ->
LogEntryRow( LogEntryRow(
@@ -861,14 +1052,23 @@ fun LogsScreen() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier),
) { ) {
listContent() listContent()
} }
}
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun logsTransparentTopAppBarColors() = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
)
@Composable @Composable
internal fun LogsAnimatedFab( internal fun LogsAnimatedFab(
visible: Boolean, visible: Boolean,
@@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -33,6 +34,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ensureFcmTokenRegistered import ru.fromchat.api.ensureFcmTokenRegistered
import ru.fromchat.api.isFcmPushRegisteredLocally
import ru.fromchat.api.unregisterFcmTokenFromServer import ru.fromchat.api.unregisterFcmTokenFromServer
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.error_unexpected import ru.fromchat.error_unexpected
@@ -51,7 +53,10 @@ fun NotificationsScreen(onBack: () -> Unit) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var notificationsEnabled by remember { mutableStateOf(areAppNotificationsEnabled()) } var notificationsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
notificationsEnabled = areAppNotificationsEnabled() && isFcmPushRegisteredLocally()
}
val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required) val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required)
val unexpectedErrorText = stringResource(Res.string.error_unexpected) val unexpectedErrorText = stringResource(Res.string.error_unexpected)
@@ -86,23 +91,27 @@ fun NotificationsScreen(onBack: () -> Unit) {
Icon(Icons.Filled.Notifications, null) Icon(Icons.Filled.Notifications, null)
}, },
checked = notificationsEnabled, checked = notificationsEnabled,
onCheckedChange = { onCheckedChange = { enabled ->
coroutineScope.launch { coroutineScope.launch {
if (!areAppNotificationsEnabled()) { if (enabled) {
if (!openAppNotificationSettings()) { if (!areAppNotificationsEnabled()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText) if (!openAppNotificationSettings()) {
} else { snackbarHostState.showSnackbar(message = unexpectedErrorText)
snackbarHostState.showSnackbar(message = notificationsPermissionText) } else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
}
return@launch
} }
return@launch
}
if (notificationsEnabled) { val registered = ensureFcmTokenRegistered()
unregisterFcmTokenFromServer() if (registered) {
notificationsEnabled = !notificationsEnabled notificationsEnabled = true
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
}
} else { } else {
ensureFcmTokenRegistered() unregisterFcmTokenFromServer()
snackbarHostState.showSnackbar(message = unexpectedErrorText) notificationsEnabled = false
} }
} }
}, },
@@ -39,7 +39,6 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.cancel import ru.fromchat.cancel
import ru.fromchat.logout import ru.fromchat.logout
@@ -139,7 +138,6 @@ fun AccountScreen(
showLogoutConfirm = false showLogoutConfirm = false
scope.launch { scope.launch {
runCatching { ApiClient.logout() } runCatching { ApiClient.logout() }
WebSocketManager.disconnect()
onLogout() onLogout()
} }
}, },
@@ -217,7 +217,7 @@ fun EditProfileScreen(
val canSave = loaded && hasChanges && !hasValidationErrors && !busy val canSave = loaded && hasChanges && !hasValidationErrors && !busy
val avatarDisplayName = trimmedDisplayName.ifBlank { trimmedUsername }.ifBlank { "?" } val avatarDisplayName = trimmedDisplayName.ifBlank { "?" }
fun showSnack(text: String) { fun showSnack(text: String) {
scope.launch { scope.launch {
@@ -150,7 +150,12 @@ import ru.fromchat.profile_load_failed
import ru.fromchat.profile_not_found import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_support import ru.fromchat.profile_verified_support
import ru.fromchat.profile_verify_prompt_support import ru.fromchat.profile_verify_prompt_support
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.ui.profile.displayNameForUi
import ru.fromchat.ui.profile.effectiveVerificationStatus import ru.fromchat.ui.profile.effectiveVerificationStatus
import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
@@ -196,6 +201,7 @@ private fun hasDisplayableProfile(
currentUserId: Int?, currentUserId: Int?,
) = !initialDisplayName.isNullOrBlank() || ) = !initialDisplayName.isNullOrBlank() ||
(profile != null && profile.id > 0 && ( (profile != null && profile.id > 0 && (
profile.isDeletedAccount(currentUserId) ||
!profile.visibleDisplayName(currentUserId).isNullOrBlank() || !profile.visibleDisplayName(currentUserId).isNullOrBlank() ||
profile.username.isNotBlank() profile.username.isNotBlank()
)) ))
@@ -461,12 +467,27 @@ fun ProfileScreen(
val showBodySkeleton = resolvedProfile == null val showBodySkeleton = resolvedProfile == null
val showAvatarSkeleton = showBodySkeleton && !hasDisplayable val showAvatarSkeleton = showBodySkeleton && !hasDisplayable
val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
val displayName = val viewerUserId = ownUserId
profile?.visibleDisplayName(currentProfileUserId) val resolvedUserId = resolvedProfile?.id ?: targetUserId
val isDeletedProfile = resolvedUserId != null && peerIsDeleted(
userId = resolvedUserId,
currentUserId = viewerUserId,
deleted = resolvedProfile?.deleted,
username = resolvedProfile?.username,
)
val displayName = when {
isDeletedProfile -> deletedUserDisplayNameForUi()
else -> profile?.displayNameForUi(viewerUserId)?.takeIf { it.isNotBlank() }
?: initialDisplayName?.takeIf { it.isNotBlank() } ?: initialDisplayName?.takeIf { it.isNotBlank() }
?: profile?.username?.takeIf { it.isNotBlank() }
?: "" ?: ""
val usernameForLinks = profile?.visibleUsername(currentProfileUserId) }
val avatarLabel = when {
isDeletedProfile -> displayName
else -> profile?.avatarLabelForInitials(viewerUserId)?.takeIf { it.isNotBlank() }
?: initialDisplayName?.takeIf { it.isNotBlank() }
?: ""
}
val usernameForLinks = if (isDeletedProfile) null else profile?.visibleUsername(viewerUserId)
val profileLink = resolvedProfile?.let { val profileLink = resolvedProfile?.let {
usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } usernameForLinks?.let { name -> "https://fromchat.ru/@$name" }
?: "https://fromchat.ru/?u=${it.id}" ?: "https://fromchat.ru/?u=${it.id}"
@@ -507,6 +528,28 @@ fun ProfileScreen(
onClick = onOpenSettings, onClick = onOpenSettings,
), ),
) )
} else if (p.isDeletedAccount(viewerUserId)) {
listOf(
ProfileAction(
label = labelChat,
icon = Icons.AutoMirrored.Filled.Chat,
holdsExpansionOnNavigate = true,
onClick = { onChat(p.id) },
),
ProfileAction(
label = labelSearch,
icon = Icons.Filled.Search,
onClick = {
scope.launch {
snackbarHostState.showReplacingSnackbar(
message = notImplementedMessage,
withDismissAction = false,
duration = SnackbarDuration.Short,
)
}
},
),
)
} else { } else {
buildList { buildList {
add( add(
@@ -554,8 +597,10 @@ fun ProfileScreen(
val showDetailsUsername = usernameForLinks != null val showDetailsUsername = usernameForLinks != null
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank() val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank() val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
val showDetailsVerify = resolvedProfile?.verified == true || ApiClient.user?.id == 1 val showDetailsVerify = !isDeletedProfile && (
val showDetailsSection = resolvedProfile != null && ( resolvedProfile?.verified == true || ApiClient.user?.id == 1
)
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
) )
@@ -596,14 +641,16 @@ fun ProfileScreen(
with(sharedTransitionScope) { with(sharedTransitionScope) {
Avatar( Avatar(
profilePictureUrl = profile?.profilePicture, profilePictureUrl = profile?.profilePicture,
displayName = displayName, displayName = avatarLabel,
modifier = Modifier modifier = Modifier
.padding(top = profileAvatarTop) .padding(top = profileAvatarTop)
.sharedElement( .sharedElement(
rememberSharedContentState(key = sharedAvatarKey), rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope animatedVisibilityScope = animatedVisibilityScope
) )
.size(104.dp) .size(104.dp),
isDeletedUser = isDeletedProfile,
userId = resolvedUserId,
) )
} }
} }
@@ -614,10 +661,12 @@ fun ProfileScreen(
item { item {
Avatar( Avatar(
profilePictureUrl = profile?.profilePicture, profilePictureUrl = profile?.profilePicture,
displayName = displayName, displayName = avatarLabel,
modifier = Modifier modifier = Modifier
.padding(top = profileAvatarTop) .padding(top = profileAvatarTop)
.size(104.dp) .size(104.dp),
isDeletedUser = isDeletedProfile,
userId = resolvedUserId,
) )
} }
item { Spacer(Modifier.height(12.dp)) } item { Spacer(Modifier.height(12.dp)) }
@@ -665,6 +714,7 @@ fun ProfileScreen(
resolvedProfile = resolvedProfile!!, resolvedProfile = resolvedProfile!!,
displayName = displayName, displayName = displayName,
isOwnProfile = isOwnProfile, isOwnProfile = isOwnProfile,
isDeletedProfile = isDeletedProfile,
typingUsers = typingUsers, typingUsers = typingUsers,
statusState = statusState, statusState = statusState,
statusText = statusText, statusText = statusText,
@@ -1145,6 +1195,7 @@ private fun ProfileLoadedBody(
resolvedProfile: UserProfile, resolvedProfile: UserProfile,
displayName: String, displayName: String,
isOwnProfile: Boolean, isOwnProfile: Boolean,
isDeletedProfile: Boolean,
typingUsers: List<String>, typingUsers: List<String>,
statusState: UserStatus?, statusState: UserStatus?,
statusText: String, statusText: String,
@@ -1204,40 +1255,46 @@ private fun ProfileLoadedBody(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
} }
StatusBadge( if (!isDeletedProfile) {
verificationStatus = resolvedProfile.effectiveVerificationStatus(), StatusBadge(
) verificationStatus = resolvedProfile.effectiveVerificationStatus(),
}
Spacer(Modifier.height(4.dp))
AnimatedContent(
targetState = when {
typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}"
statusState?.online == true -> "online"
else -> "offline"
},
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "profile_status_${resolvedProfile.id}",
) { animatedState ->
if (animatedState.startsWith("typing:")) {
TypingIndicator(typingUsers = typingUsers)
} else {
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = if (animatedState == "online") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
} }
if (!isDeletedProfile) {
Spacer(Modifier.height(4.dp))
AnimatedContent(
targetState = when {
typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}"
statusState?.online == true -> "online"
else -> "offline"
},
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "profile_status_${resolvedProfile.id}",
) { animatedState ->
if (animatedState.startsWith("typing:")) {
TypingIndicator(typingUsers = typingUsers)
} else {
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = if (animatedState == "online") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
} else {
Spacer(Modifier.height(4.dp))
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
ProfileActionButtonRow( ProfileActionButtonRow(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -16,6 +17,7 @@ import ru.fromchat.Res
import ru.fromchat.api.schema.user.profile.VerificationStatus import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.cd_similar_verified import ru.fromchat.cd_similar_verified
import ru.fromchat.cd_verified_account import ru.fromchat.cd_verified_account
import ru.fromchat.cd_account_blocked
@Composable @Composable
fun StatusBadge( fun StatusBadge(
@@ -38,6 +40,13 @@ fun StatusBadge(
tint = Color(0xFFFFA000), tint = Color(0xFFFFA000),
) )
VerificationStatus.Blocked -> Icon(
imageVector = Icons.Rounded.Block,
contentDescription = stringResource(Res.string.cd_account_blocked),
modifier = modifier.size(size),
tint = MaterialTheme.colorScheme.error,
)
VerificationStatus.None, null -> Unit VerificationStatus.None, null -> Unit
} }
} }
@@ -0,0 +1,67 @@
package ru.fromchat.ui.profile
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.shouldHideUsername
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.deleted_account
fun UserProfile.isDeletedAccount(currentUserId: Int? = null): Boolean =
deleted == true && id != currentUserId
fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
suspended == true && deleted != true && id != currentUserId
fun isDeletedAccountUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true
/** True when [userId] should be shown as a deleted account to [currentUserId]. */
fun peerIsDeleted(
userId: Int,
currentUserId: Int? = null,
deleted: Boolean? = null,
username: String? = null,
): Boolean {
if (userId <= 0 || userId == currentUserId) return false
if (deleted == true) return true
if (isDeletedAccountUsername(username)) return true
ProfileCache.get(userId)?.let { profile ->
if (profile.isDeletedAccount(currentUserId)) return true
if (isDeletedAccountUsername(profile.username)) return true
}
return false
}
suspend fun deletedUserDisplayName(): String =
getString(Res.string.deleted_account)
@Composable
fun deletedUserDisplayNameForUi(): String =
stringResource(Res.string.deleted_account)
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
if (isDeletedAccount(currentUserId)) {
return deletedUserDisplayName()
}
return visibleDisplayName(currentUserId).orEmpty()
}
@Composable
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
if (isDeletedAccount(currentUserId)) {
deletedUserDisplayNameForUi()
} else {
visibleDisplayName(currentUserId).orEmpty()
}
/** Display name for avatar initials/gradient only; never falls back to username. */
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
if (isDeletedAccount(currentUserId)) {
""
} else {
displayName?.trim().orEmpty()
}
@@ -25,6 +25,7 @@ import ru.fromchat.month_sep
import ru.fromchat.presence_date_full import ru.fromchat.presence_date_full
import ru.fromchat.presence_date_this_year import ru.fromchat.presence_date_this_year
import ru.fromchat.presence_online import ru.fromchat.presence_online
import ru.fromchat.presence_long_ago
import ru.fromchat.presence_recently import ru.fromchat.presence_recently
import ru.fromchat.presence_today_at import ru.fromchat.presence_today_at
import ru.fromchat.presence_weekday_at import ru.fromchat.presence_weekday_at
@@ -53,6 +54,7 @@ private fun formatFromXmlTemplate(template: String, vararg args: Any): String {
data class LastSeenFormatStrings( data class LastSeenFormatStrings(
val online: String, val online: String,
val recently: String, val recently: String,
val longAgo: String,
val todayAt: String, val todayAt: String,
val yesterdayAt: String, val yesterdayAt: String,
val weekdayAt: String, val weekdayAt: String,
@@ -66,6 +68,7 @@ data class LastSeenFormatStrings(
fun rememberLastSeenFormatStrings(): LastSeenFormatStrings { fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
val online = stringResource(Res.string.presence_online) val online = stringResource(Res.string.presence_online)
val recently = stringResource(Res.string.presence_recently) val recently = stringResource(Res.string.presence_recently)
val longAgo = stringResource(Res.string.presence_long_ago)
val todayAt = stringResource(Res.string.presence_today_at) val todayAt = stringResource(Res.string.presence_today_at)
val yesterdayAt = stringResource(Res.string.presence_yesterday_at) val yesterdayAt = stringResource(Res.string.presence_yesterday_at)
val weekdayAt = stringResource(Res.string.presence_weekday_at) val weekdayAt = stringResource(Res.string.presence_weekday_at)
@@ -91,7 +94,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
val mNov = stringResource(Res.string.month_nov) val mNov = stringResource(Res.string.month_nov)
val mDec = stringResource(Res.string.month_dec) val mDec = stringResource(Res.string.month_dec)
return remember( return remember(
online, recently, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull, online, recently, longAgo, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull,
mon, tue, wed, thu, fri, sat, sun, mon, tue, wed, thu, fri, sat, sun,
mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec
) { ) {
@@ -99,6 +102,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
LastSeenFormatStrings( LastSeenFormatStrings(
online = online, online = online,
recently = recently, recently = recently,
longAgo = longAgo,
todayAt = todayAt, todayAt = todayAt,
yesterdayAt = yesterdayAt, yesterdayAt = yesterdayAt,
weekdayAt = weekdayAt, weekdayAt = weekdayAt,
@@ -128,6 +132,7 @@ fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrin
if (online) return s.online if (online) return s.online
val iso = lastSeenIso ?: return "" val iso = lastSeenIso ?: return ""
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently
if (instant.toEpochMilliseconds() <= 0L) return s.longAgo
val timeZone = TimeZone.currentSystemDefault() val timeZone = TimeZone.currentSystemDefault()
val lastLocal = instant.toLocalDateTime(timeZone) val lastLocal = instant.toLocalDateTime(timeZone)
@@ -13,3 +13,5 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean {
// iOS does not use FCM token management in this app build. // iOS does not use FCM token management in this app build.
return false return false
} }
actual suspend fun isFcmPushRegisteredLocally(): Boolean = false