Fix conversations not appearing after fresh login

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-12 01:18:13 +03:00
Unverified
parent f90e2cafef
commit eb846f910a
15 changed files with 831 additions and 383 deletions
+1 -3
View File
@@ -1,8 +1,6 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="shared"> <artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path> <output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared.jar"> <root id="archive" name="shared.jar" />
<element id="module-output" name="FromChat.utils.shared.androidMain" />
</root>
</artifact> </artifact>
</component> </component>
@@ -3,10 +3,16 @@ package ru.fromchat.api
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.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
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
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.db.store.MessageCacheStore import ru.fromchat.api.local.db.store.MessageCacheStore
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
@@ -19,7 +25,10 @@ import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat * Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
* message for list previews (without opening each chat first). * message for list previews (without opening each chat first).
*/ */
@OptIn(FlowPreview::class)
object ChatListSync { object ChatListSync {
private const val CONNECTED_SYNC_DEBOUNCE_MS = 300L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var started = false private var started = false
@@ -31,6 +40,14 @@ object ChatListSync {
scope.launch { syncFromNetwork() } scope.launch { syncFromNetwork() }
} }
scope.launch {
ConnectionStateStore.status
.filter { it == ConnectionStatus.CONNECTED }
.distinctUntilChanged()
.debounce(CONNECTED_SYNC_DEBOUNCE_MS)
.collect { syncFromNetwork() }
}
WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage) WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage)
} }
@@ -11,6 +11,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus import ru.fromchat.api.schema.user.profile.VerificationStatus
@@ -165,7 +166,7 @@ object ProfileCache {
if (!hasIdentity) remove(userId) if (!hasIdentity) remove(userId)
} }
fun mergeFromDmUser(user: User) { fun mergeFromDmUser(user: DmConversationUser) {
if (user.id <= 0) return if (user.id <= 0) return
val incomingUsername = user.username.trim() val incomingUsername = user.username.trim()
@@ -188,7 +189,7 @@ object ProfileCache {
} else { } else {
user.profile_picture?.takeIf { it.isNotBlank() } ?: existing.profilePicture user.profile_picture?.takeIf { it.isNotBlank() } ?: existing.profilePicture
}, },
online = user.online, online = user.online ?: existing.online,
lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing.lastSeen, lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing.lastSeen,
verified = user.verified ?: existing.verified, verified = user.verified ?: existing.verified,
verificationStatus = user.verificationStatus ?: existing.verificationStatus, verificationStatus = user.verificationStatus ?: existing.verificationStatus,
@@ -209,9 +210,9 @@ object ProfileCache {
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() } profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
online = user.online, online = user.online ?: existing?.online ?: false,
lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing?.lastSeen, lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing?.lastSeen,
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt, createdAt = existing?.createdAt,
verified = user.verified ?: existing?.verified, verified = user.verified ?: existing?.verified,
verificationStatus = user.verificationStatus ?: existing?.verificationStatus, verificationStatus = user.verificationStatus ?: existing?.verificationStatus,
suspended = user.suspended ?: existing?.suspended, suspended = user.suspended ?: existing?.suspended,
@@ -222,6 +223,24 @@ object ProfileCache {
) )
} }
fun mergeFromUser(user: User) {
mergeFromDmUser(
DmConversationUser(
id = user.id,
username = user.username,
displayName = user.displayName,
profile_picture = user.profile_picture,
online = user.online,
last_seen = user.last_seen,
verified = user.verified,
verificationStatus = user.verificationStatus,
suspended = user.suspended,
suspensionReason = user.suspensionReason,
deleted = user.deleted,
),
)
}
fun mergePreviewFromPublicMessage(message: Message) { fun mergePreviewFromPublicMessage(message: Message) {
val uid = message.user_id val uid = message.user_id
if (uid <= 0) return if (uid <= 0) return
@@ -1,11 +1,10 @@
package ru.fromchat.api.schema.messages.dm package ru.fromchat.api.schema.messages.dm
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import ru.fromchat.api.schema.user.User
@Serializable @Serializable
data class DmConversation( data class DmConversation(
val user: User, val user: DmConversationUser,
val lastMessage: DmEnvelope, val lastMessage: DmEnvelope,
val unreadCount: Int val unreadCount: Int
) )
@@ -0,0 +1,20 @@
package ru.fromchat.api.schema.messages.dm
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import ru.fromchat.api.schema.user.profile.VerificationStatus
@Serializable
data class DmConversationUser(
val id: Int,
val username: String = "",
@SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null,
val online: Boolean? = null,
val last_seen: String? = null,
val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null,
)
@@ -66,6 +66,7 @@ class DmPanel(
private var otherDisplayName: String = "" private var otherDisplayName: String = ""
private var otherProfilePicture: String? = null private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex() private val dmEnvelopeMutex = Mutex()
private val loadMessagesMutex = Mutex()
private var messagesLoaded = false private var messagesLoaded = false
private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean) private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean)
@@ -219,78 +220,82 @@ class DmPanel(
} }
override suspend fun loadMessages() { override suspend fun loadMessages() {
if (messagesLoaded) return loadMessagesMutex.withLock {
messagesLoaded = true if (messagesLoaded) return
runCatching { MessageRepository.ensureDmConversationRow(otherUserId) } runCatching { MessageRepository.ensureDmConversationRow(otherUserId) }
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner // Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile). // when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
} else {
setLoading(true)
}
try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(),
)
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
if (historyResult.isSuccess) {
val response = historyResult.getOrNull() ?: return
val priorMessages = _state.messages
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>()
val parsedReplyIds = mutableMapOf<Int, Int>()
val messages = response.messages.map { envelope ->
val outcome = decryptDmEnvelopeForUi(envelope)
decryptedForLog.add(envelope.id to outcome.plaintext)
val dec = parseDmMessageContent(outcome.plaintext)
resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it }
createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
}
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
}
val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds)
val mergedForUi = preserveReplyToFromExisting(
priorMessages + optimisticSnapshot,
messagesWithReplies,
)
batchStateUpdates { batchStateUpdates {
clearMessages() clearMessages()
addMessages(mergedForUi) addMessages(cached)
restorePendingOptimisticMessages(optimisticSnapshot) setLoading(false)
updateState { state ->
val cleaned = dedupeMessagesByClientId(
dropSupersededOptimisticMessages(state.messages, currentUserId),
)
if (cleaned == state.messages) state else state.copy(messages = cleaned)
}
setHasMoreMessages(false)
}
// Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
} else {
val error = historyResult.exceptionOrNull()
Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error)
if (error is ClientRequestException && error.response.status.value == 403) {
MessageCacheStore.clearDmMessages(otherUserId)
clearMessages()
setHasMoreMessages(false)
} }
messagesLoaded = true
return
}
setLoading(true)
try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(),
)
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
if (historyResult.isSuccess) {
val response = historyResult.getOrNull() ?: return
val priorMessages = _state.messages
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>()
val parsedReplyIds = mutableMapOf<Int, Int>()
val messages = response.messages.map { envelope ->
val outcome = decryptDmEnvelopeForUi(envelope)
decryptedForLog.add(envelope.id to outcome.plaintext)
val dec = parseDmMessageContent(outcome.plaintext)
resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it }
createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
}
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
}
val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds)
val mergedForUi = preserveReplyToFromExisting(
priorMessages + optimisticSnapshot,
messagesWithReplies,
)
batchStateUpdates {
clearMessages()
addMessages(mergedForUi)
restorePendingOptimisticMessages(optimisticSnapshot)
updateState { state ->
val cleaned = dedupeMessagesByClientId(
dropSupersededOptimisticMessages(state.messages, currentUserId),
)
if (cleaned == state.messages) state else state.copy(messages = cleaned)
}
setHasMoreMessages(false)
}
// Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
messagesLoaded = true
} else {
val error = historyResult.exceptionOrNull()
Logger.e("DmPanel", "Failed to load DM history: ${error?.message}", error)
if (error is ClientRequestException && error.response.status.value == 403) {
MessageCacheStore.clearDmMessages(otherUserId)
clearMessages()
setHasMoreMessages(false)
messagesLoaded = true
}
}
} finally {
setLoading(false)
} }
} finally {
setLoading(false)
} }
} }
@@ -54,9 +54,7 @@ fun DmScreen(
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
val peerId = peerUserId ?: return@LaunchedEffect val peerId = peerUserId ?: return@LaunchedEffect
if (peerId <= 0) return@LaunchedEffect if (peerId <= 0) return@LaunchedEffect
if (panel.getState().messages.isEmpty()) { panel.loadMessages()
panel.loadMessages()
}
} }
LaunchedEffect(activeInstanceId, peerUserId) { LaunchedEffect(activeInstanceId, peerUserId) {
@@ -50,6 +50,7 @@ fun ContactsTab() {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.mainPagerBottomInset()
.padding(innerPadding), .padding(innerPadding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -0,0 +1,28 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class MainChromeInsets(
val top: Dp = 0.dp,
val bottom: Dp = 0.dp,
) {
fun asPaddingValues(
extraTop: Dp = 0.dp,
extraBottom: Dp = 0.dp,
): PaddingValues = PaddingValues(
top = top + extraTop,
bottom = bottom + extraBottom,
)
}
val LocalMainChromeInsets = staticCompositionLocalOf { MainChromeInsets() }
@Composable
fun Modifier.mainPagerBottomInset(): Modifier =
padding(bottom = LocalMainChromeInsets.current.bottom)
@@ -4,16 +4,18 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -28,13 +30,18 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
@@ -44,6 +51,7 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -53,14 +61,13 @@ import ru.fromchat.contacts
import ru.fromchat.profile import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import com.pr0gramm3r101.utils.exclude
private const val PAGE_CHATS = 0 private const val PAGE_CHATS = 0
private const val PAGE_CONTACTS = 1 private const val PAGE_CONTACTS = 1
@@ -73,7 +80,7 @@ private const val PAGE_COUNT = 4
fun MainScreen( fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null snackbarHostState: SnackbarHostState? = null,
) { ) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current val navController = LocalNavController.current
@@ -82,15 +89,26 @@ fun MainScreen(
pageCount = { PAGE_COUNT }, pageCount = { PAGE_COUNT },
) )
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current
val navBarHazeState = rememberHazeState(blurEnabled = true) val navBarHazeState = rememberHazeState(blurEnabled = true)
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val contextMenuHazeState = rememberHazeState(blurEnabled = true) val contextMenuHazeState = rememberHazeState(blurEnabled = true)
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp) {
MainChromeInsets(
top = statusBarTopDp,
bottom = bottomChromeHeightDp,
)
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -98,102 +116,114 @@ fun MainScreen(
.hazeSource(contextMenuHazeState), .hazeSource(contextMenuHazeState),
) { ) {
Scaffold( Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) }, snackbarHost = {
bottomBar = { FromChatSnackbarHost(
Column( hostState = effectiveSnackbarHostState,
modifier = Modifier modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
.windowInsetsPadding(WindowInsets.ime) )
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(
state = navBarHazeState,
style = rememberChatSurfaceContainerHazeStyle(),
),
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
)
}
}
}, },
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), containerColor = Color.Transparent,
modifier = Modifier.imePadding(), contentWindowInsets = WindowInsets(0, 0, 0, 0),
) { innerPadding -> modifier = Modifier
Box( .fillMaxSize()
modifier = Modifier .imePadding(),
.fillMaxSize() ) {
.hazeSource(navBarHazeState), CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .hazeSource(navBarHazeState),
) { ) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1, beyondViewportPageCount = 1,
) { page -> ) { page ->
when (page) { when (page) {
PAGE_CHATS -> ChatsTab( PAGE_CHATS -> ChatsTab(
isVisible = isChatsPage, isVisible = isChatsPage,
onOpenSearch = { onOpenSearch = {
navController.navigate("search/conversations") navController.navigate("search/conversations")
}, },
chatContextMenuOverlay = chatContextMenuOverlay, chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
)
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier.fillMaxSize(),
onOpenSettings = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
) )
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier
.fillMaxSize()
.mainPagerBottomInset(),
onOpenSettings = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
)
}
else -> Unit
} }
else -> Unit
} }
} }
}
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.zIndex(1f)
.imePadding()
.onSizeChanged { size ->
val measured = with(density) { size.height.toDp() }
if (measured != bottomChromeHeightDp) {
bottomChromeHeightDp = measured
}
} }
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = navBarHazeState, style = navBarHazeStyle),
) {
NavigationBar(
modifier = Modifier.fillMaxWidth(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) },
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) },
)
} }
} }
} }
@@ -204,7 +234,7 @@ fun MainScreen(
blurProgress = chatMenuBlurProgress, blurProgress = chatMenuBlurProgress,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.zIndex(1f), .zIndex(2f),
) )
} }
@@ -214,7 +244,7 @@ fun MainScreen(
screenHeightPx = constraints.maxHeight, screenHeightPx = constraints.maxHeight,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.zIndex(2f), .zIndex(3f),
) )
} }
} }
@@ -1,6 +1,8 @@
package ru.fromchat.ui.main.chats package ru.fromchat.ui.main.chats
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
@@ -27,10 +29,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -59,8 +63,11 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.ListItemPosition import com.pr0gramm3r101.components.ListItemPosition
@@ -92,8 +99,9 @@ import ru.fromchat.ui.profile.displayNameForUi
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
internal object ChatListLayout { internal object ChatListLayout {
private const val CATEGORY_TOP_SPACER = 0 const val SEARCH_BAR_ROW = 0
const val PUBLIC_CHAT_ROW = CATEGORY_TOP_SPACER + 1 const val COLLAPSED_SCROLL_TARGET_ROW = SEARCH_BAR_ROW + 1
const val PUBLIC_CHAT_ROW = SEARCH_BAR_ROW + 1
fun dmRow(dmIndex: Int): Int = PUBLIC_CHAT_ROW + 1 + dmIndex fun dmRow(dmIndex: Int): Int = PUBLIC_CHAT_ROW + 1 + dmIndex
@@ -113,8 +121,8 @@ internal object SearchListIndices {
private val ChatListCategoryMargin = PaddingValues( private val ChatListCategoryMargin = PaddingValues(
start = 16.dp, start = 16.dp,
end = 16.dp, end = 16.dp,
top = 16.dp, top = 8.dp,
bottom = 20.dp, bottom = 12.dp,
) )
@Composable @Composable
@@ -154,6 +162,15 @@ internal fun ChatConversationsList(
contextMenuState: ChatContextMenuState, contextMenuState: ChatContextMenuState,
overlayCloneReady: Boolean, overlayCloneReady: Boolean,
rowRevealProgress: Float, rowRevealProgress: Float,
listContentPadding: PaddingValues = PaddingValues(0.dp),
showSearchBar: Boolean = false,
searchBarHeight: Dp = 56.dp,
searchBarVisibleFraction: Float = 1f,
searchBarPlaceholder: String = "",
onSearchBarActivate: () -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
listBottomInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onOpenPublic: () -> Unit, onOpenPublic: () -> Unit,
onOpenConversation: (Int) -> Unit, onOpenConversation: (Int) -> Unit,
@@ -188,8 +205,45 @@ internal fun ChatConversationsList(
state = listState, state = listState,
modifier = modifier, modifier = modifier,
userScrollEnabled = !scrollBlocked, userScrollEnabled = !scrollBlocked,
contentPadding = PaddingValues(bottom = 12.dp), contentPadding = listContentPadding,
) { ) {
if (showSearchBar) {
item(key = "chats-search-bar") {
val fraction = searchBarVisibleFraction.coerceIn(0f, 1f)
val visibleHeight = searchBarHeight * fraction
Box(
modifier = Modifier
.fillMaxWidth()
.height(visibleHeight)
.clip(RoundedCornerShape(0.dp)),
) {
if (fraction > 0f) {
SearchBar(
query = "",
onQueryChange = {},
onSearch = {},
placeholder = searchBarPlaceholder,
readOnly = true,
onReadOnlyActivate = onSearchBarActivate,
modifier = Modifier
.fillMaxWidth()
.height(searchBarHeight)
.padding(horizontal = 16.dp),
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedElementKey = SearchBarSharedElement,
)
}
}
}
}
if (groupCount > 0) { if (groupCount > 0) {
Category( Category(
margin = ChatListCategoryMargin, margin = ChatListCategoryMargin,
@@ -327,6 +381,11 @@ internal fun ChatConversationsList(
} }
} }
} }
if (listBottomInset > 0.dp) {
item(key = "chats-main-chrome-bottom-spacer") {
Spacer(Modifier.height(listBottomInset))
}
}
} }
} }
@@ -712,15 +771,17 @@ internal fun PublicChatRowContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage
val pendingIndicator = publicChatPreviewState?.pendingIndicator
?: ChatListPreviewPendingIndicator.None
val showPreview = preview.isNotBlank() || pendingIndicator != ChatListPreviewPendingIndicator.None
ListItem( ListItem(
headline = publicChatTitle.orEmpty(), headline = publicChatTitle.orEmpty(),
supportingSlot = if (publicChatTitle != null) { supportingSlot = if (publicChatTitle != null && showPreview) {
{ {
ChatListPreviewSupportingText( ChatListPreviewSupportingText(
preview = preview, preview = preview,
pendingIndicator = publicChatPreviewState?.pendingIndicator pendingIndicator = pendingIndicator,
?: ChatListPreviewPendingIndicator.None,
uploadProgress = publicChatPreviewState?.uploadProgress, uploadProgress = publicChatPreviewState?.uploadProgress,
) )
} }
@@ -884,21 +945,28 @@ internal fun DmConversationRowContent(
val status = statusMap[conversation.otherUserId] val status = statusMap[conversation.otherUserId]
val typingUsers = status?.typingUsernames.orEmpty() val typingUsers = status?.typingUsernames.orEmpty()
val isTyping = typingUsers.isNotEmpty() val isTyping = typingUsers.isNotEmpty()
val showPreview = isTyping ||
preview.isNotBlank() ||
conversation.lastMessagePendingIndicator != ChatListPreviewPendingIndicator.None
val isOnline = status?.online ?: (cached?.online == true) val isOnline = status?.online ?: (cached?.online == true)
val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow
ListItem( ListItem(
headline = peerTitle, headline = peerTitle,
headlineSlot = { ChatListHeadlineWithBadge(peerTitle, conversation.otherUserId) }, headlineSlot = { ChatListHeadlineWithBadge(peerTitle, conversation.otherUserId) },
supportingSlot = { supportingSlot = if (showPreview) {
if (isTyping) { {
TypingIndicator(typingUsers = typingUsers) if (isTyping) {
} else { TypingIndicator(typingUsers = typingUsers)
ChatListPreviewSupportingText( } else {
preview = preview, ChatListPreviewSupportingText(
pendingIndicator = conversation.lastMessagePendingIndicator, preview = preview,
uploadProgress = conversation.lastMessageUploadProgress, pendingIndicator = conversation.lastMessagePendingIndicator,
) uploadProgress = conversation.lastMessageUploadProgress,
)
}
} }
} else {
null
}, },
containerColor = Color.Transparent, containerColor = Color.Transparent,
position = listItemPosition, position = listItemPosition,
@@ -990,6 +1058,10 @@ internal fun ChatListPreviewSupportingText(
val sendingCd = stringResource(Res.string.cd_chat_preview_sending) val sendingCd = stringResource(Res.string.cd_chat_preview_sending)
val uploadingCd = stringResource(Res.string.cd_chat_preview_uploading) val uploadingCd = stringResource(Res.string.cd_chat_preview_uploading)
if (preview.isBlank() && pendingIndicator == ChatListPreviewPendingIndicator.None) {
return
}
Row( Row(
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -66,7 +66,6 @@ import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji import ru.fromchat.chat_preview_image_emoji
@@ -98,7 +97,6 @@ fun ChatsSearchScreen(
imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji), imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji),
attachmentOnly = stringResource(Res.string.chat_preview_attachment), attachmentOnly = stringResource(Res.string.chat_preview_attachment),
) )
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
val searchHint = stringResource(Res.string.search_hint) val searchHint = stringResource(Res.string.search_hint)
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
@@ -157,7 +155,7 @@ fun ChatsSearchScreen(
try { try {
val users = ApiClient.searchUsers(querySnapshot) val users = ApiClient.searchUsers(querySnapshot)
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
users.forEach { ProfileCache.mergeFromDmUser(it) } users.forEach { ProfileCache.mergeFromUser(it) }
remoteUsers = users remoteUsers = users
lastCompletedSearchQuery = querySnapshot lastCompletedSearchQuery = querySnapshot
} }
@@ -313,7 +311,7 @@ fun ChatsSearchScreen(
remoteUsers = remoteUsers.filter { user -> remoteUsers = remoteUsers.filter { user ->
filteredDmConversations.none { it.otherUserId == user.id } filteredDmConversations.none { it.otherUserId == user.id }
}, },
defaultLastMessage = defaultLastMessage, defaultLastMessage = "",
statusMap = statusMap, statusMap = statusMap,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onOpenConversation = { userId -> onOpenConversation = { userId ->
@@ -5,7 +5,9 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animate import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
@@ -25,6 +27,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -42,10 +45,25 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.graphics.Color
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@@ -62,7 +80,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
@@ -74,6 +94,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -107,7 +128,6 @@ import ru.fromchat.cd_close_selection
import ru.fromchat.cd_selection_more import ru.fromchat.cd_selection_more
import ru.fromchat.chat_delete_confirm_body import ru.fromchat.chat_delete_confirm_body
import ru.fromchat.chat_delete_confirm_title import ru.fromchat.chat_delete_confirm_title
import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji import ru.fromchat.chat_preview_image_emoji
@@ -119,13 +139,12 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
import ru.fromchat.account_suspended import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.BrandTitle
import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.PredictiveBackHandler import ru.fromchat.ui.components.PredictiveBackHandler
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
@@ -135,17 +154,225 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource import kotlin.time.TimeSource
private const val ChatContextMenuHoldGateMs = 250L private const val ChatContextMenuHoldGateMs = 250L
private val ChatsSearchBarHeight = 56.dp
private val ChatsTopBarContentHeight = 64.dp
private fun LazyListState.searchBarHeightPx(collapseRangePx: Float): Float {
val collapseProgress = when (firstVisibleItemIndex) {
ChatListLayout.SEARCH_BAR_ROW ->
(firstVisibleItemScrollOffset / collapseRangePx).coerceIn(0f, 1f)
else -> 1f
}
return collapseRangePx * (1f - collapseProgress)
}
private class SearchBarCollapseSnapState {
var settledHeightPx: Float = 0f
var gestureStartHeightPx: Float = 0f
var gestureMinHeightPx: Float = 0f
var gestureMaxHeightPx: Float = 0f
var dragActive: Boolean = false
var suppressNextEnd: Boolean = false
}
private class SearchBarCollapseController(
private val listState: LazyListState,
private val collapseRangePx: Float,
val snapState: SearchBarCollapseSnapState = SearchBarCollapseSnapState(),
) {
private fun currentSearchBarHeightPx(): Float = listState.searchBarHeightPx(collapseRangePx)
fun beginGesture() {
val currentHeightPx = currentSearchBarHeightPx()
snapState.dragActive = true
snapState.gestureStartHeightPx = currentHeightPx
snapState.gestureMinHeightPx = currentHeightPx
snapState.gestureMaxHeightPx = currentHeightPx
}
fun updateGestureBounds() {
if (!snapState.dragActive) return
val currentHeightPx = currentSearchBarHeightPx()
snapState.gestureMinHeightPx = minOf(snapState.gestureMinHeightPx, currentHeightPx)
snapState.gestureMaxHeightPx = maxOf(snapState.gestureMaxHeightPx, currentHeightPx)
}
private suspend fun snapSearchCollapsed() {
if (listState.layoutInfo.totalItemsCount > ChatListLayout.COLLAPSED_SCROLL_TARGET_ROW) {
listState.animateScrollToItem(ChatListLayout.COLLAPSED_SCROLL_TARGET_ROW)
} else {
listState.animateScrollToItem(
ChatListLayout.SEARCH_BAR_ROW,
scrollOffset = collapseRangePx.toInt(),
)
}
snapState.settledHeightPx = 0f
}
private suspend fun snapSearchExpanded() {
listState.animateScrollToItem(
ChatListLayout.SEARCH_BAR_ROW,
scrollOffset = 0,
)
snapState.settledHeightPx = collapseRangePx
}
suspend fun onScrollGestureEnded() {
val index = listState.firstVisibleItemIndex
val offset = listState.firstVisibleItemScrollOffset
val endHeightPx = currentSearchBarHeightPx()
val fullyExpanded = index == ChatListLayout.SEARCH_BAR_ROW &&
offset == 0 &&
endHeightPx >= collapseRangePx - 1f
val fullyCollapsed = endHeightPx <= 0f ||
index > ChatListLayout.SEARCH_BAR_ROW
val heightReducedByGesture = snapState.gestureStartHeightPx - snapState.gestureMinHeightPx >= 1f
val heightIncreasedByGesture = snapState.gestureMaxHeightPx - snapState.gestureStartHeightPx >= 1f
val startedExpanded = snapState.gestureStartHeightPx >= collapseRangePx - 1f
if (fullyExpanded && !heightReducedByGesture) {
snapState.settledHeightPx = collapseRangePx
return
}
if (fullyCollapsed && !heightIncreasedByGesture) {
snapState.settledHeightPx = 0f
return
}
val shouldCollapse = heightReducedByGesture ||
(startedExpanded && !fullyExpanded) ||
(!fullyExpanded && !fullyCollapsed && !heightIncreasedByGesture)
val shouldExpand = heightIncreasedByGesture && snapState.gestureStartHeightPx < collapseRangePx - 1f
snapState.suppressNextEnd = true
try {
when {
shouldCollapse && !shouldExpand -> snapSearchCollapsed()
shouldExpand -> snapSearchExpanded()
!fullyExpanded -> snapSearchCollapsed()
else -> snapState.settledHeightPx = endHeightPx
}
} finally {
snapState.suppressNextEnd = false
}
}
fun nestedScrollConnection(enabled: Boolean): NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (!enabled || source != NestedScrollSource.UserInput || snapState.suppressNextEnd) {
return Offset.Zero
}
if (!snapState.dragActive) {
beginGesture()
}
updateGestureBounds()
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (!enabled || source != NestedScrollSource.UserInput) {
return Offset.Zero
}
if (!snapState.dragActive && !snapState.suppressNextEnd) {
beginGesture()
}
updateGestureBounds()
return Offset.Zero
}
}
}
@Composable
private fun rememberSearchBarCollapseSnap(
listState: LazyListState,
collapseRangePx: Float,
enabled: Boolean,
): Modifier {
val controller = remember(listState, collapseRangePx) {
SearchBarCollapseController(listState, collapseRangePx)
}
LaunchedEffect(listState, enabled, collapseRangePx) {
if (!enabled) return@LaunchedEffect
controller.snapState.settledHeightPx = listState.searchBarHeightPx(collapseRangePx)
snapshotFlow {
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}.collect {
controller.updateGestureBounds()
}
}
LaunchedEffect(listState, enabled, controller) {
if (!enabled) return@LaunchedEffect
snapshotFlow { listState.isScrollInProgress }.collect { scrolling ->
if (scrolling) {
if (controller.snapState.suppressNextEnd) return@collect
if (!controller.snapState.dragActive) {
controller.beginGesture()
}
return@collect
}
if (!controller.snapState.dragActive) return@collect
controller.snapState.dragActive = false
if (controller.snapState.suppressNextEnd) return@collect
controller.onScrollGestureEnded()
}
}
return Modifier.nestedScroll(
remember(controller, enabled) { controller.nestedScrollConnection(enabled) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)),
scrolledContainerColor = Color.Transparent,
)
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ChatsTopBarHazeBackdrop(
hazeState: HazeState,
blurReveal: Float,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.graphicsLayer { alpha = blurReveal.coerceIn(0f, 1f) }
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin(),
),
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ChatsNormalTopBar( private fun ChatsNormalTopBar(
blurReveal: Float,
titleKey: String, titleKey: String,
connectingTitle: String, connectingTitle: String,
updatingTitle: String, updatingTitle: String,
searchCollapseProgress: Float,
searchContentDescription: String,
onSearchClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
windowInsets = WindowInsets.statusBars,
colors = chatsTopAppBarColors(blurReveal),
title = { title = {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -203,12 +430,26 @@ private fun ChatsNormalTopBar(
} }
} }
}, },
actions = {
IconButton(
onClick = onSearchClick,
modifier = Modifier.graphicsLayer {
alpha = searchCollapseProgress.coerceIn(0f, 1f)
},
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = searchContentDescription,
)
}
},
) )
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ChatsSelectionTopBar( private fun ChatsSelectionTopBar(
blurReveal: Float,
selectedCountTitle: String, selectedCountTitle: String,
closeSelectionCd: String, closeSelectionCd: String,
bulkActions: ChatsBulkActions, bulkActions: ChatsBulkActions,
@@ -224,6 +465,8 @@ private fun ChatsSelectionTopBar(
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
windowInsets = WindowInsets.statusBars,
colors = chatsTopAppBarColors(blurReveal),
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
@@ -275,7 +518,7 @@ private fun ChatsSelectionTopBar(
) )
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun ChatsTab( fun ChatsTab(
isVisible: Boolean = true, isVisible: Boolean = true,
@@ -330,12 +573,12 @@ fun ChatsTab(
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
val tabListState = rememberLazyListState() val tabListState = rememberLazyListState()
val topChromeHazeState = rememberHazeState(blurEnabled = isVisible)
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope() val statusSubscriptionScope = rememberCoroutineScope()
val suspensionState by ApiClient.suspensionState.collectAsState() val suspensionState by ApiClient.suspensionState.collectAsState()
var showSuspendedSupportSheet by remember { mutableStateOf(false) } var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) { LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) {
MessageCacheStore.listPreviewStrings = previewStrings MessageCacheStore.listPreviewStrings = previewStrings
@@ -369,7 +612,6 @@ fun ChatsTab(
fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) { fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) {
haptic(HapticFeedbackEvent.SelectionModeEntered) haptic(HapticFeedbackEvent.SelectionModeEntered)
scope.launch { selectionTransitionProgress.snapTo(0f) }
listMode = ChatsListMode.Selecting listMode = ChatsListMode.Selecting
publicChatSelected = false publicChatSelected = false
selectedOtherUserIds = emptySet() selectedOtherUserIds = emptySet()
@@ -377,6 +619,10 @@ fun ChatsTab(
ChatContextMenuTarget.Public -> publicChatSelected = true ChatContextMenuTarget.Public -> publicChatSelected = true
ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) } ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) }
} }
scope.launch {
selectionTransitionProgress.snapTo(0f)
selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
}
} }
fun exitSelectionMode() { fun exitSelectionMode() {
@@ -435,12 +681,6 @@ fun ChatsTab(
} }
} }
LaunchedEffect(listMode) {
if (listMode == ChatsListMode.Selecting) {
selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
}
}
LaunchedEffect(publicChatSelected, selectedOtherUserIds) { LaunchedEffect(publicChatSelected, selectedOtherUserIds) {
if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) { if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) {
requestExitSelectionMode() requestExitSelectionMode()
@@ -526,21 +766,8 @@ fun ChatsTab(
} }
} }
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) { LaunchedEffect(serverConfig, activeInstanceId) {
if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
runCatching {
ApiClient.getDmConversations()
}.onSuccess { conversations ->
runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageRepository.replaceDmConversations(conversations, previewStrings)
dmConversations = ChatListReorderController.applyOrdered(
MessageRepository.loadCachedDmConversations(),
)
}
}
runCatching { PublicChatProfileCache.hydrateFromDisk() } runCatching { PublicChatProfileCache.hydrateFromDisk() }
} }
@@ -636,17 +863,203 @@ fun ChatsTab(
}, },
) )
Scaffold( val density = LocalDensity.current
topBar = { val mainChromeInsets = LocalMainChromeInsets.current
Box { val statusBarTopDp = mainChromeInsets.top
val fixedTopBarHeight = statusBarTopDp + ChatsTopBarContentHeight
val searchCollapseProgress by remember(density, tabListState) {
derivedStateOf {
val collapseRangePx = with(density) { ChatsSearchBarHeight.toPx() }.coerceAtLeast(1f)
when (tabListState.firstVisibleItemIndex) {
ChatListLayout.SEARCH_BAR_ROW ->
(tabListState.firstVisibleItemScrollOffset / collapseRangePx).coerceIn(0f, 1f)
else -> 1f
}
}
}
val collapseRangePx = with(density) { ChatsSearchBarHeight.toPx() }.coerceAtLeast(1f)
val searchCollapseSnapEnabled = isVisible && !selectionMode && selectionProgress <= 0f
val searchCollapseSnapModifier = rememberSearchBarCollapseSnap(
listState = tabListState,
collapseRangePx = collapseRangePx,
enabled = searchCollapseSnapEnabled,
)
val searchBarVisibleFraction = (1f - selectionProgress).coerceIn(0f, 1f)
val topBarSearchReveal = searchCollapseProgress * (1f - selectionProgress)
val listScrollFromTopPx by remember(density, tabListState) {
derivedStateOf {
when (tabListState.firstVisibleItemIndex) {
ChatListLayout.SEARCH_BAR_ROW -> tabListState.firstVisibleItemScrollOffset.toFloat()
else -> collapseRangePx + tabListState.firstVisibleItemScrollOffset.toFloat()
}
}
}
val topBarBlurReveal by animateFloatAsState(
targetValue = if (listScrollFromTopPx >= 1f) 1f else 0f,
animationSpec = tween(durationMillis = 200),
label = "chatsTopBarBlurReveal",
)
Box(modifier = Modifier.fillMaxSize()) {
if (suspensionState.isSuspended) {
ListItem(
headline = accountSuspendedTitle,
position = ListItemPosition.START,
groupItemCount = 1,
divider = false,
leadingContent = {
Icon(
imageVector = Icons.Rounded.Block,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { showSuspendedSupportSheet = true },
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(
top = fixedTopBarHeight + ChatsSearchBarHeight * searchBarVisibleFraction,
start = 12.dp,
end = 12.dp,
),
)
} else {
key(profileCacheRevision) {
ChatConversationsList(
listState = tabListState,
listFilter = ChatListFilter.Active,
conversations = dmConversations,
publicChatTitle = publicChatTitle,
publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = "",
statusMap = statusMap,
listMode = listMode,
selectionTransitionProgress = selectionProgress,
publicChatSelected = publicChatSelected,
selectedOtherUserIds = selectedOtherUserIds,
contextMenuState = contextMenuState,
overlayCloneReady = overlayCloneReady,
rowRevealProgress = rowRevealProgress,
listContentPadding = PaddingValues(top = fixedTopBarHeight),
showSearchBar = true,
searchBarHeight = ChatsSearchBarHeight,
searchBarVisibleFraction = searchBarVisibleFraction,
searchBarPlaceholder = searchBarHint,
onSearchBarActivate = onOpenSearch,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
listBottomInset = mainChromeInsets.bottom,
modifier = Modifier
.fillMaxSize()
.hazeSource(topChromeHazeState)
.then(searchCollapseSnapModifier),
onOpenPublic = {
when {
selectionMode && publicChatSelected -> publicChatSelected = false
selectionMode -> publicChatSelected = true
else -> navController.navigate("chats/publicChat")
}
},
onOpenConversation = { userId ->
when {
selectionMode && userId in selectedOtherUserIds -> {
selectedOtherUserIds -= userId
}
selectionMode -> selectedOtherUserIds += userId
userId != 0 -> navController.navigate(DmNav.chatRoute(userId))
}
},
onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount ->
if (suspensionState.isSuspended) return@ChatConversationsList
avatarPressMark = TimeSource.Monotonic.markNow()
contextMenuState = ChatContextMenuState(
phase = ChatContextMenuPhase.Pressing,
target = target,
otherUserId = userId,
listIndex = lazyIndex,
rowOffset = rowOffset,
rowSize = rowSize,
listItemPosition = position,
groupItemCount = groupCount,
)
},
onAvatarContextMenuPressEnd = {
avatarPressMark = null
if (contextMenuState.phase == ChatContextMenuPhase.Pressing) {
contextMenuState = ChatContextMenuState()
}
},
onAvatarContextMenuOpen = { lazyIndex, target, userId, _, rowOffset, rowSize, position, groupCount ->
if (suspensionState.isSuspended) return@ChatConversationsList
val pressMark = avatarPressMark
if (pressMark == null ||
pressMark.elapsedNow() < ChatContextMenuHoldGateMs.milliseconds
) {
return@ChatConversationsList
}
if (contextMenuState.phase != ChatContextMenuPhase.Pressing) {
return@ChatConversationsList
}
haptic(HapticFeedbackEvent.ContextMenuOpened)
chatContextMenuOverlay.overlayCloneReady = false
contextMenuState = ChatContextMenuState(
phase = ChatContextMenuPhase.Animating,
target = target,
otherUserId = userId,
listIndex = lazyIndex,
rowOffset = rowOffset,
rowSize = rowSize,
listItemPosition = position,
groupItemCount = groupCount,
)
},
onEnterSelectionMode = { _, target, userId ->
if (suspensionState.isSuspended) return@ChatConversationsList
enterSelectionModeFor(target, userId)
},
onRowPositioned = { lazyIndex, offset, size ->
if (
contextMenuState.listIndex == lazyIndex &&
contextMenuState.phase != ChatContextMenuPhase.Closed
) {
contextMenuState = contextMenuState.copy(
rowOffset = offset,
rowSize = size,
)
}
},
)
}
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(fixedTopBarHeight),
) {
ChatsTopBarHazeBackdrop(
hazeState = topChromeHazeState,
blurReveal = topBarBlurReveal,
modifier = Modifier.matchParentSize(),
)
Box(modifier = Modifier.fillMaxWidth()) {
ChatsNormalTopBar( ChatsNormalTopBar(
blurReveal = topBarBlurReveal,
titleKey = titleKey, titleKey = titleKey,
connectingTitle = connectingTitle, connectingTitle = connectingTitle,
updatingTitle = updatingTitle, updatingTitle = updatingTitle,
searchCollapseProgress = topBarSearchReveal,
searchContentDescription = searchBarHint,
onSearchClick = onOpenSearch,
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
) )
if (selectionMode || selectionProgress > 0f) { if (selectionMode || selectionProgress > 0f) {
ChatsSelectionTopBar( ChatsSelectionTopBar(
blurReveal = topBarBlurReveal,
selectedCountTitle = selectedCountTitle, selectedCountTitle = selectedCountTitle,
closeSelectionCd = closeSelectionCd, closeSelectionCd = closeSelectionCd,
bulkActions = bulkActions, bulkActions = bulkActions,
@@ -663,160 +1076,6 @@ fun ChatsTab(
) )
} }
} }
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
val searchBarReveal = 1f - selectionProgress
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { alpha = searchBarReveal }
.height((56.dp * searchBarReveal).coerceAtLeast(0.dp))
.clip(RectangleShape),
) {
if (searchBarReveal > 0f) {
SearchBar(
query = "",
onQueryChange = {},
onSearch = {},
placeholder = searchBarHint,
readOnly = true,
onReadOnlyActivate = onOpenSearch,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedElementKey = SearchBarSharedElement,
)
}
}
if (suspensionState.isSuspended) {
ListItem(
headline = accountSuspendedTitle,
position = ListItemPosition.START,
groupItemCount = 1,
divider = false,
leadingContent = {
Icon(
imageVector = Icons.Rounded.Block,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { showSuspendedSupportSheet = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
} else {
key(profileCacheRevision) {
ChatConversationsList(
listState = tabListState,
listFilter = ChatListFilter.Active,
conversations = dmConversations,
publicChatTitle = publicChatTitle,
publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
listMode = listMode,
selectionTransitionProgress = selectionProgress,
publicChatSelected = publicChatSelected,
selectedOtherUserIds = selectedOtherUserIds,
contextMenuState = contextMenuState,
overlayCloneReady = overlayCloneReady,
rowRevealProgress = rowRevealProgress,
modifier = Modifier.fillMaxSize(),
onOpenPublic = {
when {
selectionMode && publicChatSelected -> publicChatSelected = false
selectionMode -> publicChatSelected = true
else -> navController.navigate("chats/publicChat")
}
},
onOpenConversation = { userId ->
when {
selectionMode && userId in selectedOtherUserIds -> {
selectedOtherUserIds -= userId
}
selectionMode -> selectedOtherUserIds += userId
userId != 0 -> navController.navigate(DmNav.chatRoute(userId))
}
},
onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount ->
if (suspensionState.isSuspended) return@ChatConversationsList
avatarPressMark = TimeSource.Monotonic.markNow()
contextMenuState = ChatContextMenuState(
phase = ChatContextMenuPhase.Pressing,
target = target,
otherUserId = userId,
listIndex = lazyIndex,
rowOffset = rowOffset,
rowSize = rowSize,
listItemPosition = position,
groupItemCount = groupCount,
)
},
onAvatarContextMenuPressEnd = {
avatarPressMark = null
if (contextMenuState.phase == ChatContextMenuPhase.Pressing) {
contextMenuState = ChatContextMenuState()
}
},
onAvatarContextMenuOpen = { lazyIndex, target, userId, _, rowOffset, rowSize, position, groupCount ->
if (suspensionState.isSuspended) return@ChatConversationsList
val pressMark = avatarPressMark
if (pressMark == null ||
pressMark.elapsedNow() < ChatContextMenuHoldGateMs.milliseconds
) {
return@ChatConversationsList
}
if (contextMenuState.phase != ChatContextMenuPhase.Pressing) return@ChatConversationsList
haptic(HapticFeedbackEvent.ContextMenuOpened)
chatContextMenuOverlay.overlayCloneReady = false
contextMenuState = ChatContextMenuState(
phase = ChatContextMenuPhase.Animating,
target = target,
otherUserId = userId,
listIndex = lazyIndex,
rowOffset = rowOffset,
rowSize = rowSize,
listItemPosition = position,
groupItemCount = groupCount,
)
},
onEnterSelectionMode = { _, target, userId ->
if (suspensionState.isSuspended) return@ChatConversationsList
enterSelectionModeFor(target, userId)
},
onRowPositioned = { lazyIndex, offset, size ->
if (
contextMenuState.listIndex == lazyIndex &&
contextMenuState.phase != ChatContextMenuPhase.Closed
) {
contextMenuState = contextMenuState.copy(
rowOffset = offset,
rowSize = size,
)
}
},
)
}
}
} }
} }
@@ -893,7 +1152,7 @@ fun ChatsTab(
publicChatTitle = publicChatTitle, publicChatTitle = publicChatTitle,
publicChatPreviewState = publicChatPreviewState, publicChatPreviewState = publicChatPreviewState,
publicChatLink = publicChatLink, publicChatLink = publicChatLink,
defaultLastMessage = defaultLastMessage, defaultLastMessage = "",
conversations = dmConversations, conversations = dmConversations,
statusMap = statusMap, statusMap = statusMap,
listMode = listMode, listMode = listMode,
@@ -45,6 +45,7 @@ import ru.fromchat.logs_title
import ru.fromchat.settings_hub_about_sub import ru.fromchat.settings_hub_about_sub
import ru.fromchat.settings_hub_logs_sub import ru.fromchat.settings_hub_logs_sub
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.mainPagerBottomInset
val SettingsStepHorizontalPadding = 24.dp val SettingsStepHorizontalPadding = 24.dp
@@ -69,6 +70,7 @@ fun SettingsTab() {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll() .verticalScroll()
.mainPagerBottomInset()
.padding(innerPadding) .padding(innerPadding)
) { ) {
Category(Modifier.padding(top = 16.dp)) { Category(Modifier.padding(top = 16.dp)) {
@@ -298,10 +298,12 @@ fun ProfileScreen(
val latestUi by rememberUpdatedState(state) val latestUi by rememberUpdatedState(state)
val profileCacheRevision by ProfileCache.revision.collectAsState() val profileCacheRevision by ProfileCache.revision.collectAsState()
val isOwnProfileLookup = targetUserId == null && targetUsername == null val isViewingOwnProfile = ownUserId != null && (
(targetUserId == null && targetUsername == null) || targetUserId == ownUserId
)
LaunchedEffect(profileCacheRevision) { LaunchedEffect(profileCacheRevision) {
if (!isOwnProfileLookup) return@LaunchedEffect if (!isViewingOwnProfile) return@LaunchedEffect
ownUserId?.let { ProfileCache.get(it) }?.let { cached -> ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) { if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
state = latestUi.copy(profile = cached, isLoading = false, error = null) state = latestUi.copy(profile = cached, isLoading = false, error = null)
@@ -315,7 +317,7 @@ fun ProfileScreen(
handle.getStateFlow(ProfileRoutes.REFRESH_KEY, false).collect { shouldRefresh -> handle.getStateFlow(ProfileRoutes.REFRESH_KEY, false).collect { shouldRefresh ->
if (!shouldRefresh) return@collect if (!shouldRefresh) return@collect
handle[ProfileRoutes.REFRESH_KEY] = false handle[ProfileRoutes.REFRESH_KEY] = false
if (targetUserId != null || targetUsername != null) return@collect if (!isViewingOwnProfile) return@collect
try { try {
val refreshed = ApiClient.getOwnProfile() val refreshed = ApiClient.getOwnProfile()
ProfileCache.put(refreshed) ProfileCache.put(refreshed)
@@ -350,7 +352,7 @@ fun ProfileScreen(
return try { return try {
val profile = when { val profile = when {
targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() isViewingOwnProfile -> ApiClient.getOwnProfile()
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
else -> ApiClient.getProfileById(targetUserId!!) else -> ApiClient.getProfileById(targetUserId!!)
} }
@@ -380,7 +382,7 @@ fun ProfileScreen(
) )
ProfileCache.put(profile) ProfileCache.put(profile)
if (targetUserId == null && targetUsername == null) { if (isViewingOwnProfile) {
ApiClient.applyOwnProfile(profile) ApiClient.applyOwnProfile(profile)
} }
state = latestUi.copy(profile = profile, isLoading = false, error = null) state = latestUi.copy(profile = profile, isLoading = false, error = null)