11package com.nextcloud.talk.ui.chat
22
33import android.util.Log
4+ import androidx.compose.animation.AnimatedVisibility
5+ import androidx.compose.animation.core.animateFloatAsState
6+ import androidx.compose.animation.core.tween
7+ import androidx.compose.animation.fadeIn
8+ import androidx.compose.animation.fadeOut
9+ import androidx.compose.animation.scaleIn
10+ import androidx.compose.animation.scaleOut
411import androidx.compose.foundation.ExperimentalFoundationApi
512import androidx.compose.foundation.background
613import androidx.compose.foundation.layout.Arrangement
@@ -11,21 +18,32 @@ import androidx.compose.foundation.layout.Spacer
1118import androidx.compose.foundation.layout.fillMaxSize
1219import androidx.compose.foundation.layout.fillMaxWidth
1320import androidx.compose.foundation.layout.padding
21+ import androidx.compose.foundation.layout.size
1422import androidx.compose.foundation.lazy.LazyColumn
1523import androidx.compose.foundation.lazy.items
1624import androidx.compose.foundation.lazy.rememberLazyListState
25+ import androidx.compose.foundation.shape.CircleShape
1726import androidx.compose.foundation.shape.RoundedCornerShape
27+ import androidx.compose.material.icons.Icons
28+ import androidx.compose.material.icons.filled.KeyboardArrowDown
29+ import androidx.compose.material3.Icon
30+ import androidx.compose.material3.MaterialTheme
1831import androidx.compose.material3.MaterialTheme.colorScheme
32+ import androidx.compose.material3.Surface
1933import androidx.compose.material3.Text
2034import androidx.compose.runtime.Composable
2135import androidx.compose.runtime.LaunchedEffect
2236import androidx.compose.runtime.MutableState
37+ import androidx.compose.runtime.derivedStateOf
38+ import androidx.compose.runtime.getValue
2339import androidx.compose.runtime.mutableStateOf
2440import androidx.compose.runtime.remember
2541import androidx.compose.runtime.rememberCoroutineScope
42+ import androidx.compose.runtime.setValue
2643import androidx.compose.runtime.snapshotFlow
2744import androidx.compose.ui.Alignment
2845import androidx.compose.ui.Modifier
46+ import androidx.compose.ui.draw.alpha
2947import androidx.compose.ui.draw.shadow
3048import androidx.compose.ui.graphics.Color
3149import androidx.compose.ui.platform.LocalResources
@@ -36,7 +54,6 @@ import com.nextcloud.talk.chat.UnreadMessagesPopup
3654import com.nextcloud.talk.chat.data.model.ChatMessage
3755import com.nextcloud.talk.chat.viewmodels.ChatViewModel
3856import com.nextcloud.talk.data.user.model.User
39- import kotlinx.coroutines.Dispatchers
4057import kotlinx.coroutines.delay
4158import kotlinx.coroutines.flow.distinctUntilChanged
4259import kotlinx.coroutines.flow.map
@@ -47,8 +64,6 @@ import java.time.ZoneId
4764import java.time.format.DateTimeFormatter
4865
4966private const val LONG_1000 = 1000L
50- private const val SCROLL_DELAY = 20L
51- private const val ANIMATION_DURATION = 2500L
5267private val AUTHOR_TEXT_SIZE = 12 .sp
5368
5469@OptIn(ExperimentalFoundationApi ::class )
@@ -59,7 +74,6 @@ fun GetNewChatView(
5974 onLoadMore : (() -> Unit? )?
6075) {
6176 val listState = rememberLazyListState()
62- val displayedChatItems = remember(chatItems) { chatItems }
6377 val showUnreadPopup = remember { mutableStateOf(false ) }
6478 val coroutineScope = rememberCoroutineScope()
6579
@@ -69,15 +83,28 @@ fun GetNewChatView(
6983 }
7084 }
7185
86+ val isAtNewest by remember(listState) {
87+ derivedStateOf {
88+ listState.firstVisibleItemIndex == 0 &&
89+ listState.firstVisibleItemScrollOffset == 0
90+ }
91+ }
92+
93+ val showScrollToNewest by remember {
94+ derivedStateOf { ! isAtNewest }
95+ }
96+
7297 LaunchedEffect (chatItems) {
7398 if (chatItems.isEmpty()) return @LaunchedEffect
7499
75- val newestId = chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id }
100+ val newestId =
101+ chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id }
76102
77103 val previousNewestId = lastNewestIdRef.value
78104
79105 val isNearBottom = listState.firstVisibleItemIndex <= 2
80- val hasNewMessage = previousNewestId != null && newestId != previousNewestId
106+ val hasNewMessage =
107+ previousNewestId != null && newestId != previousNewestId
81108
82109 if (hasNewMessage) {
83110 if (isNearBottom) {
@@ -94,54 +121,92 @@ fun GetNewChatView(
94121 snapshotFlow { listState.firstVisibleItemIndex }
95122 .map { it <= 2 }
96123 .distinctUntilChanged()
97- .collect { isNearBottom ->
98- if (isNearBottom) {
99- showUnreadPopup.value = false
100- }
124+ .collect { nearBottom ->
125+ if (nearBottom) showUnreadPopup.value = false
101126 }
102127 }
103128
104129 LaunchedEffect (listState, chatItems.size) {
105130 snapshotFlow {
106131 val layoutInfo = listState.layoutInfo
107132 val total = layoutInfo.totalItemsCount
108- val lastVisible =
109- layoutInfo.visibleItemsInfo.lastOrNull()?.index ? : 0
110-
133+ val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ? : 0
111134 lastVisible to total
112135 }
113136 .distinctUntilChanged()
114137 .collect { (lastVisible, total) ->
115138 if (total == 0 ) return @collect
116139
117140 val buffer = 5
118- val shouldLoadMore =
119- lastVisible >= (total - 1 - buffer)
141+ val shouldLoadMore = lastVisible >= (total - 1 - buffer)
120142
121143 if (shouldLoadMore) {
122144 onLoadMore?.invoke()
123145 }
124146 }
125147 }
126148
149+ val stickyDateHeaderText by remember(listState, chatItems) {
150+ derivedStateOf {
151+ chatItems.getOrNull(
152+ listState.layoutInfo.visibleItemsInfo
153+ .lastOrNull()
154+ ?.index ? : 0
155+ )?.let { item ->
156+ when (item) {
157+ is ChatViewModel .ChatItem .MessageItem ->
158+ formatTime(item.message.timestamp * LONG_1000 )
159+
160+ is ChatViewModel .ChatItem .DateHeaderItem ->
161+ formatTime(item.date)
162+ }
163+ } ? : " "
164+ }
165+ }
166+
167+ var stickyDateHeader by remember { mutableStateOf(false ) }
168+
169+ LaunchedEffect (listState) {
170+ snapshotFlow { listState.isScrollInProgress }
171+ .collect { scrolling ->
172+ if (scrolling) {
173+ stickyDateHeader = true
174+ } else {
175+ delay(1200 )
176+ stickyDateHeader = false
177+ }
178+ }
179+ }
180+
181+ val stickyDateHeaderAlpha by animateFloatAsState(
182+ targetValue = if (stickyDateHeader && stickyDateHeaderText.isNotEmpty()) 1f else 0f ,
183+ animationSpec = tween(
184+ durationMillis = if (stickyDateHeader) 500 else 1000
185+ ),
186+ label = " "
187+ )
188+
127189 Box (modifier = Modifier .fillMaxSize()) {
128190 LazyColumn (
129191 state = listState,
130- verticalArrangement = Arrangement .spacedBy(8 .dp),
131192 reverseLayout = true ,
193+ verticalArrangement = Arrangement .spacedBy(8 .dp),
132194 modifier = Modifier
133- .padding(16 .dp)
195+ .padding(
196+ start = 12 .dp,
197+ end = 12 .dp
198+ )
134199 .fillMaxSize()
135200 ) {
136201 items(
137- displayedChatItems,
138- key = {
139- it.stableKey()
140- }
202+ chatItems,
203+ key = { it.stableKey() }
141204 ) { chatItem ->
205+
142206 when (chatItem) {
143207 is ChatViewModel .ChatItem .MessageItem -> {
144- val isBlinkingState = remember { mutableStateOf(false ) }
208+ val isBlinkingState =
209+ remember { mutableStateOf(false ) }
145210
146211 GetComposableForMessage (
147212 message = chatItem.message,
@@ -157,11 +222,28 @@ fun GetNewChatView(
157222 }
158223 }
159224
225+ Surface (
226+ modifier = Modifier
227+ .align(Alignment .TopCenter )
228+ .padding(top = 12 .dp)
229+ .alpha(stickyDateHeaderAlpha),
230+ shape = RoundedCornerShape (16 .dp),
231+ tonalElevation = 2 .dp
232+ ) {
233+ Text (
234+ stickyDateHeaderText,
235+ modifier = Modifier .padding(
236+ horizontal = 12 .dp,
237+ vertical = 6 .dp
238+ )
239+ )
240+ }
241+
160242 if (showUnreadPopup.value) {
161243 UnreadMessagesPopup (
162244 onClick = {
163245 coroutineScope.launch {
164- listState.animateScrollToItem (0 )
246+ listState.scrollToItem (0 )
165247 }
166248 showUnreadPopup.value = false
167249 },
@@ -170,6 +252,35 @@ fun GetNewChatView(
170252 .padding(bottom = 20 .dp)
171253 )
172254 }
255+
256+ AnimatedVisibility (
257+ visible = showScrollToNewest,
258+ modifier = Modifier
259+ .align(Alignment .BottomEnd )
260+ .padding(end = 16 .dp, bottom = 24 .dp),
261+ enter = fadeIn() + scaleIn(),
262+ exit = fadeOut() + scaleOut()
263+ ) {
264+ Surface (
265+ onClick = {
266+ coroutineScope.launch {
267+ listState.scrollToItem(0 )
268+ }
269+ },
270+ shape = CircleShape ,
271+ color = colorScheme.surface.copy(alpha = 0.9f ),
272+ tonalElevation = 2 .dp
273+ ) {
274+ Icon (
275+ imageVector = Icons .Default .KeyboardArrowDown ,
276+ contentDescription = " Scroll to newest" ,
277+ modifier = Modifier
278+ .size(36 .dp)
279+ .padding(8 .dp),
280+ tint = colorScheme.onSurface.copy(alpha = 0.9f )
281+ )
282+ }
283+ }
173284 }
174285}
175286
@@ -199,6 +310,7 @@ fun DateHeader(date: LocalDate) {
199310 }
200311}
201312
313+ @Deprecated(" do not use Compose Chat Adapter" )
202314@OptIn(ExperimentalFoundationApi ::class )
203315@Composable
204316fun GetView (messages : List <ChatMessage >, messageIdToBlink : String , user : User ? ) {
@@ -258,21 +370,6 @@ fun GetView(messages: List<ChatMessage>, messageIdToBlink: String, user: User?)
258370 )
259371 }
260372 }
261-
262- if (messages.isNotEmpty()) {
263- LaunchedEffect (Dispatchers .Main ) {
264- delay(SCROLL_DELAY )
265- val pos = searchMessages(
266- messages,
267- messageIdToBlink
268- )
269- if (pos > 0 ) {
270- listState.scrollToItem(pos)
271- }
272- delay(ANIMATION_DURATION )
273- isBlinkingState.value = false
274- }
275- }
276373}
277374
278375@Composable
@@ -364,13 +461,15 @@ private fun ChatMessage.shouldFilter(): Boolean =
364461fun formatTime (timestampMillis : Long ): String {
365462 val instant = Instant .ofEpochMilli(timestampMillis)
366463 val dateTime = instant.atZone(ZoneId .systemDefault()).toLocalDate()
367- val formatter = DateTimeFormatter .ofPattern(" MMM dd, yyyy" )
368- return dateTime.format(formatter)
464+ return formatTime(dateTime)
369465}
370466
371- fun searchMessages (messages : List <ChatMessage >, searchId : String ): Int {
372- messages.forEachIndexed { index, message ->
373- if (message.id == searchId) return index
467+ fun formatTime (localDate : LocalDate ): String {
468+ val formatter = DateTimeFormatter .ofPattern(" MMM dd, yyyy" )
469+ val text = when (localDate) {
470+ LocalDate .now() -> " Today"
471+ LocalDate .now().minusDays(1 ) -> " Yesterday"
472+ else -> localDate.format(formatter)
374473 }
375- return - 1
474+ return text
376475}
0 commit comments