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
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue