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.firebase.messaging.FirebaseMessaging
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.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import ru.fromchat.Logger
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.config.ServerConfig
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -34,14 +28,8 @@ private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutin
private suspend fun postFcmToken(token: String): Boolean {
return runCatching {
val suffix = token.takeLast(8)
ApiClient.http
.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")
ApiClient.registerFcmToken(token)
Logger.i("FcmReg", "Uploaded FCM token to server: ...${token.takeLast(8)}")
true
}.getOrElse { e ->
Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e)
@@ -53,8 +41,11 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
try {
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
Logger.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
if (ApiClient.token.isNullOrEmpty()) {
Logger.d("FcmReg", "Auth token missing; deferring FCM token upload")
return@withContext
}
if (pending.isBlank()) {
return@withContext
}
@@ -102,22 +93,21 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc
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)}")
return@withContext runCatching {
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
header("Content-Type", "application/json")
if (token.isNotEmpty()) {
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
}
}
ApiClient.unregisterFcmToken(token.takeIf { it.isNotBlank() })
settings.remove(PENDING_FCM_TOKEN_KEY)
if (token.isNotBlank()) {
settings.remove(CURRENT_FCM_TOKEN_KEY)
}
settings.remove(CURRENT_FCM_TOKEN_KEY)
true
}.getOrElse { e ->
Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
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_title
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.profile.avatarLabelForInitials
import kotlin.math.roundToInt
import kotlin.math.sqrt
@@ -731,7 +732,8 @@ private fun SoloCallParticipantVideos(
val lcRef = localCamRefs.firstOrNull()
val self = ApiClient.user
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
Box(
Modifier
@@ -753,7 +755,7 @@ private fun SoloCallParticipantVideos(
}
lcRef != null && !localCamOn -> {
RemoteVideoOffPlaceholder(
displayName = selfName,
displayName = selfAvatarLabel,
profilePictureUrl = selfPic,
audioLevel = localLevel,
modifier = Modifier.fillMaxSize(),
@@ -761,7 +763,7 @@ private fun SoloCallParticipantVideos(
}
else -> {
RemoteVideoOffPlaceholder(
displayName = session.peerDisplayName,
displayName = peerAvatarLabel,
profilePictureUrl = peerPic,
audioLevel = 0f,
modifier = Modifier.fillMaxSize(),
@@ -884,7 +886,8 @@ private fun DuoCallParticipantVideos(
val selfId = self?.id
val selfPic = selfId?.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(selfId).orEmpty()
val youLabel = stringResource(Res.string.message_sender_you)
val controlsReserve = if (showInCallControls) 168.dp else 0.dp
val screenShareMainBottomPad = controlsReserve
@@ -893,7 +896,7 @@ private fun DuoCallParticipantVideos(
VideoSlot.RemoteScreen, VideoSlot.RemoteCam ->
CallOwnerUi(
name = session.peerDisplayName,
avatarName = session.peerDisplayName,
avatarName = peerAvatarLabel,
pictureUrl = peerPic,
level = remoteLevel,
isSelf = false,
@@ -901,7 +904,7 @@ private fun DuoCallParticipantVideos(
VideoSlot.LocalCam, VideoSlot.LocalScreen ->
CallOwnerUi(
name = youLabel,
avatarName = selfName,
avatarName = selfAvatarLabel,
pictureUrl = selfPic,
level = localLevel,
isSelf = true,
@@ -950,7 +953,11 @@ private fun DuoCallParticipantVideos(
}
slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> {
RemoteVideoOffPlaceholder(
displayName = session.peerDisplayName,
displayName = if (slot == VideoSlot.RemoteCam) {
peerAvatarLabel
} else {
selfAvatarLabel
},
profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic,
audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel,
modifier = baseModifier,
@@ -958,7 +965,7 @@ private fun DuoCallParticipantVideos(
}
else -> {
RemoteVideoOffPlaceholder(
displayName = session.peerDisplayName,
displayName = peerAvatarLabel,
profilePictureUrl = peerPic,
audioLevel = remoteLevel,
modifier = Modifier.fillMaxSize(),
@@ -110,6 +110,9 @@
<string name="notif_media_upload_text">Загрузка продолжается в фоне</string>
<string name="message_sender_you">Вы</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_edited_suffix">(изменено)</string>
<string name="message_replying_to">Ответ %1$s</string>
@@ -178,9 +181,11 @@
<string name="verify">Сделать официальным</string>
<string name="unverify">Снять официальный статус</string>
<string name="cd_verified_account">Официальный аккаунт</string>
<string name="cd_account_blocked">Аккаунт заблокирован</string>
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
<string name="presence_online">В сети</string>
<string name="presence_recently">Недавно заходил</string>
<string name="presence_long_ago">был(а) давно</string>
<string name="presence_today_at">Сегодня в %1$s</string>
<string name="presence_yesterday_at">Вчера в %1$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_mb">%1$s МБ</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_logout_confirm_title">Выйти?</string>
@@ -413,6 +420,7 @@
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
<string name="error_unknown">Что-то пошло не так. Попробуйте ещё раз.</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_many">%1$s, %2$s и ещё %3$d печатают…</string>
<string name="more">Ещё</string>
@@ -122,6 +122,9 @@
<!-- Messages -->
<string name="message_sender_you">You</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_edited_suffix">(edited)</string>
<string name="message_replying_to">Reply to %1$s</string>
@@ -200,11 +203,13 @@
<!-- Status badge -->
<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>
<!-- Presence / last seen -->
<string name="presence_online">Online</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_yesterday_at">Yesterday at %1$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_mb">%1$s MB</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_logout_confirm_title">Log out?</string>
@@ -443,6 +450,7 @@
<!-- Typing Indicators -->
<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_many">%1$s, %2$s and %3$d more are typing…</string>
@@ -232,7 +232,7 @@ object ApiClient {
if (response.status.value == 401) {
val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
if (!isCredentialCheck) {
if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch {
runCatching { WebSocketManager.disconnect() }
runCatching { clearLocalSession() }
@@ -408,6 +408,9 @@ object ApiClient {
@Volatile
var user: User? = null
@Volatile
private var logoutInProgress = false
var onAuthError: (() -> Unit)? = null
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? =
@@ -1406,13 +1409,20 @@ object ApiClient {
}
suspend fun logout() {
runCatching {
http.get("${ServerConfig.apiBaseUrl}/logout")
}.onFailure { e ->
ru.fromchat.Logger.e("ApiClient", "Server logout failed", e)
if (logoutInProgress) return
logoutInProgress = true
try {
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")
@@ -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.
* 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.withLock
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.MessageRepository
@@ -38,6 +39,7 @@ private suspend fun activateInstance(instanceId: String) {
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
PublicChatProfileSync.ensureStarted()
ChatListSync.ensureStarted()
scheduleOutboxProcessing(instanceId)
scheduleAttachmentResumeAfterSession()
ApiClient.user?.id?.let { userId ->
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.db
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedFileCache
@@ -48,6 +49,7 @@ suspend fun wipeLocalCacheOnDisk() {
suspend fun clearAccountCacheOnLogout(instanceId: String) {
val id = instanceId.trim()
PublicChatProfileSync.resetOnLogout()
ChatListSync.resetOnLogout()
if (id.isNotEmpty()) {
cancelOutboxProcessing(id)
runCatching { MessageRepository.purgeAllPendingForInstance() }
@@ -99,11 +99,11 @@ object MessageCacheStore {
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
if (instanceId.isBlank()) return emptyList()
val convId = conversationIdForPublic()
val raw = db.messageDatabaseQueries
.selectRecentMessagesByConversation(instanceId, convId, limit)
.executeAsList()
.map { row: DbMessage -> row.toAppMessage() }
.reversed()
val raw = hydrateReplyReferences(
db.messageDatabaseQueries
.selectRecentMessagesByConversation(instanceId, convId, limit)
.executeAsList(),
).reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay(
@@ -345,8 +345,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
val upserts = conversations.map { conv ->
val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim()
val displayLabel = when {
conv.user.deleted == true -> ""
else -> conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim()
}
val localUnread = db.messageDatabaseQueries
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
.executeAsOne()
@@ -388,6 +391,7 @@ object MessageCacheStore {
pruneEmptyConversationsLocked(iid)
}
}
DmConversationListNotifier.notifyChanged()
}
private data class UpsertDmConversationRow(
@@ -853,10 +857,10 @@ object MessageCacheStore {
val iid = instanceId()
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid)
return withContext(Dispatchers.Default) {
val raw = db.messageDatabaseQueries
val rows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.map { row: DbMessage -> row.toAppMessage() }
val raw = rows.map { it.toAppMessage() }
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
sortMessagesForChatDisplay(
@@ -873,11 +877,10 @@ object MessageCacheStore {
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> {
val iid = instanceId()
return withContext(Dispatchers.Default) {
db.messageDatabaseQueries
val rows = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, conversationId, limit)
.executeAsList()
.map { row: DbMessage -> row.toAppMessage() }
.reversed()
rows.map { it.toAppMessage() }.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 {
val uid = userId.toInt()
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.
*/
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? =
if (shouldHideUsername(currentUserId)) {
@@ -71,18 +74,23 @@ object ProfileCache {
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
val isDeleted = existing?.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) {
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(
UserProfile(
id = id,
username = incomingUsername.orEmpty(),
displayName = incomingDisplayName,
profilePicture = profilePicture?.takeIf { it.isNotBlank() }
profilePicture = if (isDeleted) null else profilePicture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
online = existing?.online ?: false,
@@ -92,7 +100,7 @@ object ProfileCache {
verificationStatus = verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted,
deleted = isDeleted,
isClientPreviewOnly = true,
),
)
@@ -152,16 +160,20 @@ object ProfileCache {
val incomingUsername = user.username.trim()
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
}
put(
UserProfile(
id = user.id,
username = incomingUsername,
displayName = existing?.displayName?.takeIf { it.isNotBlank() }
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingDisplayName,
profilePicture = user.profile_picture?.takeIf { it.isNotBlank() }
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
online = user.online,
@@ -169,9 +181,9 @@ object ProfileCache {
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt,
verified = user.verified ?: existing?.verified,
verificationStatus = user.verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted,
suspended = user.suspended ?: existing?.suspended,
suspensionReason = user.suspensionReason ?: existing?.suspensionReason,
deleted = isDeleted,
isClientPreviewOnly = true,
),
)
@@ -185,8 +197,10 @@ object ProfileCache {
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return
val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
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(
UserProfile(
@@ -202,7 +216,7 @@ object ProfileCache {
verificationStatus = message.verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted,
deleted = isDeleted,
isClientPreviewOnly = true,
),
)
@@ -30,6 +30,23 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
internal fun parseMessageTimestampMillis(timestamp: String): Long? =
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). */
internal fun formatMessageTimeLocal(timestamp: String): String {
val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return ""
@@ -19,5 +19,6 @@ data class User(
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null,
)
@@ -13,6 +13,9 @@ enum class VerificationStatus {
@SerialName("none")
None,
@SerialName("blocked")
Blocked,
}
fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus =
@@ -165,7 +165,6 @@ private fun handleAccountLifecycleEvent(message: WebSocketMessage) {
MainScope().launch {
ApiClient.logout()
}
WebSocketManager.disconnect()
}
}
}
@@ -32,7 +32,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.CallUiState
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.effectiveVerificationStatus
import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -94,7 +94,7 @@ fun CallOverlay(modifier: Modifier = Modifier) {
val me = ApiClient.user?.id
val cached = ProfileCache.get(s.fromUserId)
val title =
cached?.visibleDisplayName(me)?.takeIf { it.isNotBlank() }
cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.user_fallback, s.fromUserId)
Box(
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
@@ -108,6 +109,7 @@ import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress
private val IMAGE_SIZE = 160.dp
private val IMAGE_MAX_HEIGHT = 240.dp
private const val BLUR_FADE_MS = 450
internal fun isImageFilename(name: String): Boolean =
@@ -210,11 +212,12 @@ fun AttachmentPreview(
fileAspectRatio != null && fileAspectRatio > 0f,
`if` = {
Modifier
.aspectRatio(fileAspectRatio!!)
.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE)
.heightIn(max = IMAGE_MAX_HEIGHT)
.widthIn(max = IMAGE_SIZE)
.aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true)
},
`else` = {
Modifier.size(IMAGE_SIZE)
Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT)
}
)
.clip(attachmentImageCornerShape(isAuthor))
@@ -4,6 +4,9 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.getValue
import androidx.compose.runtime.mutableStateOf
@@ -29,8 +32,35 @@ import ru.fromchat.ui.chat.components.getInitials
fun Avatar(
profilePictureUrl: 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) }
val gradient = remember(displayName) { generateGradientFromName(displayName) }
@@ -99,4 +129,3 @@ fun Avatar(
}
}
}
@@ -321,25 +321,35 @@ abstract class ChatPanel(
pending?.first?.cancel()
updateState { currentState ->
val withoutDupReal = if (confirmedMessage.id > 0) {
currentState.messages.filter { it.id != confirmedMessage.id }
val optimistic = currentState.messages.find { it.client_message_id == tempId }
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 {
currentState.messages
}
val hadTemp = withoutDupReal.any { it.client_message_id == tempId }
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 {
hadTemp -> mapped
confirmedMessage.id > 0 && mapped.none { it.id == confirmedMessage.id } ->
mapped + confirmedMessage
resolvedConfirmed.id > 0 && mapped.none { it.id == resolvedConfirmed.id } ->
mapped + resolvedConfirmed
else -> mapped
}
currentState.copy(messages = sortMessagesForChatDisplay(messages))
}
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.LazyListState
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.ExperimentalMaterial3Api
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.db.store.ConnectionStateStore
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.UserStatusStore
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.types.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.action_delete_chat
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.cd_call
import ru.fromchat.chat_group_label
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.imageAttachmentKey
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.formatLastSeen
@@ -167,6 +174,24 @@ fun ChatScreen(
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val suspensionState by ApiClient.suspensionState.collectAsState()
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()
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
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(
text = inputText,
onTextChange = { inputText = it },
@@ -632,6 +687,7 @@ fun ChatScreen(
}
}
)
}
}
}
) { innerPadding ->
@@ -775,6 +831,7 @@ fun ChatScreen(
sharedAvatarKey = sharedAvatarKey,
subtitleKey = subtitleKey,
currentTypingUsers = currentTypingUsers,
typingShowsUsernames = panel.usesPublicGroupSubtitle,
statusConnecting = statusConnecting,
statusUpdating = statusUpdating,
chatGroupLabel = chatGroupLabel,
@@ -65,7 +65,10 @@ import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted
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 kotlin.math.PI
import kotlin.math.tan
@@ -85,11 +88,19 @@ fun ChatTopBarInner(
sharedAvatarKey: Any?,
subtitleKey: String,
currentTypingUsers: List<TypingUser>,
typingShowsUsernames: Boolean = true,
statusConnecting: String,
statusUpdating: String,
chatGroupLabel: String,
modifier: Modifier = Modifier,
) {
val isDeletedPeer = profileUserId?.let { userId ->
peerIsDeleted(
userId = userId,
currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title,
)
} == true
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
@@ -118,6 +129,8 @@ fun ChatTopBarInner(
animatedVisibilityScope = animatedVisibilityScope,
)
.size(40.dp),
isDeletedUser = isDeletedPeer,
userId = profileUserId,
)
}
@@ -130,6 +143,8 @@ fun ChatTopBarInner(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
modifier = Modifier.size(40.dp),
isDeletedUser = isDeletedPeer,
userId = profileUserId,
)
Spacer(modifier = Modifier.width(6.dp))
@@ -244,6 +259,7 @@ fun ChatTopBarInner(
key == "typing" -> {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
showUsernames = typingShowsUsernames,
modifier = Modifier.padding(top = 2.dp),
)
}
@@ -6,37 +6,41 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.ProfileCache
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.ui.profile.avatarLabelForInitials
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+)$")
/**
* 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
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (currentUserId != null && message.user_id == currentUserId) {
return stringResource(Res.string.message_sender_you)
}
val cachedProfile = ProfileCache.get(message.user_id)
val isCachedUserHidden = cachedProfile?.let {
it.id != currentUserId && (it.deleted == true || it.suspended == true)
} == true
if (isCachedUserHidden) {
return stringResource(Res.string.user_fallback, message.user_id)
ProfileCache.get(message.user_id)?.let { profile ->
if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) {
return deletedUserDisplayNameForUi()
}
}
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 (message.username.equals("deleted", ignoreCase = true)) {
return stringResource(Res.string.user_fallback, message.user_id)
return deletedUserDisplayNameForUi()
}
val m = userIdUsernamePattern.matchEntire(message.username)
if (m != null) {
val id = m.groupValues[1].toIntOrNull()
if (id != null) return stringResource(Res.string.user_fallback, id)
if (id != null) return deletedUserDisplayNameForUi()
}
return message.username
}
@@ -45,6 +49,9 @@ fun messageSenderProfilePicture(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): String? {
if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) {
return null
}
if (currentUserId != null && message.user_id == currentUserId) {
return message.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() }
}
fun messageSenderIsDeleted(message: Message, currentUserId: Int? = ApiClient.user?.id): Boolean =
peerIsDeleted(
userId = message.user_id,
currentUserId = currentUserId,
username = message.username,
)
fun messageSenderAvatarLabel(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): 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) {
return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim().ifBlank {
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty()
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim()
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -57,8 +56,8 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.messages.formatMessageTimeLocal
import ru.fromchat.ui.chat.messageSenderAvatarLabel
import ru.fromchat.api.local.messages.formatMessageBubbleTimeLocal
import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
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.imageAttachmentKey
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.isAppInDarkTheme
import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -119,7 +119,7 @@ fun MessageItem(
isMessageCorrupted(message)
}
val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp)
formatMessageBubbleTimeLocal(message.timestamp)
}
val corruptedBody = stringResource(Res.string.message_corrupted)
val editedSuffix = stringResource(Res.string.message_edited_suffix)
@@ -128,9 +128,9 @@ fun MessageItem(
val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture
val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() }
?: message.username
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
@@ -193,7 +193,9 @@ fun MessageItem(
Avatar(
profilePictureUrl = avatarPictureUrl,
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
) {
// Message bubble
val isDark = isSystemInDarkTheme()
val isDark = isAppInDarkTheme()
val pendingIsImage = when {
message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename)
message.pendingFileUri != null -> isImageFilename(
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.typing_alone
import ru.fromchat.typing_many
import ru.fromchat.typing_single
import ru.fromchat.typing_two
@@ -29,6 +30,7 @@ import ru.fromchat.typing_two
@Composable
fun TypingIndicator(
typingUsers: List<String>,
showUsernames: Boolean = true,
modifier: Modifier = Modifier
) {
if (typingUsers.isEmpty()) return
@@ -40,7 +42,7 @@ fun TypingIndicator(
TypingDots()
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatTypingText(typingUsers),
text = formatTypingText(typingUsers, showUsernames),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -48,11 +50,13 @@ fun TypingIndicator(
}
@Composable
private fun formatTypingText(typingUsers: List<String>): String {
return when (typingUsers.size) {
0 -> ""
1 -> stringResource(Res.string.typing_single, typingUsers[0])
2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1])
private fun formatTypingText(typingUsers: List<String>, showUsernames: Boolean): String {
return when {
typingUsers.isEmpty() -> ""
!showUsernames && typingUsers.size == 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(
Res.string.typing_many,
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.websocket.WebSocketMessage
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.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
@@ -106,42 +107,55 @@ class DmPanel(
if (_state.title.isBlank()) {
loadPeerTitleFromConversationCache()
}
runCatching {
ApiClient.getProfileById(otherUserId)
}.onSuccess { profile ->
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
try {
val profile = ApiClient.getProfileById(otherUserId)
if (
!profile.isDeletedAccount(ApiClient.user?.id) &&
profile.username.isBlank() &&
profile.displayName.isNullOrBlank()
) {
ProfileCache.evictUnusableClientPreview(otherUserId)
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 {
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() {
val cached = ProfileCache.get(otherUserId)
val displayName = cached?.visibleDisplayName(ApiClient.user?.id).orEmpty()
if (displayName.isNotBlank()) {
applyPeerTitle(displayName, cached?.profilePicture)
} else {
updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) {
applyPeerTitle(displayName, cached?.profilePicture)
} else {
updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
}
}
}
}
@@ -423,6 +437,9 @@ class DmPanel(
pendingFileAspectRatio = aspect,
fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) },
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)
AttachmentMediaLog.persist(
@@ -461,10 +478,6 @@ class DmPanel(
val deduped = dedupeMessagesByClientId(newMessages)
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()) {
@@ -87,6 +87,9 @@ import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.Text
import ru.fromchat.unread_count_badge
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
internal object ChatListLayout {
@@ -394,12 +397,33 @@ internal fun SearchConversationsList(
resultIndex++
item {
val cached = ProfileCache.get(user.id)
val avatarUrl = cached?.profilePicture ?: user.profile_picture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: user.displayName?.takeIf { it.isNotBlank() }
?: cached?.visibleUsername(ApiClient.user?.id)
?: user.username
val username = cached?.visibleUsername(ApiClient.user?.id) ?: user.username
val isPeerDeleted = peerIsDeleted(
userId = user.id,
currentUserId = ApiClient.user?.id,
deleted = user.deleted ?: cached?.deleted,
username = 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(
listItemPosition = position,
@@ -418,11 +442,13 @@ internal fun SearchConversationsList(
leadingContent = {
ChatRowAvatar(
profilePictureUrl = avatarUrl,
displayNameForInitials = peerTitle,
displayNameForInitials = avatarInitialsLabel,
enabled = false,
onPressStart = {},
onPressEnd = {},
onLongPress = {},
isDeletedUser = isPeerDeleted,
userId = user.id,
)
},
)
@@ -503,6 +529,8 @@ internal fun ChatRowAvatar(
modifier: Modifier = Modifier,
showOnlineIndicator: Boolean = false,
onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
isDeletedUser: Boolean = false,
userId: Int? = null,
) {
Box(
modifier
@@ -526,6 +554,8 @@ internal fun ChatRowAvatar(
profilePictureUrl = profilePictureUrl,
displayName = displayNameForInitials,
modifier = Modifier.fillMaxSize(),
isDeletedUser = isDeletedUser,
userId = userId,
)
if (showOnlineIndicator) {
Box(
@@ -830,13 +860,27 @@ internal fun DmConversationRowContent(
onBodyLongPress: () -> Unit,
modifier: Modifier = Modifier,
) {
val currentUserId = ApiClient.user?.id
val cached = ProfileCache.get(conversation.otherUserId)
val avatarUrl = cached?.profilePicture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.visibleUsername(ApiClient.user?.id)
?: conversation.displayName.ifBlank {
stringResource(Res.string.user_fallback, conversation.otherUserId)
}
val isPeerDeleted = peerIsDeleted(
userId = conversation.otherUserId,
currentUserId = currentUserId,
deleted = cached?.deleted,
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 status = statusMap[conversation.otherUserId]
val typingUsers = status?.typingUsernames.orEmpty()
@@ -869,13 +913,15 @@ internal fun DmConversationRowContent(
)
ChatRowAvatar(
profilePictureUrl = avatarUrl,
displayNameForInitials = peerTitle,
displayNameForInitials = avatarInitialsLabel,
enabled = avatarEnabled,
onPressStart = onAvatarPressStart,
onPressEnd = onAvatarPressEnd,
onLongPress = onAvatarLongPress,
showOnlineIndicator = isOnline,
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.filled.Close
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.rounded.Delete
import androidx.compose.material3.AlertDialog
@@ -112,8 +115,7 @@ import ru.fromchat.public_chat
import ru.fromchat.search_title
import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.suspend_chat_banner_message
import ru.fromchat.suspended_default_reason
import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.panels.dm.DmNav
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.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.SuspendedAccountBannerStyle
import ru.fromchat.ui.components.SuspendedAccountNoticeHost
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.components.Text
import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -250,7 +251,7 @@ private fun ChatsSelectionTopBar(
Box {
IconButton(onClick = { overflowOpen = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
imageVector = Icons.Filled.MoreVert,
contentDescription = moreActionsCd,
)
}
@@ -330,6 +331,7 @@ fun ChatsTab(
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope()
val suspensionState by ApiClient.suspensionState.collectAsState()
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
@@ -519,7 +521,7 @@ fun ChatsTab(
}
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
if (activeInstanceId.isBlank()) return@LaunchedEffect
if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
runCatching {
ApiClient.getDmConversations()
@@ -546,8 +548,7 @@ fun ChatsTab(
val updatingTitle = stringResource(Res.string.status_updating)
val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message)
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason)
val accountSuspendedTitle = stringResource(Res.string.account_suspended)
val publicChatFallbackTitle = stringResource(Res.string.public_chat)
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
@@ -696,15 +697,25 @@ fun ChatsTab(
}
}
SuspendedAccountNoticeHost(
isSuspended = suspensionState.isSuspended,
reason = suspensionState.reason,
fallbackReason = suspendDefaultReason,
bannerTitle = suspendBannerTitle,
style = SuspendedAccountBannerStyle.Tabs,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
if (suspensionState.isSuspended) {
ListItem(
headline = accountSuspendedTitle,
position = ListItemPosition.START,
groupItemCount = 1,
divider = false,
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(
listState = tabListState,
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.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
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.rememberLazyListState
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.verticalScroll
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.MoreVert
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.automirrored.filled.Subject
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.Modifier
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.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
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.positionInRoot
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.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.delay
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_title
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_share
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_title
import ru.fromchat.more
import ru.fromchat.search_not_found
import ru.fromchat.logging.AppLogEntry
import ru.fromchat.logging.AppLogLevel
import ru.fromchat.logging.AppLogStore
@@ -218,6 +234,8 @@ fun LogsScreen() {
val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback()
val density = LocalDensity.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
val listState = rememberLazyListState()
@@ -246,7 +264,21 @@ fun LogsScreen() {
var listMode by remember { mutableStateOf(LogsListMode.Normal) }
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 searchTransitionProgress = remember { Animatable(0f) }
val searchFocusRequester = remember { FocusRequester() }
val gestureState = rememberLogsListGestureState()
var dragAnchorIndex by remember { mutableIntStateOf(-1) }
var dragLastY by remember { mutableFloatStateOf(0f) }
@@ -273,13 +305,22 @@ fun LogsScreen() {
val shareTitle = stringResource(Res.string.logs_title)
val selectionMode = listMode == LogsListMode.Selecting
val selectionProgress = selectionTransitionProgress.value
val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode
val searchProgress = searchTransitionProgress.value
val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode && !searchMode
val showScrollToBottomFab = isViewingCurrent &&
!selectionMode &&
!searchMode &&
displayEntries.isNotEmpty() &&
!isAtBottom &&
!isProgrammaticScroll
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 closeSelectionCd = stringResource(Res.string.cd_close_selection)
@@ -287,6 +328,20 @@ fun LogsScreen() {
val shareLabel = stringResource(Res.string.logs_share)
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() {
if (displayEntries.isEmpty()) return
scope.launch {
@@ -339,6 +394,14 @@ fun LogsScreen() {
}
}
fun enterSearchMode() {
if (selectionMode) {
exitEntrySelection()
}
scope.launch { searchTransitionProgress.snapTo(0f) }
searchMode = true
}
fun performShare(compression: LogShareCompression) {
val request = pendingShareRequest ?: return
scope.launch {
@@ -361,19 +424,20 @@ fun LogsScreen() {
}
fun applyDragSelectionRange(toIndex: Int) {
if (searchMode) return
val anchor = dragAnchorIndex
if (anchor < 0 || toIndex < 0) return
val start = minOf(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) {
if (index !in displayEntries.indices) return
if (searchMode || index !in listEntries.indices) return
gestureState.onDragSelectionStart()
dragAnchorIndex = index
if (!selectionMode) {
enterEntrySelection(displayEntries[index].id)
enterEntrySelection(listEntries[index].id)
} else {
applyDragSelectionRange(index)
}
@@ -414,11 +478,12 @@ fun LogsScreen() {
}
}
LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode) {
LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode, searchMode) {
if (
isViewingCurrent &&
displayEntries.isNotEmpty() &&
!selectionMode &&
!searchMode &&
followLatest
) {
isProgrammaticScroll = true
@@ -462,8 +527,8 @@ fun LogsScreen() {
}
}
LaunchedEffect(selectionMode) {
if (selectionMode) {
LaunchedEffect(selectionMode, searchMode) {
if (selectionMode || searchMode) {
followLatest = false
} else if (isAtBottom) {
followLatest = true
@@ -476,6 +541,13 @@ fun LogsScreen() {
}
}
LaunchedEffect(searchMode) {
if (searchMode) {
searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
searchFocusRequester.requestFocus()
}
}
LaunchedEffect(selectedEntryIds, listMode) {
if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) {
requestExitEntrySelection()
@@ -483,7 +555,10 @@ fun LogsScreen() {
}
DisposableEffect(Unit) {
onDispose { exitEntrySelection() }
onDispose {
exitEntrySelection()
exitSearchMode()
}
}
if (showDecompressDialog) {
@@ -606,6 +681,7 @@ fun LogsScreen() {
}
BackHandler(enabled = selectionMode) { requestExitEntrySelection() }
BackHandler(enabled = searchMode && !selectionMode) { requestExitSearchMode() }
PredictiveBackHandler(
enabled = selectionMode,
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(
modifier = Modifier
@@ -632,7 +727,7 @@ fun LogsScreen() {
contentWindowInsets = WindowInsets.navigationBars,
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
val fabReveal = (1f - selectionProgress).coerceIn(0f, 1f)
val fabReveal = ((1f - selectionProgress) * (1f - searchProgress)).coerceIn(0f, 1f)
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -657,11 +752,13 @@ fun LogsScreen() {
topBar = {
Box {
TopAppBar(
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
modifier = Modifier.graphicsLayer {
alpha = (1f - selectionProgress) * (1f - searchProgress)
},
navigationIcon = {
IconButton(
onClick = { navController.navigateUp() },
enabled = selectionProgress < 1f,
enabled = selectionProgress < 1f && searchProgress < 1f,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
@@ -682,7 +779,7 @@ fun LogsScreen() {
}
},
actions = {
if (isViewingCurrent && !selectionMode) {
if (isViewingCurrent && !selectionMode && searchProgress < 1f) {
IconButton(
onClick = {
pendingShareRequest = LogsShareRequest(
@@ -699,7 +796,10 @@ fun LogsScreen() {
}
}
Box {
IconButton(onClick = { menuExpanded = true }) {
IconButton(
onClick = { menuExpanded = true },
enabled = searchProgress < 1f,
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(Res.string.more),
@@ -709,6 +809,16 @@ fun LogsScreen() {
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text(logsSearchLabel) },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
onClick = {
menuExpanded = false
enterSearchMode()
},
)
if (isViewingCurrent) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.logs_rotate)) },
@@ -750,6 +860,66 @@ fun LogsScreen() {
},
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) {
TopAppBar(
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
@@ -806,11 +976,13 @@ fun LogsScreen() {
}
},
) { innerPadding ->
if (displayEntries.isEmpty()) {
when {
displayEntries.isEmpty() -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
.padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -820,7 +992,26 @@ fun LogsScreen() {
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 = {
LazyColumn(
state = listState,
@@ -837,7 +1028,7 @@ fun LogsScreen() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(
items = displayEntries,
items = listEntries,
key = { _, entry -> entry.id },
) { index, entry ->
LogEntryRow(
@@ -861,14 +1052,23 @@ fun LogsScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
.padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier),
) {
listContent()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun logsTransparentTopAppBarColors() = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
)
@Composable
internal fun LogsAnimatedFab(
visible: Boolean,
@@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -33,6 +34,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ensureFcmTokenRegistered
import ru.fromchat.api.isFcmPushRegisteredLocally
import ru.fromchat.api.unregisterFcmTokenFromServer
import ru.fromchat.back
import ru.fromchat.error_unexpected
@@ -51,7 +53,10 @@ fun NotificationsScreen(onBack: () -> Unit) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val coroutineScope = rememberCoroutineScope()
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 unexpectedErrorText = stringResource(Res.string.error_unexpected)
@@ -86,23 +91,27 @@ fun NotificationsScreen(onBack: () -> Unit) {
Icon(Icons.Filled.Notifications, null)
},
checked = notificationsEnabled,
onCheckedChange = {
onCheckedChange = { enabled ->
coroutineScope.launch {
if (!areAppNotificationsEnabled()) {
if (!openAppNotificationSettings()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
} else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
if (enabled) {
if (!areAppNotificationsEnabled()) {
if (!openAppNotificationSettings()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
} else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
}
return@launch
}
return@launch
}
if (notificationsEnabled) {
unregisterFcmTokenFromServer()
notificationsEnabled = !notificationsEnabled
val registered = ensureFcmTokenRegistered()
if (registered) {
notificationsEnabled = true
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
}
} else {
ensureFcmTokenRegistered()
snackbarHostState.showSnackbar(message = unexpectedErrorText)
unregisterFcmTokenFromServer()
notificationsEnabled = false
}
}
},
@@ -39,7 +39,6 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.back
import ru.fromchat.cancel
import ru.fromchat.logout
@@ -139,7 +138,6 @@ fun AccountScreen(
showLogoutConfirm = false
scope.launch {
runCatching { ApiClient.logout() }
WebSocketManager.disconnect()
onLogout()
}
},
@@ -217,7 +217,7 @@ fun EditProfileScreen(
val canSave = loaded && hasChanges && !hasValidationErrors && !busy
val avatarDisplayName = trimmedDisplayName.ifBlank { trimmedUsername }.ifBlank { "?" }
val avatarDisplayName = trimmedDisplayName.ifBlank { "?" }
fun showSnack(text: String) {
scope.launch {
@@ -150,7 +150,12 @@ import ru.fromchat.profile_load_failed
import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_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.isDeletedAccount
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator
@@ -196,6 +201,7 @@ private fun hasDisplayableProfile(
currentUserId: Int?,
) = !initialDisplayName.isNullOrBlank() ||
(profile != null && profile.id > 0 && (
profile.isDeletedAccount(currentUserId) ||
!profile.visibleDisplayName(currentUserId).isNullOrBlank() ||
profile.username.isNotBlank()
))
@@ -461,12 +467,27 @@ fun ProfileScreen(
val showBodySkeleton = resolvedProfile == null
val showAvatarSkeleton = showBodySkeleton && !hasDisplayable
val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
val displayName =
profile?.visibleDisplayName(currentProfileUserId)
val viewerUserId = ownUserId
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() }
?: 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 {
usernameForLinks?.let { name -> "https://fromchat.ru/@$name" }
?: "https://fromchat.ru/?u=${it.id}"
@@ -507,6 +528,28 @@ fun ProfileScreen(
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 {
buildList {
add(
@@ -554,8 +597,10 @@ fun ProfileScreen(
val showDetailsUsername = usernameForLinks != null
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
val showDetailsVerify = resolvedProfile?.verified == true || ApiClient.user?.id == 1
val showDetailsSection = resolvedProfile != null && (
val showDetailsVerify = !isDeletedProfile && (
resolvedProfile?.verified == true || ApiClient.user?.id == 1
)
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
)
@@ -596,14 +641,16 @@ fun ProfileScreen(
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
displayName = avatarLabel,
modifier = Modifier
.padding(top = profileAvatarTop)
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope
)
.size(104.dp)
.size(104.dp),
isDeletedUser = isDeletedProfile,
userId = resolvedUserId,
)
}
}
@@ -614,10 +661,12 @@ fun ProfileScreen(
item {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
displayName = avatarLabel,
modifier = Modifier
.padding(top = profileAvatarTop)
.size(104.dp)
.size(104.dp),
isDeletedUser = isDeletedProfile,
userId = resolvedUserId,
)
}
item { Spacer(Modifier.height(12.dp)) }
@@ -665,6 +714,7 @@ fun ProfileScreen(
resolvedProfile = resolvedProfile!!,
displayName = displayName,
isOwnProfile = isOwnProfile,
isDeletedProfile = isDeletedProfile,
typingUsers = typingUsers,
statusState = statusState,
statusText = statusText,
@@ -1145,6 +1195,7 @@ private fun ProfileLoadedBody(
resolvedProfile: UserProfile,
displayName: String,
isOwnProfile: Boolean,
isDeletedProfile: Boolean,
typingUsers: List<String>,
statusState: UserStatus?,
statusText: String,
@@ -1204,40 +1255,46 @@ private fun ProfileLoadedBody(
color = MaterialTheme.colorScheme.onSurface,
)
}
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) {
StatusBadge(
verificationStatus = resolvedProfile.effectiveVerificationStatus(),
)
}
}
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))
ProfileActionButtonRow(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -16,6 +17,7 @@ import ru.fromchat.Res
import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.cd_similar_verified
import ru.fromchat.cd_verified_account
import ru.fromchat.cd_account_blocked
@Composable
fun StatusBadge(
@@ -38,6 +40,13 @@ fun StatusBadge(
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
}
}
@@ -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_this_year
import ru.fromchat.presence_online
import ru.fromchat.presence_long_ago
import ru.fromchat.presence_recently
import ru.fromchat.presence_today_at
import ru.fromchat.presence_weekday_at
@@ -53,6 +54,7 @@ private fun formatFromXmlTemplate(template: String, vararg args: Any): String {
data class LastSeenFormatStrings(
val online: String,
val recently: String,
val longAgo: String,
val todayAt: String,
val yesterdayAt: String,
val weekdayAt: String,
@@ -66,6 +68,7 @@ data class LastSeenFormatStrings(
fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
val online = stringResource(Res.string.presence_online)
val recently = stringResource(Res.string.presence_recently)
val longAgo = stringResource(Res.string.presence_long_ago)
val todayAt = stringResource(Res.string.presence_today_at)
val yesterdayAt = stringResource(Res.string.presence_yesterday_at)
val weekdayAt = stringResource(Res.string.presence_weekday_at)
@@ -91,7 +94,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
val mNov = stringResource(Res.string.month_nov)
val mDec = stringResource(Res.string.month_dec)
return remember(
online, recently, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull,
online, recently, longAgo, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull,
mon, tue, wed, thu, fri, sat, sun,
mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec
) {
@@ -99,6 +102,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
LastSeenFormatStrings(
online = online,
recently = recently,
longAgo = longAgo,
todayAt = todayAt,
yesterdayAt = yesterdayAt,
weekdayAt = weekdayAt,
@@ -128,6 +132,7 @@ fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrin
if (online) return s.online
val iso = lastSeenIso ?: return ""
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently
if (instant.toEpochMilliseconds() <= 0L) return s.longAgo
val timeZone = TimeZone.currentSystemDefault()
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.
return false
}
actual suspend fun isFcmPushRegisteredLocally(): Boolean = false