mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-2813 Chats | Enhancement | Infinite paging, first iteration (#2349)
This commit is contained in:
parent
e0f34a3177
commit
bdfc2734e0
9 changed files with 451 additions and 174 deletions
|
@ -624,8 +624,8 @@ sealed class Command {
|
|||
|
||||
data class GetMessages(
|
||||
val chat: Id,
|
||||
val beforeOrderId: Id?,
|
||||
val afterOrderId: Id?,
|
||||
val beforeOrderId: Id? = null,
|
||||
val afterOrderId: Id? = null,
|
||||
val limit: Int
|
||||
) : ChatCommand() {
|
||||
data class Response(
|
||||
|
|
|
@ -18,7 +18,7 @@ sealed class Chat {
|
|||
val modifiedAt: Long,
|
||||
val content: Content?,
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
val reactions: Map<String, List<String>>,
|
||||
val reactions: Map<String, List<String>> = emptyMap(),
|
||||
val replyToMessageId: Id? = null,
|
||||
val read: Boolean = false,
|
||||
val mentionRead: Boolean = false
|
||||
|
|
|
@ -8,6 +8,9 @@ import com.anytypeio.anytype.domain.base.ResultInteractor
|
|||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* returns message ID and payload commands.
|
||||
*/
|
||||
class AddChatMessage @Inject constructor(
|
||||
private val repo: BlockRepository,
|
||||
dispatchers: AppCoroutineDispatchers
|
||||
|
|
|
@ -31,12 +31,16 @@ class ChatContainer @Inject constructor(
|
|||
private val channel: ChatEventChannel,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val lastMessages = LinkedHashMap<Id, ChatMessageMeta>()
|
||||
|
||||
private val payloads = MutableSharedFlow<List<Event.Command.Chats>>()
|
||||
private val commands = MutableSharedFlow<Transformation.Commands>(replay = 0)
|
||||
|
||||
private val attachments = MutableStateFlow<Set<Id>>(emptySet())
|
||||
private val replies = MutableStateFlow<Set<Id>>(emptySet())
|
||||
|
||||
// TODO Naive implementation. Add caching logic
|
||||
fun fetchAttachments(space: Space) : Flow<Map<Id, ObjectWrapper.Basic>> {
|
||||
return attachments
|
||||
.map { ids ->
|
||||
|
@ -65,7 +69,7 @@ class ChatContainer @Inject constructor(
|
|||
.map { wrappers -> wrappers.associate { it.id to it } }
|
||||
}
|
||||
|
||||
@Deprecated("Naive implementation. Add caching logic")
|
||||
// TODO Naive implementation. Add caching logic
|
||||
fun fetchReplies(chat: Id) : Flow<Map<Id, Chat.Message>> {
|
||||
return replies
|
||||
.map { ids ->
|
||||
|
@ -84,9 +88,10 @@ class ChatContainer @Inject constructor(
|
|||
.map { messages -> messages.associate { it.id to it } }
|
||||
}
|
||||
|
||||
fun watchWhileTrackingAttachments(chat: Id): Flow<List<Chat.Message>> {
|
||||
fun watchWhileTrackingAttachments(chat: Id): Flow<ChatStreamState> {
|
||||
return watch(chat)
|
||||
.onEach { messages ->
|
||||
.onEach { state ->
|
||||
val messages = state.messages
|
||||
val repliesIds = mutableSetOf<Id>()
|
||||
val attachmentsIds = mutableSetOf<Id>()
|
||||
messages.forEach { msg ->
|
||||
|
@ -100,14 +105,15 @@ class ChatContainer @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun watch(chat: Id): Flow<List<Chat.Message>> = flow {
|
||||
|
||||
fun watch(chat: Id): Flow<ChatStreamState> = flow {
|
||||
val initial = repo.subscribeLastChatMessages(
|
||||
command = Command.ChatCommand.SubscribeLastMessages(
|
||||
chat = chat,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
)
|
||||
).also { result ->
|
||||
cacheLastMessages(result.messages)
|
||||
}
|
||||
|
||||
val inputs: Flow<Transformation> = merge(
|
||||
channel.observe(chat).map { Transformation.Events.Payload(it) },
|
||||
|
@ -116,62 +122,98 @@ class ChatContainer @Inject constructor(
|
|||
)
|
||||
|
||||
emitAll(
|
||||
inputs.scan(initial = initial.messages) { state, transform ->
|
||||
when(transform) {
|
||||
Transformation.Commands.LoadBefore -> {
|
||||
loadThePreviousPage(state, chat)
|
||||
inputs.scan(initial = ChatStreamState(initial.messages)) { state, transform ->
|
||||
when (transform) {
|
||||
Transformation.Commands.LoadPrevious -> {
|
||||
ChatStreamState(
|
||||
messages = loadThePreviousPage(state.messages, chat),
|
||||
intent = Intent.None
|
||||
)
|
||||
}
|
||||
Transformation.Commands.LoadAfter -> {
|
||||
loadTheNextPage(state, chat)
|
||||
Transformation.Commands.LoadNext -> {
|
||||
ChatStreamState(
|
||||
messages = loadTheNextPage(state.messages, chat),
|
||||
intent = Intent.None
|
||||
)
|
||||
}
|
||||
is Transformation.Commands.LoadTo -> {
|
||||
loadToMessage(chat, transform)
|
||||
is Transformation.Commands.LoadAround -> {
|
||||
val messages = try {
|
||||
loadToMessage(chat, transform)
|
||||
} catch (e: Exception) {
|
||||
logger.logException(e, "DROID-2966 Error while loading reply context")
|
||||
state.messages
|
||||
}
|
||||
ChatStreamState(
|
||||
messages = messages,
|
||||
intent = Intent.ScrollToMessage(transform.message)
|
||||
)
|
||||
}
|
||||
is Transformation.Commands.LoadEnd -> {
|
||||
val messages = try {
|
||||
loadToEnd(chat)
|
||||
} catch (e: Exception) {
|
||||
logger.logException(e, "DROID-2966 Error while scrolling to bottom")
|
||||
state.messages
|
||||
}
|
||||
ChatStreamState(
|
||||
messages = messages,
|
||||
intent = Intent.ScrollToBottom
|
||||
)
|
||||
}
|
||||
is Transformation.Events.Payload -> {
|
||||
state.reduce(transform.events)
|
||||
ChatStreamState(
|
||||
messages = state.messages.reduce(transform.events),
|
||||
intent = Intent.None
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
)
|
||||
}.catch { e ->
|
||||
emit(value = emptyList()).also {
|
||||
logger.logException(e, "Exception occurred in the chat container: $chat")
|
||||
emit(
|
||||
value = ChatStreamState(emptyList())
|
||||
).also {
|
||||
logger.logException(e, "DROID-2966 Exception occurred in the chat container: $chat")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
private suspend fun loadToMessage(
|
||||
chat: Id,
|
||||
transform: Transformation.Commands.LoadTo
|
||||
transform: Transformation.Commands.LoadAround
|
||||
): List<Chat.Message> {
|
||||
|
||||
val replyMessage = repo.getChatMessagesByIds(
|
||||
Command.ChatCommand.GetMessagesByIds(
|
||||
chat = chat,
|
||||
messages = listOf(transform.message)
|
||||
)
|
||||
)
|
||||
).firstOrNull()
|
||||
|
||||
val loadedMessagesBefore = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = transform.message,
|
||||
afterOrderId = null,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
).messages
|
||||
if (replyMessage != null) {
|
||||
val loadedMessagesBefore = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = replyMessage.order,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
).messages
|
||||
|
||||
val loadedMessagesAfter = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = null,
|
||||
afterOrderId = transform.message,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
).messages
|
||||
val loadedMessagesAfter = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
afterOrderId = replyMessage.order,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
).messages
|
||||
|
||||
return buildList {
|
||||
addAll(loadedMessagesBefore)
|
||||
addAll(replyMessage)
|
||||
addAll(loadedMessagesAfter)
|
||||
return buildList {
|
||||
addAll(loadedMessagesBefore)
|
||||
add(replyMessage)
|
||||
addAll(loadedMessagesAfter)
|
||||
}
|
||||
} else {
|
||||
throw IllegalStateException("DROID-2966 Could not fetch replyMessage")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -184,7 +226,6 @@ class ChatContainer @Inject constructor(
|
|||
val next = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = null,
|
||||
afterOrderId = last.order,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
|
@ -192,12 +233,12 @@ class ChatContainer @Inject constructor(
|
|||
state + next.messages
|
||||
} else {
|
||||
state.also {
|
||||
logger.logWarning("The last message not found in chat")
|
||||
logger.logWarning("DROID-2966 The last message not found in chat")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state.also {
|
||||
logger.logException(e, "Error while loading previous page in chat $chat")
|
||||
logger.logException(e, "DROID-2966 Error while loading previous page in chat $chat")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -207,26 +248,37 @@ class ChatContainer @Inject constructor(
|
|||
): List<Chat.Message> = try {
|
||||
val first = state.firstOrNull()
|
||||
if (first != null) {
|
||||
val next = repo.getChatMessages(
|
||||
val previous = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = first.order,
|
||||
afterOrderId = null,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
)
|
||||
next.messages + state
|
||||
previous.messages + state
|
||||
} else {
|
||||
state.also {
|
||||
logger.logWarning("The first message not found in chat")
|
||||
logger.logWarning("DROID-2966 The first message not found in chat")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state.also {
|
||||
logger.logException(e, "Error while loading next page in chat: $chat")
|
||||
logger.logException(e, "DROID-2966 Error while loading next page in chat: $chat")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
private suspend fun loadToEnd(chat: Id): List<Chat.Message> {
|
||||
return repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = null,
|
||||
afterOrderId = null,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
).messages
|
||||
}
|
||||
|
||||
suspend fun onPayload(events: List<Event.Command.Chats>) {
|
||||
payloads.emit(events)
|
||||
}
|
||||
|
@ -236,7 +288,7 @@ class ChatContainer @Inject constructor(
|
|||
events.forEach { event ->
|
||||
when (event) {
|
||||
is Event.Command.Chats.Add -> {
|
||||
if (result.none { it.id == event.message.id }) {
|
||||
if (!result.isInCurrentWindow(event.message.id)) {
|
||||
val insertIndex = result.indexOfFirst { it.order > event.order }
|
||||
if (insertIndex >= 0) {
|
||||
result.add(insertIndex, event.message)
|
||||
|
@ -244,55 +296,97 @@ class ChatContainer @Inject constructor(
|
|||
result.add(event.message)
|
||||
}
|
||||
}
|
||||
// Tracking the last message in the chat tail
|
||||
cacheLastMessage(event.message)
|
||||
}
|
||||
is Event.Command.Chats.Delete -> {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
if (index >= 0) result.removeAt(index)
|
||||
}
|
||||
|
||||
is Event.Command.Chats.Update -> {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
if (index >= 0 && result[index] != event.message) {
|
||||
if (result.isInCurrentWindow(event.id)) {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
result[index] = event.message
|
||||
}
|
||||
// Tracking the last message in the chat tail
|
||||
cacheLastMessage(event.message)
|
||||
}
|
||||
|
||||
is Event.Command.Chats.Delete -> {
|
||||
if (result.isInCurrentWindow(event.id)) {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
result.removeAt(index)
|
||||
}
|
||||
// Tracking the last message in the chat tail
|
||||
lastMessages.remove(event.id)
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateReactions -> {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
if (index >= 0 && result[index].reactions != event.reactions) {
|
||||
result[index] = result[index].copy(reactions = event.reactions)
|
||||
if (result.isInCurrentWindow(event.id)) {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
if (result[index].reactions != event.reactions) {
|
||||
result[index] = result[index].copy(reactions = event.reactions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateMentionReadStatus -> {
|
||||
event.messages.forEach { id ->
|
||||
val idsInWindow = event.messages.filter { result.isInCurrentWindow(it) }
|
||||
idsInWindow.forEach { id ->
|
||||
val index = result.indexOfFirst { it.id == id }
|
||||
if (index >= 0 && result[index].mentionRead != event.isRead) {
|
||||
if (result[index].mentionRead != event.isRead) {
|
||||
result[index] = result[index].copy(mentionRead = event.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateMessageReadStatus -> {
|
||||
event.messages.forEach { id ->
|
||||
val idsInWindow = event.messages.filter { result.isInCurrentWindow(it) }
|
||||
idsInWindow.forEach { id ->
|
||||
val index = result.indexOfFirst { it.id == id }
|
||||
if (index >= 0 && result[index].read != event.isRead) {
|
||||
if (result[index].read != event.isRead) {
|
||||
result[index] = result[index].copy(read = event.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateState -> {
|
||||
// TODO handle event
|
||||
// TODO handle later
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun onLoadNextPage() {
|
||||
commands.emit(Transformation.Commands.LoadBefore)
|
||||
suspend fun onLoadPrevious() {
|
||||
commands.emit(Transformation.Commands.LoadPrevious)
|
||||
}
|
||||
|
||||
suspend fun onLoadPreviousPage() {
|
||||
commands.emit(Transformation.Commands.LoadAfter)
|
||||
suspend fun onLoadNext() {
|
||||
commands.emit(Transformation.Commands.LoadNext)
|
||||
}
|
||||
|
||||
suspend fun onLoadToReply(replyMessage: Id) {
|
||||
logger.logInfo("DROID-2966 emitting onLoadToReply")
|
||||
commands.emit(Transformation.Commands.LoadAround(message = replyMessage))
|
||||
}
|
||||
|
||||
suspend fun onLoadChatTail() {
|
||||
logger.logInfo("DROID-2966 emitting onLoadEnd")
|
||||
commands.emit(Transformation.Commands.LoadEnd)
|
||||
}
|
||||
|
||||
private fun cacheLastMessages(messages: List<Chat.Message>) {
|
||||
messages.sortedByDescending { it.order } // Newest first
|
||||
.take(LAST_MESSAGES_MAX_SIZE)
|
||||
.forEach { cacheLastMessage(it) }
|
||||
}
|
||||
|
||||
private fun cacheLastMessage(message: Chat.Message) {
|
||||
lastMessages[message.id] = ChatMessageMeta(message.id, message.order)
|
||||
// Ensure insertion order is preserved while trimming old entries
|
||||
if (lastMessages.size > LAST_MESSAGES_MAX_SIZE) {
|
||||
val oldestEntry = lastMessages.entries.first()
|
||||
lastMessages.remove(oldestEntry.key)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Transformation {
|
||||
|
@ -304,22 +398,47 @@ class ChatContainer @Inject constructor(
|
|||
* Loading next — older — messages in history.
|
||||
* Loading the previous page if it exists.
|
||||
*/
|
||||
data object LoadBefore : Commands()
|
||||
data object LoadPrevious : Commands()
|
||||
|
||||
/**
|
||||
* Loading next — more recent — messages in history.
|
||||
* Loading the next page if it exists.
|
||||
*/
|
||||
data object LoadAfter : Commands()
|
||||
data object LoadNext : Commands()
|
||||
|
||||
/**
|
||||
* Loading message before and current given (reply) message.
|
||||
*/
|
||||
data class LoadTo(val message: Id) : Commands()
|
||||
data class LoadAround(val message: Id) : Commands()
|
||||
|
||||
/**
|
||||
* Scroll-to-bottom behavior.
|
||||
*/
|
||||
data object LoadEnd: Commands()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_CHAT_PAGING_SIZE = 100
|
||||
const val DEFAULT_CHAT_PAGING_SIZE = 10
|
||||
private const val MAX_CHAT_CACHE_SIZE = 1000
|
||||
private const val LAST_MESSAGES_MAX_SIZE = 10
|
||||
}
|
||||
|
||||
data class ChatMessageMeta(val id: Id, val order: String)
|
||||
|
||||
data class ChatStreamState(
|
||||
val messages: List<Chat.Message>,
|
||||
val intent: Intent = Intent.None
|
||||
)
|
||||
|
||||
sealed class Intent {
|
||||
data class ScrollToMessage(val id: Id) : Intent()
|
||||
data class Highlight(val id: Id) : Intent()
|
||||
data object ScrollToBottom : Intent()
|
||||
data object None : Intent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Chat.Message>.isInCurrentWindow(id: Id): Boolean {
|
||||
return this.any { it.id == id }
|
||||
}
|
|
@ -105,7 +105,7 @@ class ChatContainerTest {
|
|||
val first = awaitItem()
|
||||
assertEquals(
|
||||
expected = emptyList(),
|
||||
actual = first
|
||||
actual = first.messages
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val second = awaitItem()
|
||||
|
@ -113,7 +113,7 @@ class ChatContainerTest {
|
|||
expected = listOf(
|
||||
msg
|
||||
),
|
||||
actual = second
|
||||
actual = second.messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -169,13 +169,13 @@ class ChatContainerTest {
|
|||
expected = listOf(
|
||||
initialMsg
|
||||
),
|
||||
actual = first
|
||||
actual = first.messages
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val second = awaitItem()
|
||||
assertEquals(
|
||||
expected = emptyList(),
|
||||
actual = second
|
||||
actual = second.messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -238,7 +238,7 @@ class ChatContainerTest {
|
|||
expected = listOf(
|
||||
initialMsg
|
||||
),
|
||||
actual = first
|
||||
actual = first.messages
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val second = awaitItem()
|
||||
|
@ -246,7 +246,7 @@ class ChatContainerTest {
|
|||
expected = listOf(
|
||||
msgAfterUpdate
|
||||
),
|
||||
actual = second
|
||||
actual = second.messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -312,7 +312,7 @@ class ChatContainerTest {
|
|||
expected = listOf(
|
||||
initialMsg
|
||||
),
|
||||
actual = first
|
||||
actual = first.messages
|
||||
)
|
||||
advanceUntilIdle()
|
||||
val second = awaitItem()
|
||||
|
@ -321,7 +321,7 @@ class ChatContainerTest {
|
|||
newMsg,
|
||||
initialMsg
|
||||
),
|
||||
actual = second
|
||||
actual = second.messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -376,17 +376,17 @@ class ChatContainerTest {
|
|||
val initial = awaitItem()
|
||||
assertEquals(
|
||||
expected = listOf(firstMessage),
|
||||
actual = initial
|
||||
actual = initial.messages
|
||||
)
|
||||
|
||||
container.onLoadNextPage()
|
||||
container.onLoadPrevious()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val updated = awaitItem()
|
||||
assertEquals(
|
||||
expected = listOf(nextMessage, firstMessage),
|
||||
actual = updated
|
||||
actual = updated.messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.anytypeio.anytype.core_models.Hash
|
|||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_models.ObjectWrapper
|
||||
import com.anytypeio.anytype.core_models.Url
|
||||
import com.anytypeio.anytype.domain.chats.ChatContainer
|
||||
import com.anytypeio.anytype.presentation.confgs.ChatConfig
|
||||
import com.anytypeio.anytype.presentation.objects.ObjectIcon
|
||||
import com.anytypeio.anytype.presentation.search.GlobalSearchItemView
|
||||
|
@ -145,4 +146,9 @@ sealed interface ChatView {
|
|||
data class Image(val hash: Hash): Avatar()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChatViewState(
|
||||
val messages: List<ChatView> = emptyList(),
|
||||
val intent: ChatContainer.Intent = ChatContainer.Intent.None
|
||||
)
|
|
@ -50,8 +50,10 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
@ -75,7 +77,7 @@ class ChatViewModel @Inject constructor(
|
|||
) : BaseViewModel(), ExitToVaultDelegate by exitToVaultDelegate {
|
||||
|
||||
val header = MutableStateFlow<HeaderView>(HeaderView.Init)
|
||||
val messages = MutableStateFlow<List<ChatView>>(emptyList())
|
||||
val uiState = MutableStateFlow<ChatViewState>(ChatViewState())
|
||||
val chatBoxAttachments = MutableStateFlow<List<ChatView.Message.ChatBoxAttachment>>(emptyList())
|
||||
val commands = MutableSharedFlow<ViewModelCommand>()
|
||||
val uXCommands = MutableSharedFlow<UXCommand>()
|
||||
|
@ -84,13 +86,12 @@ class ChatViewModel @Inject constructor(
|
|||
val mentionPanelState = MutableStateFlow<MentionPanelState>(MentionPanelState.Hidden)
|
||||
|
||||
private val dateFormatter = SimpleDateFormat("d MMMM YYYY")
|
||||
private val data = MutableStateFlow<List<Chat.Message>>(emptyList())
|
||||
|
||||
private var account: Id = ""
|
||||
|
||||
init {
|
||||
|
||||
// runDummyMessageGenerator()
|
||||
// generateDummyChatHistory()
|
||||
|
||||
viewModelScope.launch {
|
||||
spaceViews
|
||||
|
@ -131,15 +132,15 @@ class ChatViewModel @Inject constructor(
|
|||
) {
|
||||
combine(
|
||||
chatContainer
|
||||
.watchWhileTrackingAttachments(chat = chat),
|
||||
chatContainer.fetchAttachments(vmParams.space),
|
||||
chatContainer.fetchReplies(chat = chat)
|
||||
.watchWhileTrackingAttachments(chat = chat).distinctUntilChanged()
|
||||
,
|
||||
chatContainer.fetchAttachments(vmParams.space).distinctUntilChanged(),
|
||||
chatContainer.fetchReplies(chat = chat).distinctUntilChanged()
|
||||
) { result, dependencies, replies ->
|
||||
Timber.d("Got chat results: ${result.size}")
|
||||
data.value = result
|
||||
Timber.d("DROID-2966 Got chat results: ${result.messages.size}")
|
||||
var previousDate: ChatView.DateSection? = null
|
||||
buildList<ChatView> {
|
||||
result.forEach { msg ->
|
||||
val messageViews = buildList<ChatView> {
|
||||
result.messages.forEach { msg ->
|
||||
val allMembers = members.get()
|
||||
val member = allMembers.let { type ->
|
||||
when (type) {
|
||||
|
@ -294,8 +295,12 @@ class ChatViewModel @Inject constructor(
|
|||
add(view)
|
||||
}
|
||||
}.reversed()
|
||||
}.flowOn(dispatchers.io).collect {
|
||||
messages.value = it
|
||||
ChatViewState(
|
||||
messages = messageViews,
|
||||
result.intent
|
||||
)
|
||||
}.flowOn(dispatchers.io).distinctUntilChanged().collect {
|
||||
uiState.value = it
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -655,7 +660,7 @@ class ChatViewModel @Inject constructor(
|
|||
fun onReacted(msg: Id, reaction: String) {
|
||||
Timber.d("onReacted")
|
||||
viewModelScope.launch {
|
||||
val message = messages.value.find { it is ChatView.Message && it.id == msg }
|
||||
val message = uiState.value.messages.find { it is ChatView.Message && it.id == msg }
|
||||
if (message != null) {
|
||||
toggleChatMessageReaction.async(
|
||||
Command.ChatCommand.ToggleMessageReaction(
|
||||
|
@ -891,17 +896,39 @@ class ChatViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun onChatScrolledToTop() {
|
||||
Timber.d("onChatScrolledToTop")
|
||||
Timber.d("DROID-2966 onChatScrolledToTop")
|
||||
viewModelScope.launch {
|
||||
chatContainer.onLoadNextPage()
|
||||
chatContainer.onLoadPrevious()
|
||||
}
|
||||
}
|
||||
|
||||
fun onChatScrolledToBottom() {
|
||||
Timber.d("onChatScrolledToBottom")
|
||||
// TODO this behavior will be enabled later.
|
||||
Timber.d("DROID-2966 onChatScrolledToBottom")
|
||||
viewModelScope.launch {
|
||||
chatContainer.onLoadPreviousPage()
|
||||
chatContainer.onLoadNext()
|
||||
}
|
||||
}
|
||||
|
||||
fun onChatScrollToReply(replyMessage: Id) {
|
||||
Timber.d("DROID-2966 onScrollToReply: $replyMessage")
|
||||
viewModelScope.launch {
|
||||
chatContainer.onLoadToReply(replyMessage = replyMessage)
|
||||
}
|
||||
}
|
||||
|
||||
fun onScrollToBottomClicked() {
|
||||
Timber.d("DROID-2966 onScrollToBottom")
|
||||
viewModelScope.launch {
|
||||
chatContainer.onLoadChatTail()
|
||||
}
|
||||
}
|
||||
|
||||
fun onClearChatViewStateIntent() {
|
||||
Timber.d("DROID-2966 onClearChatViewStateIntent")
|
||||
viewModelScope.launch {
|
||||
uiState.update { current ->
|
||||
current.copy(intent = ChatContainer.Intent.None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -910,15 +937,22 @@ class ChatViewModel @Inject constructor(
|
|||
*/
|
||||
private fun generateDummyChatHistory() {
|
||||
viewModelScope.launch {
|
||||
repeat(100) {
|
||||
var replyTo: Id? = null
|
||||
repeat(100) { idx ->
|
||||
|
||||
addChatMessage.async(
|
||||
Command.ChatCommand.AddMessage(
|
||||
chat = vmParams.ctx,
|
||||
message = DummyMessageGenerator.generateMessage(
|
||||
text = it.toString()
|
||||
text = idx.toString(),
|
||||
replyTo = if (idx == 99) replyTo else null
|
||||
)
|
||||
)
|
||||
)
|
||||
).onSuccess { (msg, payload) ->
|
||||
if (idx == 0) {
|
||||
replyTo = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.anytypeio.anytype.core_ui.common.DefaultPreviews
|
|||
import com.anytypeio.anytype.feature_chats.R
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatView
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewState
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
|
@ -85,7 +86,8 @@ fun ChatPreview() {
|
|||
onAddReactionClicked = {},
|
||||
onViewChatReaction = { a, b -> },
|
||||
onMemberIconClicked = {},
|
||||
onMentionClicked = {}
|
||||
onMentionClicked = {},
|
||||
onScrollToReplyClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -134,7 +136,8 @@ fun ChatPreview2() {
|
|||
onAddReactionClicked = {},
|
||||
onViewChatReaction = { a, b -> },
|
||||
onMemberIconClicked = {},
|
||||
onMentionClicked = {}
|
||||
onMentionClicked = {},
|
||||
onScrollToReplyClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -143,29 +146,31 @@ fun ChatPreview2() {
|
|||
@Composable
|
||||
fun ChatScreenPreview() {
|
||||
ChatScreen(
|
||||
messages = buildList {
|
||||
repeat(30) { idx ->
|
||||
add(
|
||||
ChatView.Message(
|
||||
id = idx.toString(),
|
||||
content = ChatView.Message.Content(
|
||||
msg = stringResource(id = R.string.default_text_placeholder),
|
||||
parts = listOf(
|
||||
ChatView.Message.Content.Part(
|
||||
part = stringResource(id = R.string.default_text_placeholder)
|
||||
uiMessageState = ChatViewState(
|
||||
messages = buildList {
|
||||
repeat(30) { idx ->
|
||||
add(
|
||||
ChatView.Message(
|
||||
id = idx.toString(),
|
||||
content = ChatView.Message.Content(
|
||||
msg = stringResource(id = R.string.default_text_placeholder),
|
||||
parts = listOf(
|
||||
ChatView.Message.Content.Part(
|
||||
part = stringResource(id = R.string.default_text_placeholder)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
author = "User ${idx.inc()}",
|
||||
timestamp =
|
||||
System.currentTimeMillis()
|
||||
- 30.toDuration(DurationUnit.DAYS).inWholeMilliseconds
|
||||
+ idx.toDuration(DurationUnit.DAYS).inWholeMilliseconds,
|
||||
creator = "random id"
|
||||
),
|
||||
author = "User ${idx.inc()}",
|
||||
timestamp =
|
||||
System.currentTimeMillis()
|
||||
- 30.toDuration(DurationUnit.DAYS).inWholeMilliseconds
|
||||
+ idx.toDuration(DurationUnit.DAYS).inWholeMilliseconds,
|
||||
creator = "random id"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}.reversed(),
|
||||
}
|
||||
}.reversed()
|
||||
),
|
||||
onMessageSent = { a, b -> },
|
||||
attachments = emptyList(),
|
||||
onClearAttachmentClicked = {},
|
||||
|
@ -189,7 +194,11 @@ fun ChatScreenPreview() {
|
|||
onMentionClicked = {},
|
||||
mentionPanelState = ChatViewModel.MentionPanelState.Hidden,
|
||||
onTextChanged = {},
|
||||
onChatScrolledToTop = {}
|
||||
onChatScrolledToTop = {},
|
||||
onChatScrolledToBottom = {},
|
||||
onScrollToReplyClicked = {},
|
||||
onClearIntent = {},
|
||||
onScrollToBottomClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import android.provider.OpenableColumns
|
|||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
@ -77,15 +78,18 @@ import com.anytypeio.anytype.core_ui.views.Caption1Regular
|
|||
import com.anytypeio.anytype.core_ui.views.PreviewTitle2Regular
|
||||
import com.anytypeio.anytype.core_utils.common.DefaultFileInfo
|
||||
import com.anytypeio.anytype.core_utils.ext.parseImagePath
|
||||
import com.anytypeio.anytype.domain.chats.ChatContainer
|
||||
import com.anytypeio.anytype.feature_chats.R
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatView
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.ChatBoxMode
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.MentionPanelState
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.UXCommand
|
||||
import com.anytypeio.anytype.feature_chats.presentation.ChatViewState
|
||||
import kotlinx.coroutines.android.awaitFrame
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -120,7 +124,7 @@ fun ChatScreenWrapper(
|
|||
|
||||
ChatScreen(
|
||||
chatBoxMode = vm.chatBoxMode.collectAsState().value,
|
||||
messages = vm.messages.collectAsState().value,
|
||||
uiMessageState = vm.uiState.collectAsState().value,
|
||||
attachments = vm.chatBoxAttachments.collectAsState().value,
|
||||
onMessageSent = { text, spans ->
|
||||
vm.onMessageSent(
|
||||
|
@ -190,7 +194,11 @@ fun ChatScreenWrapper(
|
|||
text = value.text
|
||||
)
|
||||
},
|
||||
onChatScrolledToTop = vm::onChatScrolledToTop
|
||||
onChatScrolledToTop = vm::onChatScrolledToTop,
|
||||
onChatScrolledToBottom = vm::onChatScrolledToBottom,
|
||||
onScrollToReplyClicked = vm::onChatScrollToReply,
|
||||
onClearIntent = vm::onClearChatViewStateIntent,
|
||||
onScrollToBottomClicked = vm::onScrollToBottomClicked
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
vm.uXCommands.collect { command ->
|
||||
|
@ -227,6 +235,55 @@ fun ChatScreenWrapper(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyListState.OnBottomReached(
|
||||
thresholdItems: Int = 0,
|
||||
onBottomReached: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(this) {
|
||||
var prevIndex = firstVisibleItemIndex
|
||||
snapshotFlow { firstVisibleItemIndex }
|
||||
.distinctUntilChanged()
|
||||
.collect { index ->
|
||||
val isDragging = isScrollInProgress
|
||||
|
||||
// Are we scrolling *toward* the bottom edge?
|
||||
val scrollingDown = isDragging && prevIndex > index
|
||||
|
||||
// Have we crossed into the threshold zone?
|
||||
val atBottom = index <= thresholdItems
|
||||
|
||||
if (scrollingDown && atBottom) {
|
||||
onBottomReached()
|
||||
}
|
||||
prevIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyListState.OnTopReached(
|
||||
thresholdItems: Int = 0,
|
||||
onTopReached: () -> Unit
|
||||
) {
|
||||
val isReached = remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
if (lastVisibleItem != null) {
|
||||
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - thresholdItems
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isReached) {
|
||||
snapshotFlow { isReached.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { if (it) onTopReached() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: do date formating before rendering?
|
||||
*/
|
||||
|
@ -235,7 +292,7 @@ fun ChatScreen(
|
|||
mentionPanelState: MentionPanelState,
|
||||
chatBoxMode: ChatBoxMode,
|
||||
lazyListState: LazyListState,
|
||||
messages: List<ChatView>,
|
||||
uiMessageState: ChatViewState,
|
||||
attachments: List<ChatView.Message.ChatBoxAttachment>,
|
||||
onMessageSent: (String, List<ChatBoxSpan>) -> Unit,
|
||||
onClearAttachmentClicked: (ChatView.Message.ChatBoxAttachment) -> Unit,
|
||||
|
@ -256,8 +313,15 @@ fun ChatScreen(
|
|||
onMemberIconClicked: (Id?) -> Unit,
|
||||
onMentionClicked: (Id) -> Unit,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
onChatScrolledToTop: () -> Unit
|
||||
onChatScrolledToTop: () -> Unit,
|
||||
onChatScrolledToBottom: () -> Unit,
|
||||
onScrollToReplyClicked: (Id) -> Unit,
|
||||
onClearIntent: () -> Unit,
|
||||
onScrollToBottomClicked: () -> Unit
|
||||
) {
|
||||
|
||||
Timber.d("DROID-2966 Render called with state")
|
||||
|
||||
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
|
@ -268,38 +332,68 @@ fun ChatScreen(
|
|||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(uiMessageState.intent) {
|
||||
when (val intent = uiMessageState.intent) {
|
||||
is ChatContainer.Intent.ScrollToMessage -> {
|
||||
val index = uiMessageState.messages.indexOfFirst {
|
||||
it is ChatView.Message && it.id == intent.id
|
||||
}
|
||||
|
||||
// Scrolling to bottom when list size changes and we are at the bottom of the list
|
||||
LaunchedEffect(messages.size) {
|
||||
if (lazyListState.firstVisibleItemScrollOffset == 0) {
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
if (index >= 0) {
|
||||
Timber.d("DROID-2966 Waiting for layout to stabilize...")
|
||||
|
||||
snapshotFlow { lazyListState.layoutInfo.totalItemsCount }
|
||||
.first { it > index }
|
||||
|
||||
lazyListState.scrollToItem(index)
|
||||
|
||||
awaitFrame()
|
||||
|
||||
val itemInfo = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
|
||||
if (itemInfo != null) {
|
||||
val viewportHeight = lazyListState.layoutInfo.viewportSize.height
|
||||
|
||||
// Centering calculation:
|
||||
// itemCenter relative to viewport top
|
||||
val itemCenterFromTop = itemInfo.offset + (itemInfo.size / 2)
|
||||
val viewportCenter = viewportHeight / 2
|
||||
|
||||
val delta = itemCenterFromTop - viewportCenter
|
||||
|
||||
Timber.d("DROID-2966 Calculated delta for centering (reverseLayout-aware): $delta")
|
||||
|
||||
// move negatively because reverseLayout flips
|
||||
lazyListState.animateScrollBy(delta.toFloat())
|
||||
|
||||
Timber.d("DROID-2966 Scroll complete. Now clearing intent.")
|
||||
|
||||
onClearIntent()
|
||||
} else {
|
||||
Timber.w("DROID-2966 Target item not found after scroll!")
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatContainer.Intent.ScrollToBottom -> {
|
||||
smoothScrollToBottom(lazyListState)
|
||||
onClearIntent()
|
||||
}
|
||||
is ChatContainer.Intent.Highlight -> {
|
||||
// maybe flash background, etc.
|
||||
}
|
||||
ChatContainer.Intent.None -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
lazyListState.OnBottomReached(
|
||||
thresholdItems = 3
|
||||
) {
|
||||
onChatScrolledToBottom()
|
||||
}
|
||||
|
||||
var isAtTop by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(lazyListState, messages.size) {
|
||||
if (messages.isEmpty()) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
lazyListState.layoutInfo.visibleItemsInfo
|
||||
.lastOrNull { it.key is String && !(it.key as String).startsWith(DATE_KEY_PREFIX) }
|
||||
?.key as? String
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { currentTopMessageId ->
|
||||
val isNowAtTop = currentTopMessageId != null &&
|
||||
currentTopMessageId == (messages.lastOrNull { it is ChatView.Message } as? ChatView.Message)?.id
|
||||
|
||||
if (isNowAtTop && !isAtTop) {
|
||||
isAtTop = true
|
||||
onChatScrolledToTop()
|
||||
} else if (!isNowAtTop && isAtTop) {
|
||||
isAtTop = false // reset for next entry
|
||||
}
|
||||
}
|
||||
lazyListState.OnTopReached(
|
||||
thresholdItems = 3
|
||||
) {
|
||||
onChatScrolledToTop()
|
||||
}
|
||||
|
||||
Column(
|
||||
|
@ -308,7 +402,7 @@ fun ChatScreen(
|
|||
Box(modifier = Modifier.weight(1f)) {
|
||||
Messages(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
messages = messages,
|
||||
messages = uiMessageState.messages,
|
||||
scrollState = lazyListState,
|
||||
onReacted = onReacted,
|
||||
onCopyMessage = onCopyMessage,
|
||||
|
@ -331,7 +425,8 @@ fun ChatScreen(
|
|||
onAddReactionClicked = onAddReactionClicked,
|
||||
onViewChatReaction = onViewChatReaction,
|
||||
onMemberIconClicked = onMemberIconClicked,
|
||||
onMentionClicked = onMentionClicked
|
||||
onMentionClicked = onMentionClicked,
|
||||
onScrollToReplyClicked = onScrollToReplyClicked
|
||||
)
|
||||
// Jump to bottom button shows up when user scrolls past a threshold.
|
||||
// Convert to pixels:
|
||||
|
@ -353,9 +448,7 @@ fun ChatScreen(
|
|||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 12.dp),
|
||||
onGoToBottomClicked = {
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(index = 0)
|
||||
}
|
||||
onScrollToBottomClicked()
|
||||
},
|
||||
enabled = jumpToBottomButtonEnabled
|
||||
)
|
||||
|
@ -393,7 +486,8 @@ fun ChatScreen(
|
|||
|
||||
val replacementText = member.name + " "
|
||||
|
||||
val lengthDifference = replacementText.length - (query.range.last - query.range.first + 1)
|
||||
val lengthDifference =
|
||||
replacementText.length - (query.range.last - query.range.first + 1)
|
||||
|
||||
val updatedText = input.replaceRange(
|
||||
query.range,
|
||||
|
@ -404,7 +498,7 @@ fun ChatScreen(
|
|||
|
||||
val updatedSpans = spans.map { span ->
|
||||
if (span.start > query.range.last) {
|
||||
when(span) {
|
||||
when (span) {
|
||||
is ChatBoxSpan.Mention -> {
|
||||
span.copy(
|
||||
start = span.start + lengthDifference,
|
||||
|
@ -500,8 +594,10 @@ fun Messages(
|
|||
onAddReactionClicked: (String) -> Unit,
|
||||
onViewChatReaction: (Id, String) -> Unit,
|
||||
onMemberIconClicked: (Id?) -> Unit,
|
||||
onMentionClicked: (Id) -> Unit
|
||||
onMentionClicked: (Id) -> Unit,
|
||||
onScrollToReplyClicked: (Id) -> Unit,
|
||||
) {
|
||||
Timber.d("DROID-2966 Messages composition: ${messages.map { if (it is ChatView.Message) it.content.msg else it }}")
|
||||
val scope = rememberCoroutineScope()
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
|
@ -572,12 +668,11 @@ fun Messages(
|
|||
},
|
||||
reply = msg.reply,
|
||||
onScrollToReplyClicked = { reply ->
|
||||
// Naive implementation
|
||||
val idx = messages.indexOfFirst { it is ChatView.Message && it.id == reply.msg }
|
||||
if (idx != -1) {
|
||||
scope.launch {
|
||||
scrollState.animateScrollToItem(index = idx)
|
||||
}
|
||||
scope.launch { scrollState.animateScrollToItem(index = idx) }
|
||||
} else {
|
||||
onScrollToReplyClicked(reply.msg)
|
||||
}
|
||||
},
|
||||
onAddReactionClicked = {
|
||||
|
@ -687,6 +782,17 @@ fun TopDiscussionToolbar(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun smoothScrollToBottom(lazyListState: LazyListState) {
|
||||
if (lazyListState.firstVisibleItemIndex > 0) {
|
||||
lazyListState.scrollToItem(0)
|
||||
return
|
||||
}
|
||||
while (lazyListState.firstVisibleItemScrollOffset > 0) {
|
||||
val delta = (-lazyListState.firstVisibleItemScrollOffset).coerceAtLeast(-40)
|
||||
lazyListState.animateScrollBy(delta.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode")
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode")
|
||||
@Composable
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue