Clear cache on logout

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-05 22:04:55 +03:00
Unverified
parent 5a28f0c756
commit 27ff5e2e4a
23 changed files with 353 additions and 52 deletions
@@ -19,3 +19,11 @@ actual suspend fun wipeAttachmentCacheDirectories() {
}
}
}
actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) {
withContext(Dispatchers.IO) {
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (safe.isEmpty()) return@withContext
File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe").deleteRecursively()
}
}
@@ -52,12 +52,11 @@ import ru.fromchat.api.instance.InstanceIdGuard
import ru.fromchat.api.instance.InstanceIdResolveResult
import ru.fromchat.api.instance.resolveInstanceId
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.readOutboundFileBytes
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.download.streamEncryptedFileToDisk
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl
@@ -115,8 +114,6 @@ import ru.fromchat.api.schema.websocket.types.DmTypingData
import ru.fromchat.api.schema.websocket.types.SubscribeStatusData
import ru.fromchat.config.ServerConfig
import ru.fromchat.config.Settings
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds
@@ -236,13 +233,10 @@ object ApiClient {
val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
if (!isCredentialCheck) {
token = null
user = null
clearSuspensionState()
onAuthError?.let {
MainScope().launch {
it()
}
runCatching { WebSocketManager.disconnect() }
runCatching { clearLocalSession() }
onAuthError?.invoke()
}
}
}
@@ -370,6 +364,7 @@ object ApiClient {
}
Settings.lastKnownServerInstanceId = id
CacheContext.setActiveInstance(id, user?.id)
PublicChatProfileSync.ensureStarted()
scheduleOutboxProcessing(id)
}
else -> Unit
@@ -1150,6 +1145,17 @@ object ApiClient {
}
}
/** Clears in-memory and on-disk partial download state on logout. */
suspend fun clearAllDownloadCachesOnLogout() {
val keys = partialDownloadMetaCache.keys.toList()
keys.forEach { clearPartialEncryptedDownload(it) }
partialDownloadMetaCache.clear()
pausedDownloadIndexCache = emptySet()
pausedDownloadIndexPath()?.let { path ->
runCatching { PlatformFileSystem.delete(path) }
}
}
private fun pausedDownloadIndexPath(): String? {
val dir = encryptedDownloadsDir() ?: return null
return "$dir/paused_keys.txt"
@@ -1383,10 +1389,7 @@ object ApiClient {
*/
suspend fun clearLocalSession() {
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
if (instanceId.isNotEmpty()) {
runCatching { cancelOutboxProcessing(instanceId) }
runCatching { MessageRepository.purgeAllPendingForInstance() }
}
runCatching { clearAccountCacheOnLogout(instanceId) }
val uid = user?.id
secureSettings.remove("auth_token")
settings.remove("user_info")
@@ -1399,10 +1402,6 @@ object ApiClient {
uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) }
UpdateSyncManager.resetInMemoryOnLogout()
runCatching { IdentityKeyManager.clearLocalKeys() }
runCatching { ProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
runCatching { PublicChatProfileCache.clear() }
runCatching { PublicChatPanelCache.clear() }
runCatching { CacheContext.clearActive() }
}
@@ -2,6 +2,7 @@ package ru.fromchat.api
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@@ -22,6 +23,7 @@ object PublicChatProfileSync {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var started = false
private var syncJob: Job? = null
fun ensureStarted() {
if (started) return
@@ -31,12 +33,18 @@ object PublicChatProfileSync {
scope.launch { refreshFromNetworkIfNeeded() }
}
scope.launch {
syncJob = scope.launch {
runCatching { PublicChatProfileCache.hydrateFromDisk() }
syncUntilLoaded()
}
}
fun resetOnLogout() {
syncJob?.cancel()
syncJob = null
started = false
}
suspend fun refreshFromNetwork(): PublicChatProfile {
if (ApiClient.token.isNullOrEmpty()) {
return PublicChatProfileCache.profile
@@ -420,4 +420,10 @@ object DecryptedFileCache {
private fun sanitizeKeyPart(value: String): String =
value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
suspend fun clearMemoryCache() {
cacheMutex.withLock {
memoryCache.clear()
}
}
}
@@ -501,4 +501,10 @@ object DecryptedImageCache {
private fun invalidatePath(path: String) {
runCatching { PlatformFileSystem.delete(path) }
}
suspend fun clearMemoryCache() {
cacheMutex.withLock {
memoryCache.clear()
}
}
}
@@ -8,6 +8,9 @@ expect suspend fun wipeFromChatCacheDirectory()
/** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */
expect suspend fun wipeAttachmentCacheDirectories()
/** Deletes `cacheDir/fromchat/instances/<instanceId>/` auxiliary files (export index, pending saves). */
expect suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String)
suspend fun wipeAllOnDiskAttachmentCaches() {
wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories()
@@ -80,16 +80,31 @@ object PendingFileSaveRegistry {
}
}
private fun indexPath(): String? {
private fun indexPath(): String? = instanceIndexPath(
runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default",
)
private fun instanceIndexPath(instanceId: String): String? {
val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default"
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (safe.isEmpty()) return null
val dir = "$base/fromchat/instances/$safe"
PlatformFileSystem.ensureDirectory(dir)
return "$dir/$INDEX_FILE"
}
suspend fun clearForInstance(instanceId: String) {
val path = instanceIndexPath(instanceId) ?: return
mutex.withLock {
memory.clear()
diskLoaded = false
}
withContext(Dispatchers.Default) {
runCatching { PlatformFileSystem.delete(path) }
}
}
private suspend fun readIndexFromDisk(): List<PendingFileSaveEntry> {
val path = indexPath() ?: return emptyList()
if (!PlatformFileSystem.exists(path)) return emptyList()
@@ -2,18 +2,31 @@ package ru.fromchat.api.local.db
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.ApiClient
import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedFileCache
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.cache.PendingFileSaveRegistry
import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories
import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory
import ru.fromchat.api.local.cache.wipeInstanceAuxiliaryCacheDirectory
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.download.AttachmentDownloadScheduler
import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
/**
* Drops the on-disk FromChat cache tree and reopens SQLite on next access.
* Call [writeFromChatCacheGeneration] after this when wiping from settings.
* Call [ru.fromchat.api.local.cache.writeFromChatCacheGeneration] after this when wiping from settings.
*/
suspend fun wipeLocalCacheOnDisk() {
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
@@ -25,7 +38,39 @@ suspend fun wipeLocalCacheOnDisk() {
}
wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories()
PublicChatProfileCache.clear()
PublicChatPanelCache.clear()
DmPanelCache.clearAll()
clearInMemoryAccountCaches()
}
/**
* Clears all per-account cache for the active server instance on logout.
* Preserves other server-instance partitions in SQLite for multi-server use.
*/
suspend fun clearAccountCacheOnLogout(instanceId: String) {
val id = instanceId.trim()
PublicChatProfileSync.resetOnLogout()
if (id.isNotEmpty()) {
cancelOutboxProcessing(id)
runCatching { MessageRepository.purgeAllPendingForInstance() }
runCatching { AttachmentDownloadScheduler.cancelAllOnLogout() }
runCatching { InstanceRegistryStore.purgePartition(id) }
runCatching { InstanceRegistryStore.clearServerBindingForCurrentConfig() }
runCatching { wipeInstanceAuxiliaryCacheDirectory(id) }
runCatching { DownloadedFileRegistry.clearForInstance(id) }
runCatching { PendingFileSaveRegistry.clearForInstance(id) }
}
runCatching { ApiClient.clearAllDownloadCachesOnLogout() }
wipeAttachmentCacheDirectories()
clearInMemoryAccountCaches()
}
private suspend fun clearInMemoryAccountCaches() {
runCatching { ProfileCache.clear() }
runCatching { PublicChatProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
PublicChatPanelCache.clear()
AttachmentDownloadNotifier.resetOnLogout()
DecryptedImageCache.clearMemoryCache()
DecryptedFileCache.clearMemoryCache()
MessageRepository.resetListPreviewStringsOnLogout()
LocalDecodedImageCache.evictPrefix("img_")
}
@@ -9,6 +9,7 @@ import ru.fromchat.api.instance.configKey
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.config.Settings
import kotlin.time.Clock
object InstanceRegistryStore {
@@ -111,4 +112,12 @@ object InstanceRegistryStore {
}
}
}
suspend fun clearServerBindingForCurrentConfig() {
withContext(Dispatchers.Default) {
MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey())
}
}
}
}
@@ -91,10 +91,10 @@ object MessageCacheStore {
}
suspend fun loadPublicMessages(): List<Message> =
loadMessages(conversationIdForPublic())
ProfileCache.enrichPublicMessagesForDisplay(loadMessages(conversationIdForPublic()))
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
loadRecentMessages(conversationIdForPublic(), limit)
ProfileCache.enrichPublicMessagesForDisplay(loadRecentMessages(conversationIdForPublic(), limit))
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
if (instanceId.isBlank()) return emptyList()
@@ -105,13 +105,15 @@ object MessageCacheStore {
.map { row: DbMessage -> row.toAppMessage() }
.reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
return sortMessagesForChatDisplay(
return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay(
validatedOrEmpty(
convId,
dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, convId),
),
),
),
)
}
@@ -150,6 +152,7 @@ object MessageCacheStore {
}
suspend fun replacePublicMessages(messages: List<Message>) {
ProfileCache.mergePreviewFromPublicMessages(messages)
conversationIdForPublic().let {
replaceMessages(
it,
@@ -225,6 +228,7 @@ object MessageCacheStore {
}
suspend fun upsertPublicMessage(message: Message) {
ProfileCache.mergePreviewFromPublicMessage(message)
upsertSingle(conversationIdForPublic(), message)
}
@@ -280,6 +284,7 @@ object MessageCacheStore {
}
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
ProfileCache.mergePreviewFromPublicMessage(confirmed)
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
}
@@ -149,4 +149,8 @@ object MessageRepository {
MessageCacheStore.pruneEmptyConversations()
suspend fun clearAllCache() = MessageCacheStore.clearAll()
fun resetListPreviewStringsOnLogout() {
MessageCacheStore.listPreviewStrings = null
}
}
@@ -6,6 +6,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.profile.UserProfile
@@ -182,7 +183,8 @@ object ProfileCache {
val existing = get(uid)
if (existing != null && !existing.isClientPreviewOnly) return
val uname = message.username.ifBlank { existing?.username ?: return }
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return
val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture
@@ -206,6 +208,43 @@ object ProfileCache {
)
}
fun mergePreviewFromPublicMessages(messages: Iterable<Message>) {
messages.forEach(::mergePreviewFromPublicMessage)
}
/**
* Fills blank sender fields on a public-chat [Message] from [ProfileCache] or the current user.
*/
fun enrichPublicMessageForDisplay(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): Message {
val self = currentUserId
if (self != null && message.user_id == self) {
val user = ApiClient.user
return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture,
)
}
val profile = get(message.user_id)
return message.copy(
username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty()
},
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture,
verified = message.verified ?: profile?.verified,
verificationStatus = message.verificationStatus ?: profile?.verificationStatus,
)
}
fun enrichPublicMessagesForDisplay(
messages: List<Message>,
currentUserId: Int? = ApiClient.user?.id,
): List<Message> = messages.map { enrichPublicMessageForDisplay(it, currentUserId) }
fun onActiveInstanceChanged(instanceId: String) {
ioScope.launch {
persistMutex.withLock {
@@ -369,4 +369,11 @@ object AttachmentDownloadNotifier {
val id = keys.firstOrNull() ?: return DownloadProgressThrottle()
return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() }
}
fun resetOnLogout() {
_progressPercentByKey.value = emptyMap()
_failedKeys.value = emptySet()
_cancelledKeys.value = emptySet()
progressThrottleByKey.clear()
}
}
@@ -202,6 +202,13 @@ object AttachmentDownloadScheduler {
}.thenBy { it.enqueuedAt },
)
}
suspend fun cancelAllOnLogout() {
val keys = mutex.withLock {
(activeJobs.keys + waiting.map { it.storageKey } + keyToDeferred.keys).toSet()
}
keys.forEach { cancel(it) }
}
}
internal fun checkAttachmentDownloadActive(storageKey: String) {
@@ -142,16 +142,31 @@ object DownloadedFileRegistry {
private fun sanitizeKeyPart(value: String): String =
value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
private fun indexPath(): String? {
private fun indexPath(): String? = instanceIndexPath(
runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default",
)
private fun instanceIndexPath(instanceId: String): String? {
val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default"
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (safe.isEmpty()) return null
val dir = "$base/fromchat/instances/$safe"
PlatformFileSystem.ensureDirectory(dir)
return "$dir/$INDEX_FILE"
}
suspend fun clearForInstance(instanceId: String) {
val path = instanceIndexPath(instanceId) ?: return
mutex.withLock {
memory.clear()
diskIndexLoaded = false
}
withContext(Dispatchers.Default) {
runCatching { PlatformFileSystem.delete(path) }
}
}
private suspend fun readIndexFromDisk(): Map<String, String> {
val path = indexPath() ?: return emptyMap()
if (!PlatformFileSystem.exists(path)) return emptyMap()
@@ -59,8 +59,10 @@ import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.instance.bootstrapSessionInstance
import ru.fromchat.api.instance.bootstrapSessionOnStartup
import ru.fromchat.api.instance.logoutIfInstanceUnsupported
import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
@@ -427,6 +429,16 @@ fun App(
composable("auth") {
AuthScreen(
onAuthSuccess = {
MainScope().launch {
runCatching {
bootstrapSessionInstance(
hasToken = true,
forceNetwork = false,
)
}
PublicChatProfileSync.ensureStarted()
scheduleSessionInstanceNetworkRefresh()
}
WebSocketManager.connect(forceRestart = true)
navController.navigateAndWipeBackStack("chat")
},
@@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import io.ktor.client.call.body
@@ -22,6 +23,8 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.crypto.IdentityKeyManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.probeServer
import ru.fromchat.api.schema.core.ErrorResponse
@@ -98,6 +101,8 @@ private suspend fun fullLogin(
password: String,
request: suspend () -> LoginResponse,
) {
val previousInstanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
runCatching { clearAccountCacheOnLogout(previousInstanceId) }
ApiClient.clearMemorySession()
val response = request()
@@ -257,6 +262,31 @@ fun AuthScreen(
bio = ""
}
LaunchedEffect(flowState.pagerState) {
var settledPage = flowState.pagerState.currentPage
snapshotFlow { flowState.pagerState.currentPage }
.collect { page ->
if (page < settledPage) {
when (page) {
AuthFlowStep.Username.ordinal -> {
password = ""
confirmPassword = ""
}
AuthFlowStep.Password.ordinal -> {
password = ""
confirmPassword = ""
}
AuthFlowStep.ConfirmPassword.ordinal -> {
confirmPassword = ""
}
}
}
settledPage = page
}
}
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
@@ -434,6 +434,7 @@ fun ChatInput(
enabled = !isReadOnly,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.align(Alignment.Bottom),
textStyle = inputTextStyle,
singleLine = false,
@@ -452,6 +453,7 @@ fun ChatInput(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = ChatInputIconSlotSize)
.animateContentSize()
.align(Alignment.BottomStart),
contentAlignment = Alignment.CenterStart,
) {
@@ -469,11 +471,9 @@ fun ChatInput(
modifier = Modifier.fillMaxWidth(),
)
}
Box(modifier = Modifier.animateContentSize()) {
innerTextField()
}
}
}
},
)
@@ -3,9 +3,11 @@ package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.message_sender_you
import ru.fromchat.user_fallback
@@ -38,3 +40,27 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
}
return message.username
}
fun messageSenderProfilePicture(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): String? {
if (currentUserId != null && message.user_id == currentUserId) {
return message.profile_picture?.takeIf { it.isNotBlank() }
?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() }
}
return message.profile_picture?.takeIf { it.isNotBlank() }
?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() }
}
fun messageSenderAvatarLabel(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): String {
if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim().ifBlank {
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty()
}
}
@@ -56,6 +56,8 @@ import com.pr0gramm3r101.utils.conditional
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.messages.formatMessageTimeLocal
import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
@@ -123,6 +125,11 @@ fun MessageItem(
val editedSuffix = stringResource(Res.string.message_edited_suffix)
val sendFailedLabel = stringResource(Res.string.message_send_failed)
val displayUsername = messageDisplayUsername(message, currentUserId)
val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture
val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() }
?: message.username
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val replyRef = message.reply_to
@@ -184,8 +191,8 @@ fun MessageItem(
}
) {
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
profilePictureUrl = avatarPictureUrl,
displayName = avatarDisplayName,
modifier = Modifier.size(32.dp)
)
}
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
@@ -63,6 +64,25 @@ class PublicChatPanel(
return false
}
private fun mergePublicSenderFieldsFromNetwork(
shown: List<Message>,
fromNetwork: List<Message>,
): List<Message> {
val byId = fromNetwork.associateBy { it.id }
return shown.map { message ->
val fresh = byId[message.id] ?: return@map message
ProfileCache.mergePreviewFromPublicMessage(fresh)
ProfileCache.enrichPublicMessageForDisplay(
message.copy(
username = fresh.username,
profile_picture = fresh.profile_picture,
verified = fresh.verified,
verificationStatus = fresh.verificationStatus,
),
)
}
}
override val supportsNavigateToSenderProfile: Boolean
get() = true
@@ -187,7 +207,9 @@ class PublicChatPanel(
}
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
addMessage(newMsg)
ProfileCache.mergePreviewFromPublicMessage(newMsg)
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg)
addMessage(displayMessage)
withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(newMsg)
}
@@ -252,17 +274,24 @@ class PublicChatPanel(
val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) {
ProfileCache.mergePreviewFromPublicMessages(response.messages)
withContext(Dispatchers.Main) {
val shown = _state.messages
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, response.messages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
} else {
batchStateUpdates {
val merged = mergeNetworkHistoryWithShown(shown, response.messages)
clearMessages()
addMessages(merged)
addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged),
)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
}
@@ -315,10 +344,11 @@ class PublicChatPanel(
ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
}
if (response.messages.isNotEmpty()) {
// Prepend older messages (they come in reverse chronological order)
ProfileCache.mergePreviewFromPublicMessages(response.messages)
val older = ProfileCache.enrichPublicMessagesForDisplay(response.messages.reversed())
updateState { currentState ->
currentState.copy(
messages = response.messages.reversed() + currentState.messages
messages = older + currentState.messages
)
}
}
@@ -108,6 +108,7 @@ import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji
import ru.fromchat.chats_selected_count
import ru.fromchat.config.ServerConfig
import ru.fromchat.public_chat
import ru.fromchat.search_title
import ru.fromchat.status_connecting
import ru.fromchat.status_updating
@@ -517,7 +518,7 @@ fun ChatsTab(
}
}
LaunchedEffect(serverConfig, activeInstanceId) {
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
if (activeInstanceId.isBlank()) return@LaunchedEffect
runCatching {
@@ -547,7 +548,9 @@ fun ChatsTab(
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message)
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason)
val publicChatFallbackTitle = stringResource(Res.string.public_chat)
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
val publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" }
val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title)
val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body)
@@ -38,3 +38,20 @@ actual suspend fun wipeAttachmentCacheDirectories() {
}
}
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) {
withContext(Dispatchers.Default) {
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (safe.isEmpty()) return@withContext
val url = NSFileManager.defaultManager.URLForDirectory(
directory = NSCachesDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
) ?: return@withContext
val base = url.path ?: return@withContext
NSFileManager.defaultManager.removeItemAtPath("$base/fromchat/instances/$safe", error = null)
}
}