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