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">
<artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared.jar">
<element id="module-output" name="FromChat.utils.shared.androidMain" />
</root>
<root id="archive" name="shared.jar" />
</artifact>
</component>
@@ -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)
}
@@ -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
@@ -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
)
@@ -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 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<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,
)
// 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<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
val peerId = peerUserId ?: return@LaunchedEffect
if (peerId <= 0) return@LaunchedEffect
if (panel.getState().messages.isEmpty()) {
panel.loadMessages()
}
panel.loadMessages()
}
LaunchedEffect(activeInstanceId, peerUserId) {
@@ -50,6 +50,7 @@ fun ContactsTab() {
Box(
modifier = Modifier
.fillMaxSize()
.mainPagerBottomInset()
.padding(innerPadding),
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.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),
)
}
}
@@ -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,
@@ -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<List<CachedConversation>>(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 ->
@@ -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<Set<Int>>(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,
@@ -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)) {
@@ -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)