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

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