mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-3298 Chats | Enhancement | Counters basics (#2368)
This commit is contained in:
parent
e2f311f781
commit
cc2b2c95a3
15 changed files with 416 additions and 145 deletions
|
@ -618,6 +618,14 @@ sealed class Command {
|
|||
}
|
||||
|
||||
sealed class ChatCommand {
|
||||
|
||||
data class ReadMessages(
|
||||
val chat: Id,
|
||||
val afterOrderId: Id? = null,
|
||||
val beforeOrderId: Id? = null,
|
||||
val lastStateId: Id? = null
|
||||
)
|
||||
|
||||
data class AddMessage(
|
||||
val chat: Id,
|
||||
val message: Chat.Message,
|
||||
|
@ -633,11 +641,16 @@ sealed class Command {
|
|||
val message: Chat.Message
|
||||
) : ChatCommand()
|
||||
|
||||
/**
|
||||
* @property [includeBoundary] defines whether the message corresponding to the order ID
|
||||
* in [afterOrderId] or [beforeOrderId] should be included in results.
|
||||
*/
|
||||
data class GetMessages(
|
||||
val chat: Id,
|
||||
val beforeOrderId: Id? = null,
|
||||
val afterOrderId: Id? = null,
|
||||
val limit: Int
|
||||
val limit: Int,
|
||||
val includeBoundary: Boolean = false
|
||||
) : ChatCommand() {
|
||||
data class Response(
|
||||
val messages: List<Chat.Message>,
|
||||
|
|
|
@ -321,6 +321,7 @@ sealed class Event {
|
|||
}
|
||||
|
||||
sealed class Chats : Command() {
|
||||
|
||||
data class Add(
|
||||
override val context: Id,
|
||||
val id: Id,
|
||||
|
|
|
@ -91,9 +91,9 @@ sealed class Chat {
|
|||
}
|
||||
|
||||
data class State(
|
||||
val unreadMessages: UnreadState?,
|
||||
val unreadMentions: UnreadState?,
|
||||
val lastStateId: Id,
|
||||
val unreadMessages: UnreadState? = null,
|
||||
val unreadMentions: UnreadState? = null,
|
||||
val lastStateId: Id? = null,
|
||||
) {
|
||||
/**
|
||||
* @property olderOrderId oldest(in the lex sorting) unread message order id. Client should ALWAYS scroll through unread messages from the oldest to the newest
|
||||
|
@ -102,5 +102,12 @@ sealed class Chat {
|
|||
val olderOrderId: Id,
|
||||
val counter: Int
|
||||
)
|
||||
|
||||
val hasUnReadMessages: Boolean get() {
|
||||
return unreadMessages?.counter != null && unreadMessages.counter > 0
|
||||
}
|
||||
|
||||
val oldestMessageOrderId: Id? = unreadMessages?.olderOrderId
|
||||
val oldestMentionMessageOrderId: Id? = unreadMentions?.olderOrderId
|
||||
}
|
||||
}
|
|
@ -1057,6 +1057,10 @@ class BlockDataRepository(
|
|||
return remote.addChatMessage(command)
|
||||
}
|
||||
|
||||
override suspend fun readChatMessages(command: Command.ChatCommand.ReadMessages) {
|
||||
return remote.readChatMessages(command)
|
||||
}
|
||||
|
||||
override suspend fun editChatMessage(command: Command.ChatCommand.EditMessage) {
|
||||
remote.editChatMessage(command)
|
||||
}
|
||||
|
|
|
@ -454,6 +454,7 @@ interface BlockRemote {
|
|||
|
||||
suspend fun addChatMessage(command: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>>
|
||||
suspend fun editChatMessage(command: Command.ChatCommand.EditMessage)
|
||||
suspend fun readChatMessages(command: Command.ChatCommand.ReadMessages)
|
||||
suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage)
|
||||
suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): Command.ChatCommand.GetMessages.Response
|
||||
suspend fun getChatMessagesByIds(command: Command.ChatCommand.GetMessagesByIds): List<Chat.Message>
|
||||
|
|
|
@ -495,6 +495,7 @@ interface BlockRepository {
|
|||
|
||||
suspend fun addChatMessage(command: Command.ChatCommand.AddMessage): Pair<Id, List<Event.Command.Chats>>
|
||||
suspend fun editChatMessage(command: Command.ChatCommand.EditMessage)
|
||||
suspend fun readChatMessages(command: Command.ChatCommand.ReadMessages)
|
||||
suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage)
|
||||
suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): Command.ChatCommand.GetMessages.Response
|
||||
suspend fun getChatMessagesByIds(command: Command.ChatCommand.GetMessagesByIds): List<Chat.Message>
|
||||
|
|
|
@ -106,7 +106,7 @@ class ChatContainer @Inject constructor(
|
|||
}
|
||||
|
||||
fun watch(chat: Id): Flow<ChatStreamState> = flow {
|
||||
val initial = repo.subscribeLastChatMessages(
|
||||
val response = repo.subscribeLastChatMessages(
|
||||
command = Command.ChatCommand.SubscribeLastMessages(
|
||||
chat = chat,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
|
@ -115,6 +115,29 @@ class ChatContainer @Inject constructor(
|
|||
cacheLastMessages(result.messages)
|
||||
}
|
||||
|
||||
val state = response.chatState ?: Chat.State()
|
||||
|
||||
var intent: Intent = Intent.None
|
||||
|
||||
val initial = buildList<Chat.Message> {
|
||||
if (state.hasUnReadMessages && !state.oldestMessageOrderId.isNullOrEmpty()) {
|
||||
// Starting from the unread-messages window.
|
||||
val aroundUnread = loadAroundMessageOrder(
|
||||
chat = chat,
|
||||
order = state.oldestMessageOrderId.orEmpty()
|
||||
).also {
|
||||
val target = it.find { it.order == state.oldestMessageOrderId }
|
||||
if (target != null) {
|
||||
intent = Intent.ScrollToMessage(target.id)
|
||||
}
|
||||
}
|
||||
addAll(aroundUnread)
|
||||
} else {
|
||||
// Starting with the latest messages.
|
||||
addAll(response.messages)
|
||||
}
|
||||
}
|
||||
|
||||
val inputs: Flow<Transformation> = merge(
|
||||
channel.observe(chat).map { Transformation.Events.Payload(it) },
|
||||
payloads.map { Transformation.Events.Payload(it) },
|
||||
|
@ -122,49 +145,95 @@ class ChatContainer @Inject constructor(
|
|||
)
|
||||
|
||||
emitAll(
|
||||
inputs.scan(initial = ChatStreamState(initial.messages)) { state, transform ->
|
||||
inputs.scan(
|
||||
initial = ChatStreamState(
|
||||
messages = initial,
|
||||
state = state,
|
||||
intent = intent
|
||||
)
|
||||
) { state, transform ->
|
||||
when (transform) {
|
||||
Transformation.Commands.LoadPrevious -> {
|
||||
ChatStreamState(
|
||||
messages = loadThePreviousPage(state.messages, chat),
|
||||
intent = Intent.None
|
||||
intent = Intent.None,
|
||||
state = state.state
|
||||
)
|
||||
}
|
||||
Transformation.Commands.LoadNext -> {
|
||||
ChatStreamState(
|
||||
messages = loadTheNextPage(state.messages, chat),
|
||||
intent = Intent.None
|
||||
intent = Intent.None,
|
||||
state = state.state
|
||||
)
|
||||
}
|
||||
is Transformation.Commands.LoadAround -> {
|
||||
val messages = try {
|
||||
loadToMessage(chat, transform)
|
||||
loadAroundMessage(chat, transform.message)
|
||||
} catch (e: Exception) {
|
||||
logger.logException(e, "DROID-2966 Error while loading reply context")
|
||||
state.messages
|
||||
}
|
||||
ChatStreamState(
|
||||
messages = messages,
|
||||
intent = Intent.ScrollToMessage(transform.message)
|
||||
intent = Intent.ScrollToMessage(transform.message),
|
||||
state = state.state
|
||||
)
|
||||
}
|
||||
is Transformation.Commands.LoadEnd -> {
|
||||
val messages = try {
|
||||
loadToEnd(chat)
|
||||
} catch (e: Exception) {
|
||||
logger.logException(e, "DROID-2966 Error while scrolling to bottom")
|
||||
state.messages
|
||||
if (state.messages.isNotEmpty()) {
|
||||
val lastShown = state.messages.last()
|
||||
val lastTracked = lastMessages.entries.first().value
|
||||
if (lastShown.id == lastTracked.id) {
|
||||
// No need to paginate, just scroll to bottom.
|
||||
state.copy(
|
||||
intent = Intent.ScrollToBottom
|
||||
)
|
||||
} else {
|
||||
val messages = try {
|
||||
loadToEnd(chat)
|
||||
} catch (e: Exception) {
|
||||
state.messages.also {
|
||||
logger.logException(e, "DROID-2966 Error while scrolling to bottom")
|
||||
}
|
||||
}
|
||||
ChatStreamState(
|
||||
messages = messages,
|
||||
intent = Intent.ScrollToBottom,
|
||||
state = state.state
|
||||
)
|
||||
}
|
||||
} else {
|
||||
state
|
||||
}
|
||||
ChatStreamState(
|
||||
messages = messages,
|
||||
intent = Intent.ScrollToBottom
|
||||
)
|
||||
}
|
||||
is Transformation.Commands.UpdateVisibleRange -> {
|
||||
val unread = state.state
|
||||
val readFrom = state.messages.find { it.id == transform.from }
|
||||
if (
|
||||
unread.hasUnReadMessages &&
|
||||
!unread.oldestMessageOrderId.isNullOrEmpty() &&
|
||||
readFrom != null
|
||||
&& readFrom.order >= unread.oldestMessageOrderId!!
|
||||
) {
|
||||
runCatching {
|
||||
repo.readChatMessages(
|
||||
command = Command.ChatCommand.ReadMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = readFrom.order,
|
||||
lastStateId = unread.lastStateId.orEmpty()
|
||||
)
|
||||
)
|
||||
}.onFailure {
|
||||
logger.logException(it, "Error while reading messages")
|
||||
}.onSuccess {
|
||||
logger.logInfo("Read messages with success")
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
is Transformation.Events.Payload -> {
|
||||
ChatStreamState(
|
||||
messages = state.messages.reduce(transform.events),
|
||||
intent = Intent.None
|
||||
)
|
||||
state.reduce(transform.events)
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
@ -178,15 +247,15 @@ class ChatContainer @Inject constructor(
|
|||
}
|
||||
|
||||
@Throws
|
||||
private suspend fun loadToMessage(
|
||||
private suspend fun loadAroundMessage(
|
||||
chat: Id,
|
||||
transform: Transformation.Commands.LoadAround
|
||||
msg: Id
|
||||
): List<Chat.Message> {
|
||||
|
||||
val replyMessage = repo.getChatMessagesByIds(
|
||||
Command.ChatCommand.GetMessagesByIds(
|
||||
chat = chat,
|
||||
messages = listOf(transform.message)
|
||||
messages = listOf(msg)
|
||||
)
|
||||
).firstOrNull()
|
||||
|
||||
|
@ -217,6 +286,34 @@ class ChatContainer @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
private suspend fun loadAroundMessageOrder(
|
||||
chat: Id,
|
||||
order: Id
|
||||
): List<Chat.Message> {
|
||||
val loadedMessagesBefore = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
beforeOrderId = order,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE
|
||||
)
|
||||
).messages
|
||||
|
||||
val loadedMessagesAfter = repo.getChatMessages(
|
||||
Command.ChatCommand.GetMessages(
|
||||
chat = chat,
|
||||
afterOrderId = order,
|
||||
limit = DEFAULT_CHAT_PAGING_SIZE,
|
||||
includeBoundary = true
|
||||
)
|
||||
).messages
|
||||
|
||||
return buildList {
|
||||
addAll(loadedMessagesBefore)
|
||||
addAll(loadedMessagesAfter)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTheNextPage(
|
||||
state: List<Chat.Message>,
|
||||
chat: Id
|
||||
|
@ -283,17 +380,20 @@ class ChatContainer @Inject constructor(
|
|||
payloads.emit(events)
|
||||
}
|
||||
|
||||
fun List<Chat.Message>.reduce(events: List<Event.Command.Chats>): List<Chat.Message> {
|
||||
val result = this.toMutableList()
|
||||
fun ChatStreamState.reduce(
|
||||
events: List<Event.Command.Chats>
|
||||
): ChatStreamState {
|
||||
val messageList = this.messages.toMutableList()
|
||||
var countersState = this.state
|
||||
events.forEach { event ->
|
||||
when (event) {
|
||||
is Event.Command.Chats.Add -> {
|
||||
if (!result.isInCurrentWindow(event.message.id)) {
|
||||
val insertIndex = result.indexOfFirst { it.order > event.order }
|
||||
if (!messageList.isInCurrentWindow(event.message.id)) {
|
||||
val insertIndex = messageList.indexOfFirst { it.order > event.order }
|
||||
if (insertIndex >= 0) {
|
||||
result.add(insertIndex, event.message)
|
||||
messageList.add(insertIndex, event.message)
|
||||
} else {
|
||||
result.add(event.message)
|
||||
messageList.add(event.message)
|
||||
}
|
||||
}
|
||||
// Tracking the last message in the chat tail
|
||||
|
@ -301,59 +401,67 @@ class ChatContainer @Inject constructor(
|
|||
}
|
||||
|
||||
is Event.Command.Chats.Update -> {
|
||||
if (result.isInCurrentWindow(event.id)) {
|
||||
val index = result.indexOfFirst { it.id == event.id }
|
||||
result[index] = event.message
|
||||
if (messageList.isInCurrentWindow(event.id)) {
|
||||
val index = messageList.indexOfFirst { it.id == event.id }
|
||||
messageList[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)
|
||||
if (messageList.isInCurrentWindow(event.id)) {
|
||||
val index = messageList.indexOfFirst { it.id == event.id }
|
||||
messageList.removeAt(index)
|
||||
}
|
||||
// Tracking the last message in the chat tail
|
||||
lastMessages.remove(event.id)
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateReactions -> {
|
||||
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)
|
||||
if (messageList.isInCurrentWindow(event.id)) {
|
||||
val index = messageList.indexOfFirst { it.id == event.id }
|
||||
if (messageList[index].reactions != event.reactions) {
|
||||
messageList[index] = messageList[index].copy(reactions = event.reactions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateMentionReadStatus -> {
|
||||
val idsInWindow = event.messages.filter { result.isInCurrentWindow(it) }
|
||||
val idsInWindow = event.messages.filter { messageList.isInCurrentWindow(it) }
|
||||
idsInWindow.forEach { id ->
|
||||
val index = result.indexOfFirst { it.id == id }
|
||||
if (result[index].mentionRead != event.isRead) {
|
||||
result[index] = result[index].copy(mentionRead = event.isRead)
|
||||
val index = messageList.indexOfFirst { it.id == id }
|
||||
if (messageList[index].mentionRead != event.isRead) {
|
||||
messageList[index] = messageList[index].copy(mentionRead = event.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateMessageReadStatus -> {
|
||||
val idsInWindow = event.messages.filter { result.isInCurrentWindow(it) }
|
||||
val idsInWindow = event.messages.filter { messageList.isInCurrentWindow(it) }
|
||||
idsInWindow.forEach { id ->
|
||||
val index = result.indexOfFirst { it.id == id }
|
||||
if (result[index].read != event.isRead) {
|
||||
result[index] = result[index].copy(read = event.isRead)
|
||||
val index = messageList.indexOfFirst { it.id == id }
|
||||
if (messageList[index].read != event.isRead) {
|
||||
messageList[index] = messageList[index].copy(read = event.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Event.Command.Chats.UpdateState -> {
|
||||
// TODO handle later
|
||||
logger.logWarning(
|
||||
"DROID-2966 Updating chat state, " +
|
||||
"last state: ${this.state.lastStateId}, " +
|
||||
"new state: ${event.state?.lastStateId}"
|
||||
)
|
||||
countersState = event.state ?: Chat.State()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return ChatStreamState(
|
||||
messages = messageList,
|
||||
state = countersState
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun onLoadPrevious() {
|
||||
|
@ -374,6 +482,11 @@ class ChatContainer @Inject constructor(
|
|||
commands.emit(Transformation.Commands.LoadEnd)
|
||||
}
|
||||
|
||||
suspend fun onVisibleRangeChanged(from: Id, to: Id) {
|
||||
logger.logInfo("DROID-2966 onVisibleRangeChanged")
|
||||
commands.emit(Transformation.Commands.UpdateVisibleRange(from, to))
|
||||
}
|
||||
|
||||
private fun cacheLastMessages(messages: List<Chat.Message>) {
|
||||
messages.sortedByDescending { it.order } // Newest first
|
||||
.take(LAST_MESSAGES_MAX_SIZE)
|
||||
|
@ -415,19 +528,26 @@ class ChatContainer @Inject constructor(
|
|||
* Scroll-to-bottom behavior.
|
||||
*/
|
||||
data object LoadEnd: Commands()
|
||||
|
||||
data class UpdateVisibleRange(val from: Id, val to: Id) : Commands()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_CHAT_PAGING_SIZE = 10
|
||||
const val DEFAULT_CHAT_PAGING_SIZE = 100
|
||||
// TODO reduce message size to reduce UI and VM overload.
|
||||
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)
|
||||
|
||||
/**
|
||||
* Messages sorted — from the oldest to the latest.
|
||||
*/
|
||||
data class ChatStreamState(
|
||||
val messages: List<Chat.Message>,
|
||||
val state: Chat.State = Chat.State(),
|
||||
val intent: Intent = Intent.None
|
||||
)
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ sealed interface ChatView {
|
|||
|
||||
data class Message(
|
||||
val id: String,
|
||||
val order: Id = "",
|
||||
val content: Content,
|
||||
val author: String,
|
||||
val creator: Id?,
|
||||
|
@ -150,5 +151,10 @@ sealed interface ChatView {
|
|||
|
||||
data class ChatViewState(
|
||||
val messages: List<ChatView> = emptyList(),
|
||||
val intent: ChatContainer.Intent = ChatContainer.Intent.None
|
||||
)
|
||||
val intent: ChatContainer.Intent = ChatContainer.Intent.None,
|
||||
val counter: Counter = Counter()
|
||||
) {
|
||||
data class Counter(
|
||||
val count: Int = 0
|
||||
)
|
||||
}
|
|
@ -46,10 +46,12 @@ import com.anytypeio.anytype.presentation.util.CopyFileToCacheDirectory
|
|||
import com.anytypeio.anytype.presentation.vault.ExitToVaultDelegate
|
||||
import java.text.SimpleDateFormat
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
@ -76,6 +78,12 @@ class ChatViewModel @Inject constructor(
|
|||
private val exitToVaultDelegate: ExitToVaultDelegate,
|
||||
) : BaseViewModel(), ExitToVaultDelegate by exitToVaultDelegate {
|
||||
|
||||
private val visibleRangeUpdates = MutableSharedFlow<Pair<Id, Id>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
val header = MutableStateFlow<HeaderView>(HeaderView.Init)
|
||||
val uiState = MutableStateFlow<ChatViewState>(ChatViewState())
|
||||
val chatBoxAttachments = MutableStateFlow<List<ChatView.Message.ChatBoxAttachment>>(emptyList())
|
||||
|
@ -110,6 +118,15 @@ class ChatViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
visibleRangeUpdates
|
||||
.debounce(300) // Delay to avoid spamming
|
||||
.distinctUntilChanged()
|
||||
.collect { (from, to) ->
|
||||
chatContainer.onVisibleRangeChanged(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
getAccount
|
||||
.async(Unit)
|
||||
|
@ -137,7 +154,9 @@ class ChatViewModel @Inject constructor(
|
|||
chatContainer.fetchAttachments(vmParams.space).distinctUntilChanged(),
|
||||
chatContainer.fetchReplies(chat = chat).distinctUntilChanged()
|
||||
) { result, dependencies, replies ->
|
||||
Timber.d("DROID-2966 Got chat results: ${result.messages.size}")
|
||||
Timber.d("DROID-2966 Chat counter state from container: ${result.state}")
|
||||
Timber.d("DROID-2966 Intent from container: ${result.intent}")
|
||||
Timber.d("DROID-2966 Message results size from container: ${result.messages.size}")
|
||||
var previousDate: ChatView.DateSection? = null
|
||||
val messageViews = buildList<ChatView> {
|
||||
result.messages.forEach { msg ->
|
||||
|
@ -195,6 +214,7 @@ class ChatViewModel @Inject constructor(
|
|||
|
||||
val view = ChatView.Message(
|
||||
id = msg.id,
|
||||
order = msg.order,
|
||||
timestamp = msg.createdAt * 1000,
|
||||
content = ChatView.Message.Content(
|
||||
msg = content?.text.orEmpty(),
|
||||
|
@ -297,7 +317,10 @@ class ChatViewModel @Inject constructor(
|
|||
}.reversed()
|
||||
ChatViewState(
|
||||
messages = messageViews,
|
||||
result.intent
|
||||
intent = result.intent,
|
||||
counter = ChatViewState.Counter(
|
||||
count = result.state.unreadMessages?.counter ?: 0
|
||||
)
|
||||
)
|
||||
}.flowOn(dispatchers.io).distinctUntilChanged().collect {
|
||||
uiState.value = it
|
||||
|
@ -502,7 +525,7 @@ class ChatViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
}.onFailure {
|
||||
Timber.e(it, "Error while uploading file as attachment")
|
||||
Timber.e(it, "DROID-2966 Error while uploading file as attachment")
|
||||
chatBoxAttachments.value = currAttachments.toMutableList().apply {
|
||||
set(
|
||||
index = idx,
|
||||
|
@ -932,6 +955,14 @@ class ChatViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun onVisibleRangeChanged(
|
||||
from: Id,
|
||||
to: Id
|
||||
) {
|
||||
Timber.d("onVisibleRangeChanged, from: $from, to: $to")
|
||||
visibleRangeUpdates.tryEmit(from to to)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for testing. Will be deleted.
|
||||
*/
|
||||
|
|
|
@ -198,7 +198,8 @@ fun ChatScreenPreview() {
|
|||
onChatScrolledToBottom = {},
|
||||
onScrollToReplyClicked = {},
|
||||
onClearIntent = {},
|
||||
onScrollToBottomClicked = {}
|
||||
onScrollToBottomClicked = {},
|
||||
onVisibleRangeChanged = { _, _ -> }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
@ -40,6 +41,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
|
@ -87,9 +89,12 @@ import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.MentionPan
|
|||
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.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
|
@ -198,7 +203,8 @@ fun ChatScreenWrapper(
|
|||
onChatScrolledToBottom = vm::onChatScrolledToBottom,
|
||||
onScrollToReplyClicked = vm::onChatScrollToReply,
|
||||
onClearIntent = vm::onClearChatViewStateIntent,
|
||||
onScrollToBottomClicked = vm::onScrollToBottomClicked
|
||||
onScrollToBottomClicked = vm::onScrollToBottomClicked,
|
||||
onVisibleRangeChanged = vm::onVisibleRangeChanged
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
vm.uXCommands.collect { command ->
|
||||
|
@ -236,51 +242,41 @@ fun ChatScreenWrapper(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun LazyListState.OnBottomReached(
|
||||
private fun LazyListState.OnTopReachedSafely(
|
||||
thresholdItems: Int = 0,
|
||||
onBottomReached: () -> Unit
|
||||
onTopReached: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(this) {
|
||||
var prevIndex = firstVisibleItemIndex
|
||||
snapshotFlow { firstVisibleItemIndex }
|
||||
snapshotFlow {
|
||||
layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
}
|
||||
.filterNotNull()
|
||||
.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()
|
||||
.collect { lastVisibleIndex ->
|
||||
val isTop = lastVisibleIndex >= layoutInfo.totalItemsCount - 1 - thresholdItems
|
||||
if (isTop) {
|
||||
onTopReached()
|
||||
}
|
||||
prevIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyListState.OnTopReached(
|
||||
private fun LazyListState.OnBottomReachedSafely(
|
||||
thresholdItems: Int = 0,
|
||||
onTopReached: () -> Unit
|
||||
onBottomReached: () -> Unit
|
||||
) {
|
||||
val isReached = remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
if (lastVisibleItem != null) {
|
||||
lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - thresholdItems
|
||||
} else {
|
||||
false
|
||||
}
|
||||
LaunchedEffect(this) {
|
||||
snapshotFlow {
|
||||
layoutInfo.visibleItemsInfo.firstOrNull()?.index
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isReached) {
|
||||
snapshotFlow { isReached.value }
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect { if (it) onTopReached() }
|
||||
.collect { index ->
|
||||
if (index <= thresholdItems) {
|
||||
onBottomReached()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -317,10 +313,11 @@ fun ChatScreen(
|
|||
onChatScrolledToBottom: () -> Unit,
|
||||
onScrollToReplyClicked: (Id) -> Unit,
|
||||
onClearIntent: () -> Unit,
|
||||
onScrollToBottomClicked: () -> Unit
|
||||
onScrollToBottomClicked: () -> Unit,
|
||||
onVisibleRangeChanged: (Id, Id) -> Unit
|
||||
) {
|
||||
|
||||
Timber.d("DROID-2966 Render called with state")
|
||||
Timber.d("DROID-2966 Render called with state, number of messages: ${uiMessageState.messages.size}")
|
||||
|
||||
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
|
@ -332,49 +329,35 @@ fun ChatScreen(
|
|||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val latestMessages by rememberUpdatedState(uiMessageState.messages)
|
||||
|
||||
val isPerformingScrollIntent = remember { mutableStateOf(false) }
|
||||
|
||||
// Applying view model intents
|
||||
LaunchedEffect(uiMessageState.intent) {
|
||||
when (val intent = uiMessageState.intent) {
|
||||
is ChatContainer.Intent.ScrollToMessage -> {
|
||||
isPerformingScrollIntent.value = true
|
||||
val index = uiMessageState.messages.indexOfFirst {
|
||||
it is ChatView.Message && it.id == intent.id
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
Timber.d("DROID-2966 Waiting for layout to stabilize...")
|
||||
|
||||
snapshotFlow { lazyListState.layoutInfo.totalItemsCount }
|
||||
.first { it > index }
|
||||
|
||||
lazyListState.scrollToItem(index)
|
||||
|
||||
lazyListState.animateScrollToItem(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!")
|
||||
}
|
||||
} else {
|
||||
Timber.d("DROID-2966 COMPOSE Could not find the scrolling target for the intent")
|
||||
}
|
||||
onClearIntent()
|
||||
isPerformingScrollIntent.value = false
|
||||
}
|
||||
is ChatContainer.Intent.ScrollToBottom -> {
|
||||
Timber.d("DROID-2966 COMPOSE scroll to bottom")
|
||||
isPerformingScrollIntent.value = true
|
||||
smoothScrollToBottom(lazyListState)
|
||||
awaitFrame()
|
||||
isPerformingScrollIntent.value = false
|
||||
onClearIntent()
|
||||
}
|
||||
is ChatContainer.Intent.Highlight -> {
|
||||
|
@ -384,16 +367,53 @@ fun ChatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
lazyListState.OnBottomReached(
|
||||
thresholdItems = 3
|
||||
) {
|
||||
onChatScrolledToBottom()
|
||||
// Tracking visible range
|
||||
LaunchedEffect(lazyListState) {
|
||||
snapshotFlow { lazyListState.layoutInfo }
|
||||
.mapNotNull { layoutInfo ->
|
||||
val viewportHeight = layoutInfo.viewportSize.height
|
||||
val visibleMessages = layoutInfo.visibleItemsInfo
|
||||
.filter { item ->
|
||||
val itemBottom = item.offset + item.size
|
||||
val isFullyVisible = item.offset >= 0 && itemBottom <= viewportHeight
|
||||
isFullyVisible
|
||||
}
|
||||
.sortedBy { it.index } // still necessary
|
||||
.mapNotNull { item -> latestMessages.getOrNull(item.index) }
|
||||
.filterIsInstance<ChatView.Message>()
|
||||
|
||||
if (visibleMessages.isNotEmpty()) {
|
||||
// TODO could be optimised by passing order ID
|
||||
visibleMessages.first().id to visibleMessages.last().id
|
||||
} else null
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (from, to) ->
|
||||
onVisibleRangeChanged(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
lazyListState.OnTopReached(
|
||||
thresholdItems = 3
|
||||
) {
|
||||
onChatScrolledToTop()
|
||||
// Scrolling to bottom when list size changes and we are at the bottom of the list
|
||||
LaunchedEffect(latestMessages) {
|
||||
if (lazyListState.firstVisibleItemScrollOffset == 0 && !isPerformingScrollIntent.value) {
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lazyListState.OnBottomReachedSafely {
|
||||
if (!isPerformingScrollIntent.value && latestMessages.isNotEmpty()) {
|
||||
Timber.d("DROID-2966 Safe onBottomReached dispatched from compose to VM")
|
||||
onChatScrolledToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
lazyListState.OnTopReachedSafely {
|
||||
if (!isPerformingScrollIntent.value && latestMessages.isNotEmpty()) {
|
||||
Timber.d("DROID-2966 Safe onTopReached dispatched from compose to VM")
|
||||
onChatScrolledToTop()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
|
@ -453,6 +473,29 @@ fun ChatScreen(
|
|||
enabled = jumpToBottomButtonEnabled
|
||||
)
|
||||
|
||||
if (uiMessageState.counter.count > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.padding(bottom = 46.dp, end = 2.dp)
|
||||
.background(
|
||||
color = colorResource(R.color.transparent_active),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = uiMessageState.counter.count.toString(),
|
||||
modifier = Modifier.align(Alignment.Center).padding(
|
||||
horizontal = 5.dp,
|
||||
vertical = 2.dp
|
||||
),
|
||||
color = colorResource(R.color.glyph_white),
|
||||
style = Caption1Regular
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when(mentionPanelState) {
|
||||
MentionPanelState.Hidden -> {
|
||||
// Draw nothing.
|
||||
|
@ -597,7 +640,7 @@ fun Messages(
|
|||
onMentionClicked: (Id) -> Unit,
|
||||
onScrollToReplyClicked: (Id) -> Unit,
|
||||
) {
|
||||
Timber.d("DROID-2966 Messages composition: ${messages.map { if (it is ChatView.Message) it.content.msg else it }}")
|
||||
// Timber.d("DROID-2966 Messages composition: ${messages.map { if (it is ChatView.Message) it.content.msg else it }}")
|
||||
val scope = rememberCoroutineScope()
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
|
@ -783,13 +826,16 @@ 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.scrollToItem(0)
|
||||
|
||||
// Wait for the layout to settle after scrolling
|
||||
awaitFrame()
|
||||
|
||||
while (lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset > 0) {
|
||||
val offset = lazyListState.firstVisibleItemScrollOffset
|
||||
val delta = (-offset).coerceAtLeast(-80)
|
||||
lazyListState.animateScrollBy(delta.toFloat())
|
||||
awaitFrame() // Yield to UI again
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1023,6 +1023,10 @@ class BlockMiddleware(
|
|||
middleware.chatEditMessageContent(command)
|
||||
}
|
||||
|
||||
override suspend fun readChatMessages(command: Command.ChatCommand.ReadMessages) {
|
||||
middleware.chatReadMessages(command)
|
||||
}
|
||||
|
||||
override suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage) {
|
||||
middleware.chatDeleteMessage(command)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.anytypeio.anytype.middleware.interactor
|
||||
|
||||
import anytype.Rpc
|
||||
import anytype.Rpc.Chat.ReadMessages.ReadType
|
||||
import anytype.model.Block
|
||||
import anytype.model.ParticipantPermissionChange
|
||||
import anytype.model.Range
|
||||
|
@ -2786,7 +2787,8 @@ class Middleware @Inject constructor(
|
|||
chatObjectId = command.chat,
|
||||
beforeOrderId = command.beforeOrderId.orEmpty(),
|
||||
afterOrderId = command.afterOrderId.orEmpty(),
|
||||
limit = command.limit
|
||||
limit = command.limit,
|
||||
includeBoundary = command.includeBoundary
|
||||
)
|
||||
logRequestIfDebug(request)
|
||||
val (response, time) = measureTimedValue { service.chatGetMessages(request) }
|
||||
|
@ -2809,6 +2811,20 @@ class Middleware @Inject constructor(
|
|||
return response.messages.map { it.core() }
|
||||
}
|
||||
|
||||
@Throws
|
||||
fun chatReadMessages(command: Command.ChatCommand.ReadMessages) {
|
||||
val request = Rpc.Chat.ReadMessages.Request(
|
||||
chatObjectId = command.chat,
|
||||
afterOrderId = command.afterOrderId.orEmpty(),
|
||||
beforeOrderId = command.beforeOrderId.orEmpty(),
|
||||
lastStateId = command.lastStateId.orEmpty(),
|
||||
type = ReadType.Messages
|
||||
)
|
||||
logRequestIfDebug(request)
|
||||
val (response, time) = measureTimedValue { service.chatReadMessages(request) }
|
||||
logResponseIfDebug(response, time)
|
||||
}
|
||||
|
||||
@Throws
|
||||
fun chatDeleteMessage(command: Command.ChatCommand.DeleteMessage) {
|
||||
val request = Rpc.Chat.DeleteMessage.Request(
|
||||
|
|
|
@ -57,9 +57,6 @@ class MiddlewareEventChannel(
|
|||
blockDataviewIsCollectionSet != null -> true
|
||||
blockSetWidget != null -> true
|
||||
spaceAutoWidgetAdded != null -> true
|
||||
chatStateUpdate != null -> true
|
||||
chatUpdateMentionReadStatus != null -> true
|
||||
chatUpdateMessageReadStatus != null -> true
|
||||
else -> false.also {
|
||||
if (featureToggles.isLogMiddlewareInteraction)
|
||||
Timber.w("Ignored event: $this")
|
||||
|
|
|
@ -38,7 +38,32 @@ fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? {
|
|||
message = requireNotNull(event.message?.core())
|
||||
)
|
||||
}
|
||||
|
||||
chatStateUpdate != null -> {
|
||||
val event = chatStateUpdate
|
||||
checkNotNull(event)
|
||||
Event.Command.Chats.UpdateState(
|
||||
context = contextId,
|
||||
state = event.state?.core()
|
||||
)
|
||||
}
|
||||
chatUpdateMessageReadStatus != null -> {
|
||||
val event = chatUpdateMessageReadStatus
|
||||
checkNotNull(event)
|
||||
Event.Command.Chats.UpdateMessageReadStatus(
|
||||
context = contextId,
|
||||
messages = event.ids,
|
||||
isRead = event.isRead
|
||||
)
|
||||
}
|
||||
chatUpdateMentionReadStatus != null -> {
|
||||
val event = chatUpdateMentionReadStatus
|
||||
checkNotNull(event)
|
||||
Event.Command.Chats.UpdateMentionReadStatus(
|
||||
context = contextId,
|
||||
messages = event.ids,
|
||||
isRead = event.isRead
|
||||
)
|
||||
}
|
||||
chatUpdate != null -> {
|
||||
val event = chatUpdate
|
||||
checkNotNull(event)
|
||||
|
@ -48,7 +73,6 @@ fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? {
|
|||
message = requireNotNull(event.message?.core())
|
||||
)
|
||||
}
|
||||
|
||||
chatDelete != null -> {
|
||||
val event = chatDelete
|
||||
checkNotNull(event)
|
||||
|
@ -57,7 +81,6 @@ fun MEventMessage.payload(contextId: Id) : Event.Command.Chats? {
|
|||
id = event.id
|
||||
)
|
||||
}
|
||||
|
||||
chatUpdateReactions != null -> {
|
||||
val event = chatUpdateReactions
|
||||
checkNotNull(event)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue