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