diff --git a/.idea/artifacts/shared.xml b/.idea/artifacts/shared.xml
index df2c7e6..59ff3f6 100644
--- a/.idea/artifacts/shared.xml
+++ b/.idea/artifacts/shared.xml
@@ -1,8 +1,6 @@
$PROJECT_DIR$/utils/shared/build/libs
-
-
-
+
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
index 24ba949..c44935f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
@@ -3,10 +3,16 @@ package ru.fromchat.api
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.FlowPreview
import kotlinx.serialization.json.JsonElement
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
+import ru.fromchat.api.local.db.store.ConnectionStateStore
+import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
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
* message for list previews (without opening each chat first).
*/
+@OptIn(FlowPreview::class)
object ChatListSync {
+ private const val CONNECTED_SYNC_DEBOUNCE_MS = 300L
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var started = false
@@ -31,6 +40,14 @@ object ChatListSync {
scope.launch { syncFromNetwork() }
}
+ scope.launch {
+ ConnectionStateStore.status
+ .filter { it == ConnectionStatus.CONNECTED }
+ .distinctUntilChanged()
+ .debounce(CONNECTED_SYNC_DEBOUNCE_MS)
+ .collect { syncFromNetwork() }
+ }
+
WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
index 3df9faf..66a0f4f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
@@ -11,6 +11,7 @@ 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.messages.dm.DmConversationUser
import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus
@@ -165,7 +166,7 @@ object ProfileCache {
if (!hasIdentity) remove(userId)
}
- fun mergeFromDmUser(user: User) {
+ fun mergeFromDmUser(user: DmConversationUser) {
if (user.id <= 0) return
val incomingUsername = user.username.trim()
@@ -188,7 +189,7 @@ object ProfileCache {
} else {
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,
verified = user.verified ?: existing.verified,
verificationStatus = user.verificationStatus ?: existing.verificationStatus,
@@ -209,9 +210,9 @@ object ProfileCache {
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
- online = user.online,
+ online = user.online ?: existing?.online ?: false,
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,
verificationStatus = user.verificationStatus ?: existing?.verificationStatus,
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) {
val uid = message.user_id
if (uid <= 0) return
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt
index b5c2973..f500b5c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversation.kt
@@ -1,11 +1,10 @@
package ru.fromchat.api.schema.messages.dm
import kotlinx.serialization.Serializable
-import ru.fromchat.api.schema.user.User
@Serializable
data class DmConversation(
- val user: User,
+ val user: DmConversationUser,
val lastMessage: DmEnvelope,
val unreadCount: Int
)
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationUser.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationUser.kt
new file mode 100644
index 0000000..7d02df6
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmConversationUser.kt
@@ -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,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
index 383391b..b1d0163 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
@@ -66,6 +66,7 @@ class DmPanel(
private var otherDisplayName: String = ""
private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex()
+ private val loadMessagesMutex = Mutex()
private var messagesLoaded = false
private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean)
@@ -219,78 +220,82 @@ class DmPanel(
}
override suspend fun loadMessages() {
- if (messagesLoaded) return
- messagesLoaded = true
+ loadMessagesMutex.withLock {
+ 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
- // when the chat screen re-entered composition (e.g. pop back from profile).
- val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
- 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>()
- val parsedReplyIds = mutableMapOf()
- 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,
- )
+ // 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).
+ val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
+ if (cached.isNotEmpty()) {
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)
- } 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)
+ addMessages(cached)
+ setLoading(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>()
+ val parsedReplyIds = mutableMapOf()
+ 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)
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt
index 7651005..4d8d44b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt
@@ -54,9 +54,7 @@ fun DmScreen(
if (activeInstanceId.isBlank()) return@LaunchedEffect
val peerId = peerUserId ?: return@LaunchedEffect
if (peerId <= 0) return@LaunchedEffect
- if (panel.getState().messages.isEmpty()) {
- panel.loadMessages()
- }
+ panel.loadMessages()
}
LaunchedEffect(activeInstanceId, peerUserId) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt
index 3325429..e2bc779 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ContactsTab.kt
@@ -50,6 +50,7 @@ fun ContactsTab() {
Box(
modifier = Modifier
.fillMaxSize()
+ .mainPagerBottomInset()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainChromeInsets.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainChromeInsets.kt
new file mode 100644
index 0000000..d85eba6
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainChromeInsets.kt
@@ -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)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
index f6eff4a..dce726b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
@@ -4,16 +4,18 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.ime
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.safeDrawing
-import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -28,13 +30,18 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
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.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState
@@ -44,6 +51,7 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
+import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -53,14 +61,13 @@ import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController
-import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
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.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen
-import com.pr0gramm3r101.utils.exclude
private const val PAGE_CHATS = 0
private const val PAGE_CONTACTS = 1
@@ -73,7 +80,7 @@ private const val PAGE_COUNT = 4
fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
- snackbarHostState: SnackbarHostState? = null
+ snackbarHostState: SnackbarHostState? = null,
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
@@ -82,15 +89,26 @@ fun MainScreen(
pageCount = { PAGE_COUNT },
)
val scope = rememberCoroutineScope()
+ val density = LocalDensity.current
val navBarHazeState = rememberHazeState(blurEnabled = true)
+ val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val contextMenuHazeState = rememberHazeState(blurEnabled = true)
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
- // Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS
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()) {
Box(
modifier = Modifier
@@ -98,102 +116,114 @@ fun MainScreen(
.hazeSource(contextMenuHazeState),
) {
Scaffold(
- snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
- bottomBar = {
- Column(
- modifier = Modifier
- .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) }
- )
- }
- }
+ snackbarHost = {
+ FromChatSnackbarHost(
+ hostState = effectiveSnackbarHostState,
+ modifier = Modifier.padding(bottom = mainChromeInsets.bottom),
+ )
},
- contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
- modifier = Modifier.imePadding(),
- ) { innerPadding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .hazeSource(navBarHazeState),
- ) {
+ containerColor = Color.Transparent,
+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding(),
+ ) {
+ CompositionLocalProvider(LocalMainChromeInsets provides mainChromeInsets) {
Box(
modifier = Modifier
.fillMaxSize()
- .padding(innerPadding),
+ .hazeSource(navBarHazeState),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
- when (page) {
- PAGE_CHATS -> ChatsTab(
- isVisible = isChatsPage,
- onOpenSearch = {
- navController.navigate("search/conversations")
- },
- chatContextMenuOverlay = chatContextMenuOverlay,
- sharedTransitionScope = sharedTransitionScope,
- 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) }
- }
+ when (page) {
+ PAGE_CHATS -> ChatsTab(
+ isVisible = isChatsPage,
+ onOpenSearch = {
+ navController.navigate("search/conversations")
+ },
+ chatContextMenuOverlay = chatContextMenuOverlay,
+ sharedTransitionScope = sharedTransitionScope,
+ animatedVisibilityScope = animatedVisibilityScope,
)
+ 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,
modifier = Modifier
.fillMaxSize()
- .zIndex(1f),
+ .zIndex(2f),
)
}
@@ -214,7 +244,7 @@ fun MainScreen(
screenHeightPx = constraints.maxHeight,
modifier = Modifier
.fillMaxSize()
- .zIndex(2f),
+ .zIndex(3f),
)
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
index d54a810..545d416 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
@@ -1,6 +1,8 @@
package ru.fromchat.ui.main.chats
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.Spring
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.width
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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.positionInRoot
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
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.ListItem
import com.pr0gramm3r101.components.ListItemPosition
@@ -92,8 +99,9 @@ import ru.fromchat.ui.profile.displayNameForUi
import ru.fromchat.ui.profile.peerIsDeleted
internal object ChatListLayout {
- private const val CATEGORY_TOP_SPACER = 0
- const val PUBLIC_CHAT_ROW = CATEGORY_TOP_SPACER + 1
+ const val SEARCH_BAR_ROW = 0
+ 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
@@ -113,8 +121,8 @@ internal object SearchListIndices {
private val ChatListCategoryMargin = PaddingValues(
start = 16.dp,
end = 16.dp,
- top = 16.dp,
- bottom = 20.dp,
+ top = 8.dp,
+ bottom = 12.dp,
)
@Composable
@@ -154,6 +162,15 @@ internal fun ChatConversationsList(
contextMenuState: ChatContextMenuState,
overlayCloneReady: Boolean,
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,
onOpenPublic: () -> Unit,
onOpenConversation: (Int) -> Unit,
@@ -188,8 +205,45 @@ internal fun ChatConversationsList(
state = listState,
modifier = modifier,
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) {
Category(
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,
) {
val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage
+ val pendingIndicator = publicChatPreviewState?.pendingIndicator
+ ?: ChatListPreviewPendingIndicator.None
+ val showPreview = preview.isNotBlank() || pendingIndicator != ChatListPreviewPendingIndicator.None
ListItem(
headline = publicChatTitle.orEmpty(),
- supportingSlot = if (publicChatTitle != null) {
+ supportingSlot = if (publicChatTitle != null && showPreview) {
{
ChatListPreviewSupportingText(
preview = preview,
- pendingIndicator = publicChatPreviewState?.pendingIndicator
- ?: ChatListPreviewPendingIndicator.None,
+ pendingIndicator = pendingIndicator,
uploadProgress = publicChatPreviewState?.uploadProgress,
)
}
@@ -884,21 +945,28 @@ internal fun DmConversationRowContent(
val status = statusMap[conversation.otherUserId]
val typingUsers = status?.typingUsernames.orEmpty()
val isTyping = typingUsers.isNotEmpty()
+ val showPreview = isTyping ||
+ preview.isNotBlank() ||
+ conversation.lastMessagePendingIndicator != ChatListPreviewPendingIndicator.None
val isOnline = status?.online ?: (cached?.online == true)
val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow
ListItem(
headline = peerTitle,
headlineSlot = { ChatListHeadlineWithBadge(peerTitle, conversation.otherUserId) },
- supportingSlot = {
- if (isTyping) {
- TypingIndicator(typingUsers = typingUsers)
- } else {
- ChatListPreviewSupportingText(
- preview = preview,
- pendingIndicator = conversation.lastMessagePendingIndicator,
- uploadProgress = conversation.lastMessageUploadProgress,
- )
+ supportingSlot = if (showPreview) {
+ {
+ if (isTyping) {
+ TypingIndicator(typingUsers = typingUsers)
+ } else {
+ ChatListPreviewSupportingText(
+ preview = preview,
+ pendingIndicator = conversation.lastMessagePendingIndicator,
+ uploadProgress = conversation.lastMessageUploadProgress,
+ )
+ }
}
+ } else {
+ null
},
containerColor = Color.Transparent,
position = listItemPosition,
@@ -990,6 +1058,10 @@ internal fun ChatListPreviewSupportingText(
val sendingCd = stringResource(Res.string.cd_chat_preview_sending)
val uploadingCd = stringResource(Res.string.cd_chat_preview_uploading)
+ if (preview.isBlank() && pendingIndicator == ChatListPreviewPendingIndicator.None) {
+ return
+ }
+
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
index 12da162..20bf686 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
@@ -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.visibleUsername
import ru.fromchat.api.schema.user.User
-import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji
@@ -98,7 +97,6 @@ fun ChatsSearchScreen(
imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji),
attachmentOnly = stringResource(Res.string.chat_preview_attachment),
)
- val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
val searchHint = stringResource(Res.string.search_hint)
val searchBarHint = stringResource(Res.string.search_title)
var dmConversations by remember { mutableStateOf>(emptyList()) }
@@ -157,7 +155,7 @@ fun ChatsSearchScreen(
try {
val users = ApiClient.searchUsers(querySnapshot)
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
- users.forEach { ProfileCache.mergeFromDmUser(it) }
+ users.forEach { ProfileCache.mergeFromUser(it) }
remoteUsers = users
lastCompletedSearchQuery = querySnapshot
}
@@ -313,7 +311,7 @@ fun ChatsSearchScreen(
remoteUsers = remoteUsers.filter { user ->
filteredDmConversations.none { it.otherUserId == user.id }
},
- defaultLastMessage = defaultLastMessage,
+ defaultLastMessage = "",
statusMap = statusMap,
modifier = Modifier.fillMaxSize(),
onOpenConversation = { userId ->
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
index 736dca0..7cc0a1c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
@@ -5,7 +5,9 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animate
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -42,10 +45,25 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
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.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.LaunchedEffect
import androidx.compose.runtime.SideEffect
@@ -62,7 +80,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.graphicsLayer
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.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
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.chat_delete_confirm_body
import ru.fromchat.chat_delete_confirm_title
-import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji
@@ -119,13 +139,12 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.BrandTitle
import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.PredictiveBackHandler
-import ru.fromchat.ui.components.SearchBar
-import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.components.Text
import ru.fromchat.utils.NetworkConnectivity
@@ -135,17 +154,225 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
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)
@Composable
private fun ChatsNormalTopBar(
+ blurReveal: Float,
titleKey: String,
connectingTitle: String,
updatingTitle: String,
+ searchCollapseProgress: Float,
+ searchContentDescription: String,
+ onSearchClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
+ windowInsets = WindowInsets.statusBars,
+ colors = chatsTopAppBarColors(blurReveal),
title = {
Row(
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)
@Composable
private fun ChatsSelectionTopBar(
+ blurReveal: Float,
selectedCountTitle: String,
closeSelectionCd: String,
bulkActions: ChatsBulkActions,
@@ -224,6 +465,8 @@ private fun ChatsSelectionTopBar(
TopAppBar(
modifier = modifier,
+ windowInsets = WindowInsets.statusBars,
+ colors = chatsTopAppBarColors(blurReveal),
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
@@ -275,7 +518,7 @@ private fun ChatsSelectionTopBar(
)
}
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatsTab(
isVisible: Boolean = true,
@@ -330,12 +573,12 @@ fun ChatsTab(
val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk
val searchBarHint = stringResource(Res.string.search_title)
val tabListState = rememberLazyListState()
+ val topChromeHazeState = rememberHazeState(blurEnabled = isVisible)
val statusMap by UserStatusStore.status.collectAsState()
var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope()
val suspensionState by ApiClient.suspensionState.collectAsState()
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
- val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) {
MessageCacheStore.listPreviewStrings = previewStrings
@@ -369,7 +612,6 @@ fun ChatsTab(
fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) {
haptic(HapticFeedbackEvent.SelectionModeEntered)
- scope.launch { selectionTransitionProgress.snapTo(0f) }
listMode = ChatsListMode.Selecting
publicChatSelected = false
selectedOtherUserIds = emptySet()
@@ -377,6 +619,10 @@ fun ChatsTab(
ChatContextMenuTarget.Public -> publicChatSelected = true
ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) }
}
+ scope.launch {
+ selectionTransitionProgress.snapTo(0f)
+ selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
+ }
}
fun exitSelectionMode() {
@@ -435,12 +681,6 @@ fun ChatsTab(
}
}
- LaunchedEffect(listMode) {
- if (listMode == ChatsListMode.Selecting) {
- selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
- }
- }
-
LaunchedEffect(publicChatSelected, selectedOtherUserIds) {
if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) {
requestExitSelectionMode()
@@ -526,21 +766,8 @@ fun ChatsTab(
}
}
- LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
- if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
-
- runCatching {
- ApiClient.getDmConversations()
- }.onSuccess { conversations ->
- runCatching {
- conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
- MessageRepository.replaceDmConversations(conversations, previewStrings)
- dmConversations = ChatListReorderController.applyOrdered(
- MessageRepository.loadCachedDmConversations(),
- )
- }
- }
-
+ LaunchedEffect(serverConfig, activeInstanceId) {
+ if (activeInstanceId.isBlank()) return@LaunchedEffect
runCatching { PublicChatProfileCache.hydrateFromDisk() }
}
@@ -636,17 +863,203 @@ fun ChatsTab(
},
)
- Scaffold(
- topBar = {
- Box {
+ val density = LocalDensity.current
+ val mainChromeInsets = LocalMainChromeInsets.current
+ 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(
+ blurReveal = topBarBlurReveal,
titleKey = titleKey,
connectingTitle = connectingTitle,
updatingTitle = updatingTitle,
+ searchCollapseProgress = topBarSearchReveal,
+ searchContentDescription = searchBarHint,
+ onSearchClick = onOpenSearch,
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
)
if (selectionMode || selectionProgress > 0f) {
ChatsSelectionTopBar(
+ blurReveal = topBarBlurReveal,
selectedCountTitle = selectedCountTitle,
closeSelectionCd = closeSelectionCd,
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,
publicChatPreviewState = publicChatPreviewState,
publicChatLink = publicChatLink,
- defaultLastMessage = defaultLastMessage,
+ defaultLastMessage = "",
conversations = dmConversations,
statusMap = statusMap,
listMode = listMode,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
index ed99735..9294143 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsTab.kt
@@ -45,6 +45,7 @@ import ru.fromchat.logs_title
import ru.fromchat.settings_hub_about_sub
import ru.fromchat.settings_hub_logs_sub
import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.main.mainPagerBottomInset
val SettingsStepHorizontalPadding = 24.dp
@@ -69,6 +70,7 @@ fun SettingsTab() {
Modifier
.fillMaxSize()
.verticalScroll()
+ .mainPagerBottomInset()
.padding(innerPadding)
) {
Category(Modifier.padding(top = 16.dp)) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
index 6b5067c..7f6248d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
@@ -298,10 +298,12 @@ fun ProfileScreen(
val latestUi by rememberUpdatedState(state)
val profileCacheRevision by ProfileCache.revision.collectAsState()
- val isOwnProfileLookup = targetUserId == null && targetUsername == null
+ val isViewingOwnProfile = ownUserId != null && (
+ (targetUserId == null && targetUsername == null) || targetUserId == ownUserId
+ )
LaunchedEffect(profileCacheRevision) {
- if (!isOwnProfileLookup) return@LaunchedEffect
+ if (!isViewingOwnProfile) return@LaunchedEffect
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
state = latestUi.copy(profile = cached, isLoading = false, error = null)
@@ -315,7 +317,7 @@ fun ProfileScreen(
handle.getStateFlow(ProfileRoutes.REFRESH_KEY, false).collect { shouldRefresh ->
if (!shouldRefresh) return@collect
handle[ProfileRoutes.REFRESH_KEY] = false
- if (targetUserId != null || targetUsername != null) return@collect
+ if (!isViewingOwnProfile) return@collect
try {
val refreshed = ApiClient.getOwnProfile()
ProfileCache.put(refreshed)
@@ -350,7 +352,7 @@ fun ProfileScreen(
return try {
val profile = when {
- targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile()
+ isViewingOwnProfile -> ApiClient.getOwnProfile()
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
else -> ApiClient.getProfileById(targetUserId!!)
}
@@ -380,7 +382,7 @@ fun ProfileScreen(
)
ProfileCache.put(profile)
- if (targetUserId == null && targetUsername == null) {
+ if (isViewingOwnProfile) {
ApiClient.applyOwnProfile(profile)
}
state = latestUi.copy(profile = profile, isLoading = false, error = null)