1
0
Fork 0
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:
Evgenii Kozlov 2025-04-28 11:23:19 +02:00 committed by GitHub
parent e0f34a3177
commit bdfc2734e0
Signed by: github
GPG key ID: B5690EEEBB952194
9 changed files with 451 additions and 174 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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