mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Fix cache bugs, light theme and others
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -27,6 +27,8 @@ import ru.fromchat.MainActivity
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.R
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
import ru.fromchat.api.local.messages.buildChatListPreview
|
||||
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
|
||||
@@ -301,15 +303,13 @@ object NotificationHelper {
|
||||
}
|
||||
}
|
||||
|
||||
val senderName = if (
|
||||
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank()
|
||||
) {
|
||||
dmSenderName
|
||||
} else if (!envelope.senderUsername.isNullOrBlank()) {
|
||||
envelope.senderUsername
|
||||
} else {
|
||||
"User ${envelope.senderId}"
|
||||
}
|
||||
val senderName = when {
|
||||
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
|
||||
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
|
||||
else -> ProfileCache.get(envelope.senderId)
|
||||
?.visibleDisplayName(currentUserId)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}.orEmpty()
|
||||
val dmConversationUserId = envelope.senderId
|
||||
val notificationBody = buildChatListPreviewFromEnvelope(
|
||||
envelope = envelope,
|
||||
@@ -319,7 +319,11 @@ object NotificationHelper {
|
||||
|
||||
showFallbackPushNotification(
|
||||
context = context,
|
||||
title = "Direct message from $senderName",
|
||||
title = if (senderName.isNotBlank()) {
|
||||
"Direct message from $senderName"
|
||||
} else {
|
||||
"Direct message"
|
||||
},
|
||||
body = notificationBody,
|
||||
sender = senderName,
|
||||
messageId = envelopeId,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color as AndroidColor
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
@Composable
|
||||
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
|
||||
@@ -16,4 +23,26 @@ actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
|
||||
} else {
|
||||
if (darkTheme) darkColorScheme()
|
||||
else lightColorScheme()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) {
|
||||
val view = LocalView.current
|
||||
if (view.isInEditMode) return
|
||||
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
window.statusBarColor = AndroidColor.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
window.navigationBarColor = AndroidColor.TRANSPARENT
|
||||
window.decorView.setBackgroundColor(surfaceColor.toArgb())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
<string name="notif_media_upload_title">Отправка вложения</string>
|
||||
<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>
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -166,6 +166,23 @@ object ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun applyOwnProfile(profile: UserProfile) {
|
||||
syncSuspensionStateFromProfile(profile)
|
||||
val currentUser = user ?: return
|
||||
user = currentUser.copy(
|
||||
username = profile.username,
|
||||
displayName = profile.displayName,
|
||||
bio = profile.bio,
|
||||
profile_picture = profile.profilePicture,
|
||||
verified = profile.verified,
|
||||
verificationStatus = profile.verificationStatus,
|
||||
deleted = profile.deleted,
|
||||
online = profile.online,
|
||||
last_seen = profile.lastSeen,
|
||||
)
|
||||
persistCurrentUser()
|
||||
}
|
||||
|
||||
fun clearSuspensionState() {
|
||||
_suspensionState.value = SuspensionState()
|
||||
user = user?.copy(
|
||||
|
||||
@@ -51,11 +51,13 @@ object ChatListSync {
|
||||
}
|
||||
|
||||
private suspend fun syncDmConversations() {
|
||||
val previewStrings = MessageCacheStore.listPreviewStrings ?: return
|
||||
runCatching {
|
||||
val conversations = ApiClient.getDmConversations()
|
||||
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||
MessageRepository.replaceDmConversations(conversations, previewStrings)
|
||||
MessageRepository.replaceDmConversations(
|
||||
conversations,
|
||||
MessageCacheStore.listPreviewStrings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
|
||||
/**
|
||||
* Network work that must not block cold start or the first frame.
|
||||
@@ -20,7 +21,8 @@ object DeferredStartupNetwork {
|
||||
if (ApiClient.token.isNullOrEmpty()) return@launch
|
||||
runCatching {
|
||||
val profile = ApiClient.getOwnProfile()
|
||||
ApiClient.syncSuspensionStateFromProfile(profile)
|
||||
ApiClient.applyOwnProfile(profile)
|
||||
ProfileCache.put(profile)
|
||||
}
|
||||
runCatching { syncPushTokenAfterStartup() }
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ object CallStore {
|
||||
val me = ApiClient.user?.id
|
||||
val p = ProfileCache.get(peerUserId)
|
||||
val label = p?.visibleDisplayName(me)?.orEmpty()?.ifBlank { null }
|
||||
return label ?: p?.username?.takeIf { it.isNotBlank() } ?: "User $peerUserId"
|
||||
return label ?: p?.username?.takeIf { it.isNotBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
fun onWebSocketMessage(message: WebSocketMessage) {
|
||||
|
||||
@@ -16,6 +16,18 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@Serializable
|
||||
private data class DmOutboundTextEnvelope(
|
||||
val type: String = "text",
|
||||
val data: DmOutboundTextData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DmOutboundTextData(
|
||||
val content: String,
|
||||
@SerialName("reply_to_id") val replyToId: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedOptimisticOutboundPayload(
|
||||
@SerialName("text") val text: String,
|
||||
@@ -40,6 +52,8 @@ private data class PersistedDmMessagePayload(
|
||||
|
||||
data class ParsedDmMessageContent(
|
||||
val text: String,
|
||||
/** Reply target from encrypted JSON payload (`reply_to_id`), when present. */
|
||||
val replyToId: Int? = null,
|
||||
val envelope: DmEnvelope? = null,
|
||||
val fileThumbnails: List<String>? = null,
|
||||
val fileAspectRatios: List<Float>? = null,
|
||||
@@ -52,6 +66,21 @@ data class ParsedDmMessageContent(
|
||||
val uploadJobId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Plaintext for outbound DM encryption. Embeds [replyToId] in the Web-compatible JSON envelope
|
||||
* so recipients can resolve reply previews without relying on API envelope metadata.
|
||||
*/
|
||||
fun buildDmOutboundPlaintext(content: String, replyToId: Int?): String {
|
||||
val trimmed = content.trim()
|
||||
val replyId = replyToId?.takeIf { it > 0 }
|
||||
if (replyId == null) return trimmed
|
||||
return json.encodeToString(
|
||||
DmOutboundTextEnvelope(
|
||||
data = DmOutboundTextData(content = trimmed, replyToId = replyId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Persists in-flight attachment fields so SQLDelight reload keeps the file row UI. */
|
||||
fun encodeOptimisticOutboundMessage(message: Message): String {
|
||||
val pendingUri = message.pendingFileUri?.trim().orEmpty()
|
||||
@@ -133,6 +162,17 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
|
||||
val trimmed = plaintext.trim()
|
||||
if (trimmed.startsWith("{")) {
|
||||
val root = runCatching { json.parseToJsonElement(trimmed).jsonObject }.getOrNull()
|
||||
if (root?.containsKey("type") == true && root["type"]?.jsonPrimitive?.content == "text") {
|
||||
return runCatching {
|
||||
val payload = json.decodeFromString<DmOutboundTextEnvelope>(trimmed)
|
||||
ParsedDmMessageContent(
|
||||
text = payload.data.content,
|
||||
replyToId = payload.data.replyToId?.takeIf { it > 0 },
|
||||
)
|
||||
}.getOrElse {
|
||||
ParsedDmMessageContent(text = plaintext)
|
||||
}
|
||||
}
|
||||
if (root?.containsKey("pendingFileUri") == true) {
|
||||
return runCatching {
|
||||
val payload = json.decodeFromString<PersistedOptimisticOutboundPayload>(trimmed)
|
||||
|
||||
+59
-19
@@ -41,8 +41,10 @@ import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.api.local.cache.DecryptedFileCache
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.download.DownloadedFileRegistry
|
||||
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
|
||||
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
||||
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
|
||||
import kotlin.concurrent.Volatile
|
||||
import ru.fromchat.db.Message as DbMessage
|
||||
|
||||
data class CachedConversation(
|
||||
@@ -78,7 +80,7 @@ object MessageCacheStore {
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
.map { rows ->
|
||||
val raw = hydrateReplyReferences(rows)
|
||||
val raw = hydrateReplyReferencesFromRows(rows)
|
||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||
sortMessagesForChatDisplay(
|
||||
validatedOrEmpty(
|
||||
@@ -99,11 +101,10 @@ object MessageCacheStore {
|
||||
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
|
||||
if (instanceId.isBlank()) return emptyList()
|
||||
val convId = conversationIdForPublic()
|
||||
val raw = hydrateReplyReferences(
|
||||
db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(instanceId, convId, limit)
|
||||
.executeAsList(),
|
||||
).reversed()
|
||||
val rows = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(instanceId, convId, limit)
|
||||
.executeAsList()
|
||||
val raw = hydrateReplyReferencesFromRows(rows).reversed()
|
||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||
return ProfileCache.enrichPublicMessagesForDisplay(
|
||||
sortMessagesForChatDisplay(
|
||||
@@ -338,9 +339,9 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun replaceDmConversations(
|
||||
conversations: List<DmConversation>,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
previewStrings: ChatListPreviewStrings? = listPreviewStrings,
|
||||
) {
|
||||
listPreviewStrings = previewStrings
|
||||
previewStrings?.let { listPreviewStrings = it }
|
||||
val iid = instanceId()
|
||||
val currentUserId = ApiClient.user?.id
|
||||
withContext(Dispatchers.Default) {
|
||||
@@ -408,10 +409,14 @@ object MessageCacheStore {
|
||||
private suspend fun buildDmListPreview(
|
||||
envelope: DmEnvelope,
|
||||
currentUserId: Int?,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
previewStrings: ChatListPreviewStrings?,
|
||||
): String? {
|
||||
val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||
val previewSource = buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings)
|
||||
val previewSource = if (previewStrings != null) {
|
||||
buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings)
|
||||
} else {
|
||||
decrypted?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@@ -448,11 +453,25 @@ object MessageCacheStore {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull()
|
||||
if (existing != null) return@withContext
|
||||
val label = displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ""
|
||||
val label = resolveDmConversationDisplayLabel(otherUserId, displayName)
|
||||
if (existing != null) {
|
||||
if (label.isNotEmpty() && existing.displayName.isNullOrBlank()) {
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
instanceId = iid,
|
||||
id = existing.id,
|
||||
type = existing.type,
|
||||
otherUserId = existing.otherUserId,
|
||||
displayName = label,
|
||||
lastMessageId = existing.lastMessageId,
|
||||
lastMessagePreview = existing.lastMessagePreview,
|
||||
unreadCount = existing.unreadCount,
|
||||
updatedAt = existing.updatedAt,
|
||||
archived = existing.archived,
|
||||
)
|
||||
DmConversationListNotifier.notifyChanged()
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
instanceId = iid,
|
||||
id = convId,
|
||||
@@ -468,6 +487,11 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDmConversationDisplayLabel(otherUserId: Int, displayName: String?): String =
|
||||
displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ProfileCache.get(otherUserId)?.visibleUsername(ApiClient.user?.id).orEmpty()
|
||||
|
||||
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
@@ -835,6 +859,11 @@ object MessageCacheStore {
|
||||
val storedContent = encodePersistedDmMessage(confirmed)
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.transaction {
|
||||
val existingReplyToId = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
.firstOrNull { it.clientMessageId == clientMessageId }
|
||||
?.replyToId
|
||||
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
|
||||
db.messageDatabaseQueries.upsertMessage(
|
||||
instanceId = iid,
|
||||
@@ -845,7 +874,7 @@ object MessageCacheStore {
|
||||
timestamp = confirmed.timestamp,
|
||||
isRead = if (confirmed.is_read) 1L else 0L,
|
||||
isEdited = if (confirmed.is_edited) 1L else 0L,
|
||||
replyToId = resolveReplyToIdForPersistence(confirmed),
|
||||
replyToId = resolveReplyToIdForPersistence(confirmed, existingReplyToId),
|
||||
clientMessageId = confirmed.client_message_id,
|
||||
deletedFlag = 0L,
|
||||
sendStatus = "sent"
|
||||
@@ -865,7 +894,7 @@ object MessageCacheStore {
|
||||
val rows = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, conversationId)
|
||||
.executeAsList()
|
||||
val raw = hydrateReplyReferences(rows)
|
||||
val raw = hydrateReplyReferencesFromRows(rows)
|
||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
|
||||
sortMessagesForChatDisplay(
|
||||
@@ -885,7 +914,7 @@ object MessageCacheStore {
|
||||
val rows = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(iid, conversationId, limit)
|
||||
.executeAsList()
|
||||
hydrateReplyReferences(rows).reversed()
|
||||
hydrateReplyReferencesFromRows(rows).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,13 +1020,23 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hydrateReplyReferencesFromRows(rows: List<DbMessage>): List<Message> {
|
||||
val replyIds = rows.mapNotNull { row ->
|
||||
row.replyToId?.toInt()?.takeIf { it > 0 }?.let { row.id.toInt() to it }
|
||||
}.toMap()
|
||||
return attachPublicReplyReferences(hydrateReplyReferences(rows), replyIds)
|
||||
}
|
||||
|
||||
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() ?: message.dmEnvelope?.replyToId
|
||||
if (replyId != null) {
|
||||
message.copy(reply_to = byId[replyId])
|
||||
message.copy(
|
||||
replyToId = replyId,
|
||||
reply_to = byId[replyId] ?: message.reply_to,
|
||||
)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
@@ -1015,6 +1054,7 @@ object MessageCacheStore {
|
||||
|
||||
private fun resolveReplyToIdForPersistence(msg: Message, existingReplyToId: Long? = null): Long? {
|
||||
return msg.reply_to?.id?.toLong()
|
||||
?: msg.replyToId?.toLong()
|
||||
?: msg.dmEnvelope?.replyToId?.toLong()
|
||||
?: existingReplyToId?.takeIf { it > 0L }
|
||||
}
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ object MessageRepository {
|
||||
|
||||
suspend fun replaceDmConversations(
|
||||
conversations: List<DmConversation>,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
previewStrings: ChatListPreviewStrings? = null,
|
||||
) = MessageCacheStore.replaceDmConversations(conversations, previewStrings)
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
|
||||
@@ -3,6 +3,9 @@ package ru.fromchat.api.local.db.store
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -45,9 +48,16 @@ object ProfileCache {
|
||||
@Volatile
|
||||
private var loadedInstanceId: String = ""
|
||||
|
||||
private val _revision = MutableStateFlow(0)
|
||||
val revision: StateFlow<Int> = _revision.asStateFlow()
|
||||
|
||||
private val persistMutex = Mutex()
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private fun bumpRevision() {
|
||||
_revision.value++
|
||||
}
|
||||
|
||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||
|
||||
fun findByUsername(username: String): UserProfile? =
|
||||
@@ -125,6 +135,7 @@ object ProfileCache {
|
||||
}
|
||||
val cur = profiles
|
||||
profiles = cur + (profile.id to profile)
|
||||
bumpRevision()
|
||||
val instanceId = loadedInstanceId
|
||||
if (instanceId.isNotEmpty()) {
|
||||
ioScope.launch {
|
||||
@@ -137,6 +148,7 @@ object ProfileCache {
|
||||
val cur = profiles
|
||||
if (userId !in cur) return
|
||||
profiles = cur - userId
|
||||
bumpRevision()
|
||||
val instanceId = loadedInstanceId
|
||||
if (instanceId.isNotEmpty()) {
|
||||
ioScope.launch {
|
||||
@@ -154,8 +166,7 @@ object ProfileCache {
|
||||
}
|
||||
|
||||
fun mergeFromDmUser(user: User) {
|
||||
val existing = get(user.id)
|
||||
if (existing != null && !existing.isClientPreviewOnly) return
|
||||
if (user.id <= 0) return
|
||||
|
||||
val incomingUsername = user.username.trim()
|
||||
if (incomingUsername.isEmpty()) return
|
||||
@@ -167,6 +178,28 @@ object ProfileCache {
|
||||
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
|
||||
}
|
||||
|
||||
val existing = get(user.id)
|
||||
if (existing != null && !existing.isClientPreviewOnly) {
|
||||
val patched = existing.copy(
|
||||
username = incomingUsername,
|
||||
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName,
|
||||
profilePicture = if (isDeleted) {
|
||||
null
|
||||
} else {
|
||||
user.profile_picture?.takeIf { it.isNotBlank() } ?: existing.profilePicture
|
||||
},
|
||||
online = user.online,
|
||||
lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing.lastSeen,
|
||||
verified = user.verified ?: existing.verified,
|
||||
verificationStatus = user.verificationStatus ?: existing.verificationStatus,
|
||||
suspended = user.suspended ?: existing.suspended,
|
||||
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
|
||||
deleted = isDeleted,
|
||||
)
|
||||
if (patched != existing) put(patched)
|
||||
return
|
||||
}
|
||||
|
||||
put(
|
||||
UserProfile(
|
||||
id = user.id,
|
||||
@@ -272,6 +305,7 @@ object ProfileCache {
|
||||
emptyMap()
|
||||
}
|
||||
pruneUnusableClientPreviewsLocked()
|
||||
bumpRevision()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,6 +320,7 @@ object ProfileCache {
|
||||
emptyMap()
|
||||
}
|
||||
pruneUnusableClientPreviewsLocked()
|
||||
bumpRevision()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +343,7 @@ object ProfileCache {
|
||||
persistMutex.withLock {
|
||||
profiles = emptyMap()
|
||||
loadedInstanceId = ""
|
||||
bumpRevision()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-22
@@ -8,10 +8,13 @@ import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
||||
import ru.fromchat.ui.chat.utils.attachDmReplyReferences
|
||||
import ru.fromchat.ui.chat.utils.resolveDmReplyToId
|
||||
|
||||
object DmInboundMessageProcessor {
|
||||
suspend fun processNew(element: JsonElement) {
|
||||
@@ -32,18 +35,28 @@ object DmInboundMessageProcessor {
|
||||
val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||
val plaintext = outcome ?: ""
|
||||
val isCorrupted = outcome == null
|
||||
val dec = parseDmMessageContent(plaintext)
|
||||
val message = buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)
|
||||
val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) }
|
||||
.getOrDefault(emptyList())
|
||||
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
|
||||
?: existing.firstOrNull { it.client_message_id == envelope.clientMessageId?.trim() }
|
||||
?.reply_to?.id?.takeIf { it > 0 }
|
||||
val hydrated = attachDmReplyReferences(
|
||||
existing.filter { it.id != message.id } + message,
|
||||
replyId?.let { mapOf(message.id to it) } ?: emptyMap(),
|
||||
).last()
|
||||
|
||||
if (envelope.senderId == currentUserId) {
|
||||
val clientId = envelope.clientMessageId?.trim().orEmpty()
|
||||
if (clientId.isNotEmpty()) {
|
||||
MessageRepository.confirmDmMessage(otherUserId, clientId, message)
|
||||
MessageRepository.confirmDmMessage(otherUserId, clientId, hydrated)
|
||||
} else {
|
||||
MessageRepository.upsertDmMessage(otherUserId, message)
|
||||
MessageRepository.upsertDmMessage(otherUserId, hydrated)
|
||||
}
|
||||
} else {
|
||||
val isRead = ActiveDmChatTracker.isActive(otherUserId)
|
||||
val inbound = message.copy(is_read = isRead)
|
||||
val inbound = hydrated.copy(is_read = isRead)
|
||||
MessageRepository.upsertDmMessage(otherUserId, inbound)
|
||||
}
|
||||
}
|
||||
@@ -82,26 +95,32 @@ object DmInboundMessageProcessor {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) }
|
||||
val existingMessages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
|
||||
.getOrDefault(emptyList())
|
||||
.find { it.id == envelope.id }
|
||||
val existing = existingMessages.find { it.id == envelope.id }
|
||||
val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||
val plaintext = outcome ?: ""
|
||||
val isCorrupted = outcome == null
|
||||
val dec = parseDmMessageContent(plaintext)
|
||||
val updated = (existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId))
|
||||
.copy(
|
||||
content = dec.text,
|
||||
is_edited = true,
|
||||
files = envelope.files,
|
||||
dmEnvelope = envelope,
|
||||
fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails,
|
||||
fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios,
|
||||
fileSizes = dec.fileSizes ?: existing?.fileSizes,
|
||||
fileDimensions = dec.fileDimensions ?: existing?.fileDimensions,
|
||||
isContentCorrupted = isCorrupted,
|
||||
)
|
||||
MessageRepository.upsertDmMessage(otherUserId, updated)
|
||||
val base = existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)
|
||||
val updated = base.copy(
|
||||
content = dec.text,
|
||||
is_edited = true,
|
||||
files = envelope.files,
|
||||
dmEnvelope = envelope,
|
||||
fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails,
|
||||
fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios,
|
||||
fileSizes = dec.fileSizes ?: existing?.fileSizes,
|
||||
fileDimensions = dec.fileDimensions ?: existing?.fileDimensions,
|
||||
isContentCorrupted = isCorrupted,
|
||||
)
|
||||
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
|
||||
?: existing?.reply_to?.id?.takeIf { it > 0 }
|
||||
val hydrated = attachDmReplyReferences(
|
||||
existingMessages.filter { it.id != updated.id } + updated,
|
||||
replyId?.let { mapOf(updated.id to it) } ?: emptyMap(),
|
||||
).last()
|
||||
MessageRepository.upsertDmMessage(otherUserId, hydrated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,14 +132,18 @@ object DmInboundMessageProcessor {
|
||||
otherUserId: Int,
|
||||
): Message {
|
||||
val dec = parseDmMessageContent(plaintext)
|
||||
val cached = ProfileCache.get(otherUserId)
|
||||
if (envelope.senderId != currentUserId) {
|
||||
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName ->
|
||||
ProfileCache.mergePreview(id = envelope.senderId, username = senderName)
|
||||
}
|
||||
}
|
||||
val senderProfile = ProfileCache.get(envelope.senderId)
|
||||
val username = if (envelope.senderId == currentUserId) {
|
||||
"You"
|
||||
} else {
|
||||
cached?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: cached?.username?.takeIf { it.isNotBlank() }
|
||||
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() }
|
||||
?: envelope.senderUsername?.takeIf { it.isNotBlank() }
|
||||
?: "User $otherUserId"
|
||||
?: ""
|
||||
}
|
||||
return Message(
|
||||
id = envelope.id,
|
||||
|
||||
+5
-2
@@ -26,6 +26,7 @@ import ru.fromchat.api.local.send.outboundFailureErrorKey
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.SendDmFile
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.buildDmOutboundPlaintext
|
||||
import ru.fromchat.api.local.cache.clearUploadArtifacts
|
||||
import ru.fromchat.api.local.cache.clearUploadSecretsOnly
|
||||
import ru.fromchat.api.local.AttachmentMediaLog
|
||||
@@ -179,10 +180,11 @@ object OutgoingMessageCoordinator {
|
||||
val conversationId = conversationIdForDm(recipientId)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
||||
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
|
||||
val payload = json.encodeToString(
|
||||
DmOutboxPayload(
|
||||
recipientId = recipientId,
|
||||
plaintext = plaintext,
|
||||
plaintext = outboundPlaintext,
|
||||
clientMessageId = clientMessageId,
|
||||
replyToId = replyToId,
|
||||
transportFiles = transportFiles,
|
||||
@@ -226,10 +228,11 @@ object OutgoingMessageCoordinator {
|
||||
)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
||||
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
|
||||
val payload = json.encodeToString(
|
||||
DmAttachmentOutboxPayload(
|
||||
recipientId = recipientId,
|
||||
plaintext = plaintext,
|
||||
plaintext = outboundPlaintext,
|
||||
clientMessageId = clientMessageId,
|
||||
replyToId = replyToId,
|
||||
fileUri = fileUri,
|
||||
|
||||
@@ -47,5 +47,7 @@ data class Message(
|
||||
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
|
||||
@Transient val fileDimensions: List<Pair<Int, Int>>? = null,
|
||||
/** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */
|
||||
@Transient val isContentCorrupted: Boolean = false
|
||||
@Transient val isContentCorrupted: Boolean = false,
|
||||
/** Reply target id from local DB when nested [reply_to] is not hydrated yet. */
|
||||
@Transient val replyToId: Int? = null,
|
||||
)
|
||||
@@ -67,6 +67,7 @@ import ru.fromchat.legal_document_cached_banner
|
||||
import ru.fromchat.legal_document_load_error
|
||||
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
private data class PendingDocument(
|
||||
@@ -108,6 +109,7 @@ fun DocumentScreen(
|
||||
|
||||
ToggleNavScrimEffect()
|
||||
|
||||
ScreenSurface {
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = Color.Transparent,
|
||||
@@ -273,6 +275,7 @@ fun DocumentScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +361,6 @@ private fun DocumentContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ import ru.fromchat.ui.profile.EditProfileFocusField
|
||||
import ru.fromchat.ui.profile.EditProfileScreen
|
||||
import ru.fromchat.ui.profile.ProfileRoutes
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.utils.NetworkConnectivity
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||
@@ -401,8 +402,9 @@ fun App(
|
||||
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
||||
) {
|
||||
if (startDestination != null) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
ScreenSurface {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
enterTransition = { rootNavEnterTransition() },
|
||||
@@ -691,6 +693,7 @@ fun App(
|
||||
}
|
||||
|
||||
CallOverlay(Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.ui.components.googleSansMaterialTypography
|
||||
|
||||
@@ -28,6 +29,9 @@ var theme by mutableStateOf(
|
||||
@Composable
|
||||
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
|
||||
|
||||
@Composable
|
||||
expect fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color)
|
||||
|
||||
@Composable
|
||||
fun FromChatTheme(
|
||||
darkTheme: Boolean = when (theme) {
|
||||
@@ -76,6 +80,8 @@ fun FromChatTheme(
|
||||
val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh)
|
||||
val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest)
|
||||
|
||||
ApplySystemBarTheme(darkTheme = darkTheme, surfaceColor = surface)
|
||||
|
||||
colorScheme = colorScheme.copy(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
|
||||
@@ -42,7 +42,6 @@ import ru.fromchat.call_dismiss
|
||||
import ru.fromchat.call_failed_title
|
||||
import ru.fromchat.call_incoming_subtitle
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.user_fallback
|
||||
|
||||
@Composable
|
||||
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
@@ -96,7 +95,8 @@ fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
val title =
|
||||
cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() }
|
||||
?: cached?.username?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(Res.string.user_fallback, s.fromUserId)
|
||||
?: s.fromUsername.takeIf { it.isNotBlank() }
|
||||
?: ""
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -1090,7 +1090,7 @@ private fun AttachmentUploadFailedOverlay(
|
||||
}
|
||||
val retryText = stringResource(Res.string.attachment_retry)
|
||||
val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
|
||||
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||
val headlineColor = messageBubbleContentColor(isAuthor)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
|
||||
@@ -1125,7 +1125,7 @@ private fun AttachmentImageLoadFailedOverlay(
|
||||
val failedText = stringResource(Res.string.attachment_image_load_failed)
|
||||
val retryText = stringResource(Res.string.attachment_retry)
|
||||
val retryCd = stringResource(Res.string.cd_attachment_retry)
|
||||
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||
val headlineColor = messageBubbleContentColor(isAuthor)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
|
||||
@@ -1194,12 +1194,8 @@ internal fun ExpressiveFileAttachmentRow(
|
||||
onCancelProgress: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||
val supportingColor = if (isAuthor) {
|
||||
Color.White.copy(alpha = 0.78f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val headlineColor = messageBubbleContentColor(isAuthor)
|
||||
val supportingColor = messageBubbleSupportingContentColor(isAuthor)
|
||||
val leadingSize = 48.dp
|
||||
Row(
|
||||
modifier = modifier
|
||||
|
||||
@@ -22,6 +22,7 @@ import ru.fromchat.ui.chat.utils.TypingHandler
|
||||
import ru.fromchat.ui.chat.utils.TypingUser
|
||||
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
||||
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
|
||||
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
|
||||
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@@ -83,11 +84,14 @@ abstract class ChatPanel(
|
||||
|
||||
/** Merge SQLDelight rows with in-memory optimistic attachment UI (pending preview, thumbnails). */
|
||||
suspend fun syncMessagesFromDatabase(messages: List<Message>) {
|
||||
batchStateUpdates {
|
||||
updateState { current ->
|
||||
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
|
||||
if (current.messages == merged) current
|
||||
else current.copy(messages = merged)
|
||||
addMessageMutex.withLock {
|
||||
batchStateUpdates {
|
||||
updateState { current ->
|
||||
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
|
||||
val withReplies = attachPublicReplyReferences(merged)
|
||||
if (current.messages == withReplies) current
|
||||
else current.copy(messages = withReplies)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,7 +364,9 @@ abstract class ChatPanel(
|
||||
mapped + resolvedConfirmed
|
||||
else -> mapped
|
||||
}
|
||||
currentState.copy(messages = sortMessagesForChatDisplay(messages))
|
||||
currentState.copy(
|
||||
messages = sortMessagesForChatDisplay(attachPublicReplyReferences(messages)),
|
||||
)
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val resolved = _state.messages.find { it.client_message_id == tempId }
|
||||
@@ -435,7 +441,11 @@ abstract class ChatPanel(
|
||||
* Send message with immediate display (optimistic update)
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) {
|
||||
suspend fun sendMessageWithImmediateDisplay(
|
||||
content: String,
|
||||
replyToId: Int?,
|
||||
replyTo: Message? = null,
|
||||
) {
|
||||
if (content.isBlank()) return
|
||||
|
||||
val sendT0 = kotlin.time.Clock.System.now().toEpochMilliseconds()
|
||||
@@ -446,6 +456,10 @@ abstract class ChatPanel(
|
||||
|
||||
// Show the bubble immediately; pace only the network send below.
|
||||
val tempId = generateClientMessageId()
|
||||
val resolvedReply = replyTo?.takeIf { it.id > 0 }
|
||||
?: replyToId?.takeIf { it > 0 }?.let { replyId ->
|
||||
_state.messages.find { it.id == replyId }
|
||||
}
|
||||
val tempMessage = Message(
|
||||
id = -1, // Temporary negative ID
|
||||
user_id = currentUserId ?: -1,
|
||||
@@ -455,9 +469,8 @@ abstract class ChatPanel(
|
||||
is_edited = false,
|
||||
username = "You",
|
||||
client_message_id = tempId,
|
||||
reply_to = replyToId?.let { replyId ->
|
||||
_state.messages.find { it.id == replyId }
|
||||
}
|
||||
reply_to = resolvedReply,
|
||||
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
|
||||
)
|
||||
|
||||
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
|
||||
|
||||
@@ -754,7 +754,7 @@ fun ChatScreen(
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
val replyToId = replyTo?.id
|
||||
val replyToId = replyTo?.id?.takeIf { it > 0 }
|
||||
val recipientId = panel.getRecipientId()
|
||||
if (attachments.isNotEmpty() && recipientId != null) {
|
||||
val plaintext = text.ifBlank { "" }
|
||||
@@ -842,7 +842,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
} else if (text.isNotBlank()) {
|
||||
panel.sendMessageWithImmediateDisplay(text, replyToId)
|
||||
panel.sendMessageWithImmediateDisplay(text, replyToId, replyTo)
|
||||
}
|
||||
replyTo = null
|
||||
haptic(HapticFeedbackEvent.MessageSent)
|
||||
|
||||
@@ -14,8 +14,6 @@ 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 «Вы», deleted user label, or server-provided name.
|
||||
*/
|
||||
@@ -37,11 +35,6 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
||||
if (message.username.equals("deleted", ignoreCase = true)) {
|
||||
return deletedUserDisplayNameForUi()
|
||||
}
|
||||
val m = userIdUsernamePattern.matchEntire(message.username)
|
||||
if (m != null) {
|
||||
val id = m.groupValues[1].toIntOrNull()
|
||||
if (id != null) return deletedUserDisplayNameForUi()
|
||||
}
|
||||
return message.username
|
||||
}
|
||||
|
||||
|
||||
@@ -180,8 +180,7 @@ fun MessageItem(
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
var avatarPressed by remember(message.id) { mutableStateOf(false) }
|
||||
var replyPressed by remember(message.id) { mutableStateOf(false) }
|
||||
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
|
||||
var slackRowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
|
||||
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
|
||||
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
|
||||
@@ -375,9 +374,33 @@ fun MessageItem(
|
||||
)
|
||||
}
|
||||
|
||||
val rowLongPress =
|
||||
if (isContextMenuOpen) Modifier
|
||||
else Modifier.pointerInput(isContextMenuOpen, message.id) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
isPressed = false
|
||||
}
|
||||
},
|
||||
onLongPress = { localOffset ->
|
||||
val coords = rowLayoutCoords
|
||||
if (coords != null && coords.isAttached) {
|
||||
onTapPosition(coords.localToRoot(localOffset))
|
||||
}
|
||||
onLongPress()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned { rowLayoutCoords = it }
|
||||
.then(rowLongPress)
|
||||
.padding(horizontal = 8.dp)
|
||||
.enterLayoutHeight(
|
||||
scale = enterScale.value,
|
||||
@@ -467,13 +490,9 @@ fun MessageItem(
|
||||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||
)
|
||||
|
||||
val bubblePressAndLongPress =
|
||||
if (isContextMenuOpen) Modifier
|
||||
else Modifier.pointerInput(
|
||||
isContextMenuOpen,
|
||||
message.id,
|
||||
onBubbleTap,
|
||||
) {
|
||||
val bubbleTap =
|
||||
if (isContextMenuOpen || onBubbleTap == null) Modifier
|
||||
else Modifier.pointerInput(message.id, onBubbleTap) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
@@ -483,48 +502,11 @@ fun MessageItem(
|
||||
isPressed = false
|
||||
}
|
||||
},
|
||||
onTap = { onBubbleTap?.invoke() },
|
||||
onLongPress = { localOffset ->
|
||||
onTapPosition(bubbleBodyPositionInRoot + localOffset)
|
||||
onLongPress()
|
||||
}
|
||||
onTap = { onBubbleTap.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
val slackRowPressAndLongPress =
|
||||
if (isContextMenuOpen) Modifier
|
||||
else Modifier.pointerInput(
|
||||
isContextMenuOpen,
|
||||
message.id,
|
||||
onBubbleTap,
|
||||
) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
isPressed = false
|
||||
}
|
||||
},
|
||||
onTap = { onBubbleTap?.invoke() },
|
||||
onLongPress = { localOffset ->
|
||||
val coords = slackRowLayoutCoords
|
||||
if (coords != null && coords.isAttached) {
|
||||
onTapPosition(coords.localToRoot(localOffset))
|
||||
} else {
|
||||
onTapPosition(bubbleBodyPositionInRoot + localOffset)
|
||||
}
|
||||
onLongPress()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { slackRowLayoutCoords = it }
|
||||
.then(slackRowPressAndLongPress)
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
horizontalAlignment =
|
||||
if (isAuthor) Alignment.End else Alignment.Start,
|
||||
@@ -618,13 +600,10 @@ fun MessageItem(
|
||||
}
|
||||
}
|
||||
|
||||
val gestureWidthModifier = Modifier.fillMaxWidth()
|
||||
Box(
|
||||
modifier = gestureWidthModifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
bubbleBodyPositionInRoot = coordinates.positionInRoot()
|
||||
}
|
||||
.then(bubblePressAndLongPress)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(bubbleTap)
|
||||
) {
|
||||
Column {
|
||||
replyRef?.let { replyToMsg ->
|
||||
@@ -1069,3 +1048,19 @@ private fun MessageReplyQuote(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun messageBubbleContentColor(isAuthor: Boolean) =
|
||||
if (isAuthor) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun messageBubbleSupportingContentColor(isAuthor: Boolean) =
|
||||
if (isAuthor) {
|
||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.78f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,7 +18,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -41,6 +40,7 @@ import ru.fromchat.attachment_upload_failed
|
||||
import ru.fromchat.attachment_upload_failed_too_large
|
||||
import ru.fromchat.cd_attachment_upload_retry
|
||||
import ru.fromchat.ui.chat.ExpressiveFileAttachmentRow
|
||||
import ru.fromchat.ui.chat.messageBubbleContentColor
|
||||
import ru.fromchat.ui.chat.utils.showAttachmentOpenFailed
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
@@ -228,7 +228,7 @@ fun ChatFileAttachmentTile(
|
||||
}
|
||||
val retryText = stringResource(Res.string.attachment_retry)
|
||||
val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
|
||||
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
|
||||
val headlineColor = messageBubbleContentColor(isAuthor)
|
||||
|
||||
Box(modifier = modifier.widthIn(max = 280.dp)) {
|
||||
ExpressiveFileAttachmentRow(
|
||||
|
||||
+5
-4
@@ -59,13 +59,14 @@ internal fun FileAttachmentLeadingSlot(
|
||||
isDownloaded -> FileLeadingVisual.File
|
||||
else -> FileLeadingVisual.Download
|
||||
}
|
||||
val onPrimary = MaterialTheme.colorScheme.onPrimary
|
||||
val containerColor = if (isAuthor) {
|
||||
Color.White.copy(alpha = 0.22f)
|
||||
onPrimary.copy(alpha = 0.22f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
}
|
||||
val iconOnContainer = if (isAuthor) {
|
||||
Color.White
|
||||
onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
@@ -83,9 +84,9 @@ internal fun FileAttachmentLeadingSlot(
|
||||
onCancel = onCancelProgress,
|
||||
showCloseScrim = false,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
indicatorColor = if (isAuthor) Color.White else null,
|
||||
indicatorColor = if (isAuthor) onPrimary else null,
|
||||
trackColorOverride = if (isAuthor) {
|
||||
Color.White.copy(alpha = 0.28f)
|
||||
onPrimary.copy(alpha = 0.28f)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
@@ -43,10 +43,12 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.ui.chat.utils.DmTypingHandler
|
||||
import ru.fromchat.api.local.download.DownloadedFileRegistry
|
||||
import ru.fromchat.ui.chat.utils.TypingHandler
|
||||
import ru.fromchat.ui.chat.utils.attachDmReplyReferences
|
||||
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
||||
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
|
||||
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
||||
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
|
||||
import ru.fromchat.ui.chat.utils.resolveDmReplyToId
|
||||
import ru.fromchat.ui.chat.isImageFilename
|
||||
import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded
|
||||
|
||||
@@ -246,21 +248,18 @@ class DmPanel(
|
||||
val priorMessages = _state.messages
|
||||
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
||||
val decryptedForLog = mutableListOf<Pair<Int, String>>()
|
||||
val parsedReplyIds = mutableMapOf<Int, Int>()
|
||||
val messages = response.messages.map { envelope ->
|
||||
val outcome = decryptDmEnvelopeForUi(envelope)
|
||||
decryptedForLog.add(envelope.id to outcome.plaintext)
|
||||
val dec = parseDmMessageContent(outcome.plaintext)
|
||||
resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it }
|
||||
createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
}
|
||||
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
|
||||
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
|
||||
}
|
||||
val replyToMap = messages.associateBy { it.id }
|
||||
val messagesWithReplies = messages.map { msg ->
|
||||
val envelope = response.messages.find { it.id == msg.id }
|
||||
if (envelope?.replyToId != null) {
|
||||
msg.copy(reply_to = replyToMap[envelope.replyToId])
|
||||
} else msg
|
||||
}
|
||||
val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds)
|
||||
val mergedForUi = preserveReplyToFromExisting(
|
||||
priorMessages + optimisticSnapshot,
|
||||
messagesWithReplies,
|
||||
@@ -368,11 +367,12 @@ class DmPanel(
|
||||
if (envelope.senderId == currentUserId) {
|
||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
} else {
|
||||
val dec = parseDmMessageContent(outcome.plaintext)
|
||||
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
val replyTo = envelope.replyToId?.let { replyId ->
|
||||
_state.messages.find { it.id == replyId }
|
||||
}
|
||||
val withReply = if (replyTo != null) incoming.copy(reply_to = replyTo) else incoming
|
||||
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
|
||||
val context = _state.messages + incoming
|
||||
val withReply = attachDmReplyReferences(context, replyId?.let { mapOf(incoming.id to it) } ?: emptyMap())
|
||||
.last()
|
||||
if (ActiveDmChatTracker.isActive(otherUserId)) {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.upsertDmMessage(otherUserId, withReply)
|
||||
@@ -446,16 +446,20 @@ 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)
|
||||
val dec = parseDmMessageContent(plaintext)
|
||||
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
|
||||
?: stateSourceBeforeMerge?.reply_to?.id?.takeIf { it > 0 }
|
||||
val mergedWithReply = attachDmReplyReferences(
|
||||
_state.messages.filter { it.id != envelope.id && it.client_message_id != cid } + merged,
|
||||
replyId?.let { mapOf(merged.id to it) } ?: emptyMap(),
|
||||
).last()
|
||||
val mergedForPersistence = mergedWithReply.copy(pendingFilename = null)
|
||||
AttachmentMediaLog.persist(
|
||||
"merge_confirmed",
|
||||
"msgId" to envelope.id,
|
||||
"clientId" to cid,
|
||||
"localPreview" to (merged.pendingFileUri?.take(64) ?: "null"),
|
||||
"localPreview" to (mergedWithReply.pendingFileUri?.take(64) ?: "null"),
|
||||
"aspect" to aspect,
|
||||
)
|
||||
|
||||
@@ -471,7 +475,7 @@ class DmPanel(
|
||||
optimisticIndex >= 0 -> {
|
||||
currentState.messages.mapIndexedNotNull { index, message ->
|
||||
when {
|
||||
index == optimisticIndex -> merged
|
||||
index == optimisticIndex -> mergedWithReply
|
||||
message.id == envelope.id -> null
|
||||
else -> message
|
||||
}
|
||||
@@ -479,10 +483,10 @@ class DmPanel(
|
||||
}
|
||||
existingRealIndex >= 0 -> {
|
||||
currentState.messages.mapIndexed { index, message ->
|
||||
if (index == existingRealIndex) merged else message
|
||||
if (index == existingRealIndex) mergedWithReply else message
|
||||
}
|
||||
}
|
||||
else -> currentState.messages + merged
|
||||
else -> currentState.messages + mergedWithReply
|
||||
}
|
||||
val deduped = dedupeMessagesByClientId(newMessages)
|
||||
currentState.copy(messages = deduped)
|
||||
@@ -540,7 +544,7 @@ class DmPanel(
|
||||
val username = if (envelope.senderId == currentUserId) {
|
||||
"You"
|
||||
} else {
|
||||
otherDisplayName.ifBlank { "User $otherUserId" }
|
||||
otherDisplayName
|
||||
}
|
||||
return Message(
|
||||
id = envelope.id,
|
||||
|
||||
+27
-13
@@ -31,6 +31,8 @@ import ru.fromchat.ui.chat.AvatarInfo
|
||||
import ru.fromchat.ui.chat.ChatPanel
|
||||
import ru.fromchat.ui.chat.utils.PublicChatTypingHandler
|
||||
import ru.fromchat.ui.chat.utils.TypingHandler
|
||||
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
|
||||
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
|
||||
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
|
||||
|
||||
class PublicChatPanel(
|
||||
@@ -160,23 +162,31 @@ class PublicChatPanel(
|
||||
|
||||
private suspend fun hydrateMessagesFromLocalCache() {
|
||||
val cached = withContext(Dispatchers.Default) {
|
||||
runCatching { MessageRepository.loadRecentPublicMessagesImmediate(limit = 128) }
|
||||
runCatching { MessageRepository.loadPublicMessages() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
if (cached.isEmpty()) return
|
||||
if (cached.isEmpty() && _state.messages.isEmpty()) return
|
||||
withContext(Dispatchers.Main) {
|
||||
batchStateUpdates {
|
||||
val shown = _state.messages
|
||||
if (shown.isNotEmpty()) {
|
||||
val withReplies = preserveReplyToFromExisting(shown, cached)
|
||||
if (withReplies != shown) {
|
||||
updateState { it.copy(messages = sortMessagesForChatDisplay(withReplies)) }
|
||||
when {
|
||||
shown.isEmpty() -> {
|
||||
if (cached.isNotEmpty()) {
|
||||
clearMessages()
|
||||
addMessages(cached)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
cached.isEmpty() -> setLoading(false)
|
||||
else -> {
|
||||
// Never replace the in-memory list with the DB snapshot alone — that
|
||||
// dropped paginated / ahead-of-network rows when reopening (e.g. profile).
|
||||
val merged = mergeDatabaseMessagesWithPanelState(shown, cached)
|
||||
if (merged != shown) {
|
||||
updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) }
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
} else {
|
||||
clearMessages()
|
||||
addMessages(cached)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,10 +229,11 @@ class PublicChatPanel(
|
||||
|
||||
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
||||
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
||||
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg)
|
||||
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
|
||||
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(withReply)
|
||||
addMessage(displayMessage)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.upsertPublicMessage(newMsg)
|
||||
MessageCacheStore.upsertPublicMessage(withReply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +376,9 @@ class PublicChatPanel(
|
||||
messages = older + currentState.messages
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -5,6 +5,44 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
|
||||
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||
|
||||
internal fun resolveDmReplyToId(
|
||||
envelope: DmEnvelope?,
|
||||
parsedReplyToId: Int?,
|
||||
): Int? = envelope?.replyToId?.takeIf { it > 0 }
|
||||
?: parsedReplyToId?.takeIf { it > 0 }
|
||||
|
||||
internal fun resolvePublicReplyToId(message: Message): Int? =
|
||||
message.replyToId?.takeIf { it > 0 }
|
||||
?: message.reply_to?.id?.takeIf { it > 0 }
|
||||
|
||||
/** Resolves [Message.reply_to] from in-chat siblings when the payload is missing or stub-only. */
|
||||
internal fun attachPublicReplyReferences(
|
||||
messages: List<Message>,
|
||||
parsedReplyIds: Map<Int, Int> = emptyMap(),
|
||||
): List<Message> {
|
||||
val byId = messages.associateBy { it.id }
|
||||
return messages.map { msg ->
|
||||
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
|
||||
val nested = msg.reply_to
|
||||
if (nested != null && nested.content.isNotBlank()) return@map msg
|
||||
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves [Message.reply_to] from envelope metadata and/or parsed reply ids. */
|
||||
internal fun attachDmReplyReferences(
|
||||
messages: List<Message>,
|
||||
parsedReplyIds: Map<Int, Int> = emptyMap(),
|
||||
): List<Message> {
|
||||
val byId = messages.associateBy { it.id }
|
||||
return messages.map { msg ->
|
||||
if (msg.reply_to != null) return@map msg
|
||||
val replyId = resolveDmReplyToId(msg.dmEnvelope, parsedReplyIds[msg.id]) ?: return@map msg
|
||||
byId[replyId]?.let { msg.copy(reply_to = it) } ?: msg
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLDelight rows omit optimistic attachment fields; merge DB snapshot with in-memory UI state.
|
||||
@@ -70,6 +108,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
|
||||
fileDimensions = db.fileDimensions ?: panel.fileDimensions,
|
||||
content = db.content.ifBlank { panel.content },
|
||||
isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted,
|
||||
replyToId = db.replyToId ?: panel.replyToId,
|
||||
reply_to = db.reply_to ?: panel.reply_to,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.fromchat.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Full-bleed themed background for screens behind edge-to-edge system bars.
|
||||
* Paint this at the root; apply [WindowInsets] padding on inner content, not on this layer.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.background,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(color),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -90,7 +90,6 @@ 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 {
|
||||
private const val CATEGORY_TOP_SPACER = 0
|
||||
@@ -873,7 +872,7 @@ internal fun DmConversationRowContent(
|
||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||
else -> stringResource(Res.string.user_fallback, conversation.otherUserId)
|
||||
else -> cached?.visibleUsername(currentUserId).orEmpty()
|
||||
}
|
||||
val avatarInitialsLabel = when {
|
||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.resetFocus
|
||||
@@ -76,6 +77,7 @@ import ru.fromchat.search_not_found_message
|
||||
import ru.fromchat.search_title
|
||||
import ru.fromchat.ui.components.SearchBar
|
||||
import ru.fromchat.ui.components.SearchBarSharedElement
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@@ -229,12 +231,12 @@ fun ChatsSearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
topBar = {
|
||||
ScreenSurface {
|
||||
Scaffold(
|
||||
modifier = Modifier.imePadding(),
|
||||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
topBar = {
|
||||
SearchBar(
|
||||
query = searchText,
|
||||
onQueryChange = { searchText = it },
|
||||
@@ -325,6 +327,7 @@ fun ChatsSearchScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) =
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -86,6 +87,7 @@ import ru.fromchat.Res
|
||||
import ru.fromchat.action_delete
|
||||
import ru.fromchat.action_mark_read
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ChatListSync
|
||||
import ru.fromchat.api.calls.CallStore
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
@@ -289,6 +291,7 @@ fun ChatsTab(
|
||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||
val profileCacheRevision by ProfileCache.revision.collectAsState()
|
||||
var dmConversations by remember(activeInstanceId) {
|
||||
val cached = if (activeInstanceId.isBlank()) {
|
||||
emptyList()
|
||||
@@ -334,8 +337,11 @@ fun ChatsTab(
|
||||
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
|
||||
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
||||
|
||||
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
|
||||
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) {
|
||||
MessageCacheStore.listPreviewStrings = previewStrings
|
||||
if (activeInstanceId.isNotBlank()) {
|
||||
runCatching { ChatListSync.syncFromNetwork() }
|
||||
}
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
@@ -716,6 +722,7 @@ fun ChatsTab(
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
key(profileCacheRevision) {
|
||||
ChatConversationsList(
|
||||
listState = tabListState,
|
||||
listFilter = ChatListFilter.Active,
|
||||
@@ -809,6 +816,7 @@ fun ChatsTab(
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -108,6 +109,7 @@ import ru.fromchat.logging.LogShareCompression
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.BackHandler
|
||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
|
||||
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
|
||||
@@ -424,6 +426,7 @@ fun LogFilesScreen(
|
||||
val listBottomInset = if (selectionBarVisible) 88.dp else 8.dp
|
||||
val fileCategoryColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
|
||||
ScreenSurface {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = Color.Transparent,
|
||||
@@ -442,7 +445,9 @@ fun LogFilesScreen(
|
||||
topBar = {
|
||||
Box {
|
||||
TopAppBar(
|
||||
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = 1f - selectionProgress }
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { navController.navigateUp() },
|
||||
@@ -493,7 +498,8 @@ fun LogFilesScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
.padding(innerPadding)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
DisableSelection {
|
||||
LazyColumn(
|
||||
@@ -626,6 +632,7 @@ fun LogFilesScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -197,6 +198,7 @@ import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.BackHandler
|
||||
import ru.fromchat.ui.components.ExpressiveIconFrame
|
||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
|
||||
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
|
||||
@@ -719,6 +721,7 @@ fun LogsScreen() {
|
||||
ToggleNavScrimEffect()
|
||||
}
|
||||
|
||||
ScreenSurface {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -752,9 +755,11 @@ fun LogsScreen() {
|
||||
topBar = {
|
||||
Box {
|
||||
TopAppBar(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = (1f - selectionProgress) * (1f - searchProgress)
|
||||
},
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
alpha = (1f - selectionProgress) * (1f - searchProgress)
|
||||
}
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { navController.navigateUp() },
|
||||
@@ -976,12 +981,17 @@ fun LogsScreen() {
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
when {
|
||||
displayEntries.isEmpty() -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.then(if (searchMode) Modifier.imePadding() else Modifier),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -998,7 +1008,6 @@ fun LogsScreen() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.imePadding(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -1052,13 +1061,14 @@ fun LogsScreen() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.then(if (searchMode) Modifier.imePadding() else Modifier),
|
||||
) {
|
||||
listContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ import ru.fromchat.ui.components.DisabledBringIntoViewSpec
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.HazeActionButton
|
||||
import ru.fromchat.ui.components.LazyListFocusScrollEffect
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.expressiveStepFieldColors
|
||||
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
|
||||
@@ -320,6 +321,7 @@ fun EditProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ScreenSurface {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
@@ -499,4 +501,5 @@ fun EditProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.chat.TypingIndicator
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.ScreenSurface
|
||||
import ru.fromchat.ui.components.ShimmerBox
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||
@@ -296,6 +297,17 @@ fun ProfileScreen(
|
||||
}
|
||||
|
||||
val latestUi by rememberUpdatedState(state)
|
||||
val profileCacheRevision by ProfileCache.revision.collectAsState()
|
||||
val isOwnProfileLookup = targetUserId == null && targetUsername == null
|
||||
|
||||
LaunchedEffect(profileCacheRevision) {
|
||||
if (!isOwnProfileLookup) return@LaunchedEffect
|
||||
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
|
||||
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
|
||||
state = latestUi.copy(profile = cached, isLoading = false, error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backStackEntry = navController.currentBackStackEntry
|
||||
LaunchedEffect(backStackEntry, lookupKey) {
|
||||
@@ -307,6 +319,7 @@ fun ProfileScreen(
|
||||
try {
|
||||
val refreshed = ApiClient.getOwnProfile()
|
||||
ProfileCache.put(refreshed)
|
||||
ApiClient.applyOwnProfile(refreshed)
|
||||
state = latestUi.copy(profile = refreshed, error = null)
|
||||
} catch (_: Exception) {
|
||||
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
|
||||
@@ -367,6 +380,9 @@ fun ProfileScreen(
|
||||
)
|
||||
|
||||
ProfileCache.put(profile)
|
||||
if (targetUserId == null && targetUsername == null) {
|
||||
ApiClient.applyOwnProfile(profile)
|
||||
}
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
loadedSuccessfully = true
|
||||
true
|
||||
@@ -604,21 +620,18 @@ fun ProfileScreen(
|
||||
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
) {
|
||||
ScreenSurface(modifier = modifier) {
|
||||
val useSharedAvatar = sharedTransitionScope != null &&
|
||||
animatedVisibilityScope != null &&
|
||||
sharedAvatarKey != null
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -764,10 +777,12 @@ fun ProfileScreen(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -890,21 +905,18 @@ fun PublicChatProfileScreen(
|
||||
animatedVisibilityScope != null &&
|
||||
sharedAvatarKey != null
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
ScreenSurface(modifier = modifier) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when {
|
||||
useSharedAvatar && displayName.isNotBlank() -> {
|
||||
item {
|
||||
@@ -974,10 +986,12 @@ fun PublicChatProfileScreen(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ WHERE instanceId = ? AND id = ?;
|
||||
|
||||
deleteEmptyDmConversations:
|
||||
DELETE FROM conversation
|
||||
WHERE instanceId = ? AND type = 'dm' AND id NOT IN (
|
||||
WHERE instanceId = ? AND type = 'dm' AND lastMessageId IS NULL AND id NOT IN (
|
||||
SELECT DISTINCT conversationId FROM message
|
||||
WHERE instanceId = ? AND deletedFlag = 0
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ package ru.fromchat.ui
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
|
||||
if (darkTheme) darkColorScheme() else lightColorScheme()
|
||||
if (darkTheme) darkColorScheme() else lightColorScheme()
|
||||
|
||||
@Composable
|
||||
actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit
|
||||
|
||||
Reference in New Issue
Block a user