1
0
Fork 0
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:
Evgenii Kozlov 2025-05-02 17:10:30 +02:00 committed by GitHub
parent e2f311f781
commit cc2b2c95a3
Signed by: github
GPG key ID: B5690EEEBB952194
15 changed files with 416 additions and 145 deletions

View file

@ -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>,

View file

@ -321,6 +321,7 @@ sealed class Event {
}
sealed class Chats : Command() {
data class Add(
override val context: Id,
val id: Id,

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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>

View file

@ -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>

View file

@ -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
)

View file

@ -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
)
}

View file

@ -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.
*/

View file

@ -198,7 +198,8 @@ fun ChatScreenPreview() {
onChatScrolledToBottom = {},
onScrollToReplyClicked = {},
onClearIntent = {},
onScrollToBottomClicked = {}
onScrollToBottomClicked = {},
onVisibleRangeChanged = { _, _ -> }
)
}

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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(

View file

@ -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")

View file

@ -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)