Skip to content

Commit bc72e8c

Browse files
feat(conv-list): Create conversation list Composable
AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent d684f02 commit bc72e8c

9 files changed

Lines changed: 1627 additions & 1409 deletions

File tree

app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt

Lines changed: 241 additions & 589 deletions
Large diffs are not rendered by default.
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package com.nextcloud.talk.conversationlist.ui
8+
9+
import androidx.compose.foundation.clickable
10+
import androidx.compose.foundation.layout.Box
11+
import androidx.compose.foundation.layout.Column
12+
import androidx.compose.foundation.layout.Row
13+
import androidx.compose.foundation.layout.Spacer
14+
import androidx.compose.foundation.layout.fillMaxSize
15+
import androidx.compose.foundation.layout.fillMaxWidth
16+
import androidx.compose.foundation.layout.padding
17+
import androidx.compose.foundation.layout.size
18+
import androidx.compose.foundation.layout.width
19+
import androidx.compose.foundation.lazy.LazyColumn
20+
import androidx.compose.foundation.lazy.LazyListState
21+
import androidx.compose.foundation.lazy.items
22+
import androidx.compose.foundation.lazy.rememberLazyListState
23+
import androidx.compose.foundation.shape.CircleShape
24+
import androidx.compose.material3.ExperimentalMaterial3Api
25+
import androidx.compose.material3.MaterialTheme
26+
import androidx.compose.material3.Text
27+
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
28+
import androidx.compose.runtime.Composable
29+
import androidx.compose.runtime.LaunchedEffect
30+
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.mutableIntStateOf
32+
import androidx.compose.runtime.remember
33+
import androidx.compose.runtime.setValue
34+
import androidx.compose.runtime.snapshotFlow
35+
import androidx.compose.ui.Alignment
36+
import androidx.compose.ui.Modifier
37+
import androidx.compose.ui.draw.clip
38+
import androidx.compose.ui.graphics.Color
39+
import androidx.compose.ui.platform.LocalContext
40+
import androidx.compose.ui.res.colorResource
41+
import androidx.compose.ui.res.painterResource
42+
import androidx.compose.ui.res.stringResource
43+
import androidx.compose.ui.text.AnnotatedString
44+
import androidx.compose.ui.text.SpanStyle
45+
import androidx.compose.ui.text.buildAnnotatedString
46+
import androidx.compose.ui.text.font.FontWeight
47+
import androidx.compose.ui.text.style.TextOverflow
48+
import androidx.compose.ui.text.withStyle
49+
import androidx.compose.ui.unit.dp
50+
import coil.compose.AsyncImage
51+
import coil.request.ImageRequest
52+
import coil.transform.CircleCropTransformation
53+
import com.nextcloud.talk.R
54+
import com.nextcloud.talk.data.user.model.User
55+
import com.nextcloud.talk.models.domain.ConversationModel
56+
import com.nextcloud.talk.models.domain.SearchMessageEntry
57+
import com.nextcloud.talk.models.json.participants.Participant
58+
import com.nextcloud.talk.utils.ApiUtils
59+
60+
private const val MSG_KEY_EXCERPT_LENGTH = 20
61+
62+
/**
63+
* The full conversation list: pull-to-refresh + LazyColumn.
64+
* Replaces RecyclerView + FlexibleAdapter + SwipeRefreshLayout.
65+
*/
66+
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
67+
@OptIn(ExperimentalMaterial3Api::class)
68+
@Composable
69+
fun ConversationList(
70+
entries: List<ConversationListEntry>,
71+
isRefreshing: Boolean,
72+
currentUser: User,
73+
credentials: String,
74+
onConversationClick: (ConversationModel) -> Unit,
75+
onConversationLongClick: (ConversationModel) -> Unit,
76+
onMessageResultClick: (SearchMessageEntry) -> Unit,
77+
onContactClick: (Participant) -> Unit,
78+
onLoadMoreClick: () -> Unit,
79+
onRefresh: () -> Unit,
80+
searchQuery: String = "",
81+
/** Called whenever scroll direction changes; true = scrolled down, false = scrolled up. */
82+
onScrollChanged: (scrolledDown: Boolean) -> Unit = {},
83+
/** Called when the list stops scrolling; delivers the last-visible item index. */
84+
onScrollStopped: (lastVisibleIndex: Int) -> Unit = {},
85+
listState: LazyListState = rememberLazyListState()
86+
) {
87+
var prevIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) }
88+
var prevOffset by remember { mutableIntStateOf(listState.firstVisibleItemScrollOffset) }
89+
LaunchedEffect(listState) {
90+
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
91+
.collect { (index, offset) ->
92+
if (index != prevIndex || offset != prevOffset) {
93+
val scrolledDown = index > prevIndex || (index == prevIndex && offset > prevOffset)
94+
onScrollChanged(scrolledDown)
95+
prevIndex = index
96+
prevOffset = offset
97+
}
98+
}
99+
}
100+
101+
// Unread-bubble: notify Activity when scrolling stops
102+
LaunchedEffect(listState) {
103+
snapshotFlow { listState.isScrollInProgress }
104+
.collect { isScrolling ->
105+
if (!isScrolling) {
106+
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
107+
onScrollStopped(lastVisible)
108+
}
109+
}
110+
}
111+
112+
PullToRefreshBox(
113+
isRefreshing = isRefreshing,
114+
onRefresh = onRefresh,
115+
modifier = Modifier.fillMaxSize()
116+
) {
117+
LazyColumn(
118+
state = listState,
119+
modifier = Modifier.fillMaxSize()
120+
) {
121+
items(
122+
items = entries,
123+
key = { entry ->
124+
when (entry) {
125+
is ConversationListEntry.Header ->
126+
"header_${entry.title}"
127+
is ConversationListEntry.ConversationEntry ->
128+
"conv_${entry.model.token}"
129+
is ConversationListEntry.MessageResultEntry ->
130+
"msg_${entry.result.conversationToken}_" +
131+
"${entry.result.messageId ?: entry.result.messageExcerpt.take(MSG_KEY_EXCERPT_LENGTH)}"
132+
is ConversationListEntry.ContactEntry ->
133+
"contact_${entry.participant.actorId}_${entry.participant.actorType}"
134+
ConversationListEntry.LoadMore ->
135+
"load_more"
136+
}
137+
}
138+
) { entry ->
139+
when (entry) {
140+
is ConversationListEntry.Header ->
141+
ConversationSectionHeader(title = entry.title)
142+
143+
is ConversationListEntry.ConversationEntry ->
144+
ConversationListItem(
145+
model = entry.model,
146+
currentUser = currentUser,
147+
callbacks = ConversationListItemCallbacks(
148+
onClick = { onConversationClick(entry.model) },
149+
onLongClick = { onConversationLongClick(entry.model) }
150+
),
151+
searchQuery = searchQuery
152+
)
153+
154+
is ConversationListEntry.MessageResultEntry ->
155+
MessageResultListItem(
156+
result = entry.result,
157+
credentials = credentials,
158+
onClick = { onMessageResultClick(entry.result) }
159+
)
160+
161+
is ConversationListEntry.ContactEntry ->
162+
ContactResultListItem(
163+
participant = entry.participant,
164+
currentUser = currentUser,
165+
credentials = credentials,
166+
searchQuery = searchQuery,
167+
onClick = { onContactClick(entry.participant) }
168+
)
169+
170+
ConversationListEntry.LoadMore ->
171+
LoadMoreListItem(onClick = onLoadMoreClick)
172+
}
173+
}
174+
}
175+
}
176+
}
177+
178+
@Composable
179+
private fun ConversationSectionHeader(title: String) {
180+
Text(
181+
text = title,
182+
style = MaterialTheme.typography.titleSmall,
183+
color = MaterialTheme.colorScheme.primary,
184+
modifier = Modifier
185+
.fillMaxWidth()
186+
.padding(horizontal = 16.dp, vertical = 8.dp)
187+
)
188+
}
189+
190+
@Composable
191+
private fun MessageResultListItem(result: SearchMessageEntry, credentials: String, onClick: () -> Unit) {
192+
val primaryColor = MaterialTheme.colorScheme.primary
193+
Row(
194+
modifier = Modifier
195+
.fillMaxWidth()
196+
.clickable { onClick() }
197+
.padding(horizontal = 16.dp, vertical = 8.dp),
198+
verticalAlignment = Alignment.CenterVertically
199+
) {
200+
AsyncImage(
201+
model = ImageRequest.Builder(LocalContext.current)
202+
.data(result.thumbnailURL)
203+
.addHeader("Authorization", credentials)
204+
.crossfade(true)
205+
.transformations(CircleCropTransformation())
206+
.build(),
207+
contentDescription = null,
208+
modifier = Modifier
209+
.size(48.dp)
210+
.clip(CircleShape),
211+
placeholder = painterResource(R.drawable.ic_user),
212+
error = painterResource(R.drawable.ic_user)
213+
)
214+
Spacer(modifier = Modifier.width(12.dp))
215+
Column(modifier = Modifier.weight(1f)) {
216+
Text(
217+
text = buildHighlightedText(result.title, result.searchTerm, primaryColor),
218+
style = MaterialTheme.typography.bodyLarge,
219+
fontWeight = FontWeight.Normal,
220+
maxLines = 1,
221+
overflow = TextOverflow.Ellipsis,
222+
color = colorResource(R.color.conversation_item_header)
223+
)
224+
Text(
225+
text = buildHighlightedText(result.messageExcerpt, result.searchTerm, primaryColor),
226+
style = MaterialTheme.typography.bodyMedium,
227+
color = colorResource(R.color.textColorMaxContrast),
228+
maxLines = 2,
229+
overflow = TextOverflow.Ellipsis
230+
)
231+
}
232+
}
233+
}
234+
235+
internal fun buildHighlightedText(text: String, searchTerm: String, highlightColor: Color): AnnotatedString =
236+
buildAnnotatedString {
237+
if (searchTerm.isBlank()) {
238+
append(text)
239+
return@buildAnnotatedString
240+
}
241+
val lowerText = text.lowercase()
242+
val lowerTerm = searchTerm.lowercase()
243+
var lastIndex = 0
244+
var matchIndex = lowerText.indexOf(lowerTerm, lastIndex)
245+
while (matchIndex != -1) {
246+
append(text.substring(lastIndex, matchIndex))
247+
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = highlightColor)) {
248+
append(text.substring(matchIndex, matchIndex + searchTerm.length))
249+
}
250+
lastIndex = matchIndex + searchTerm.length
251+
matchIndex = lowerText.indexOf(lowerTerm, lastIndex)
252+
}
253+
append(text.substring(lastIndex))
254+
}
255+
256+
@Composable
257+
private fun ContactResultListItem(
258+
participant: Participant,
259+
currentUser: User,
260+
credentials: String,
261+
searchQuery: String,
262+
onClick: () -> Unit
263+
) {
264+
val primaryColor = MaterialTheme.colorScheme.primary
265+
val avatarUrl = remember(currentUser.baseUrl, participant.actorId) {
266+
ApiUtils.getUrlForAvatar(currentUser.baseUrl, participant.actorId, false)
267+
}
268+
Row(
269+
modifier = Modifier
270+
.fillMaxWidth()
271+
.clickable { onClick() }
272+
.padding(horizontal = 16.dp, vertical = 8.dp),
273+
verticalAlignment = Alignment.CenterVertically
274+
) {
275+
AsyncImage(
276+
model = ImageRequest.Builder(LocalContext.current)
277+
.data(avatarUrl)
278+
.addHeader("Authorization", credentials)
279+
.crossfade(true)
280+
.transformations(CircleCropTransformation())
281+
.build(),
282+
contentDescription = participant.displayName,
283+
modifier = Modifier
284+
.size(48.dp)
285+
.clip(CircleShape),
286+
placeholder = painterResource(R.drawable.ic_user),
287+
error = painterResource(R.drawable.ic_user)
288+
)
289+
Spacer(modifier = Modifier.width(12.dp))
290+
Text(
291+
text = buildHighlightedText(participant.displayName ?: "", searchQuery, primaryColor),
292+
style = MaterialTheme.typography.bodyLarge,
293+
color = colorResource(R.color.conversation_item_header),
294+
maxLines = 1,
295+
overflow = TextOverflow.Ellipsis,
296+
modifier = Modifier.weight(1f)
297+
)
298+
}
299+
}
300+
301+
@Composable
302+
private fun LoadMoreListItem(onClick: () -> Unit) {
303+
Box(
304+
modifier = Modifier
305+
.fillMaxWidth()
306+
.clickable { onClick() }
307+
.padding(16.dp),
308+
contentAlignment = Alignment.Center
309+
) {
310+
Text(
311+
text = stringResource(R.string.load_more_results),
312+
style = MaterialTheme.typography.bodyMedium,
313+
color = MaterialTheme.colorScheme.primary
314+
)
315+
}
316+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.conversationlist.ui
9+
10+
import com.nextcloud.talk.models.domain.ConversationModel
11+
import com.nextcloud.talk.models.domain.SearchMessageEntry
12+
import com.nextcloud.talk.models.json.participants.Participant
13+
14+
/**
15+
* Sealed class that represents every possible entry in the conversation list LazyColumn.
16+
*/
17+
sealed class ConversationListEntry {
18+
/** Section header (e.g. "Conversations", "Users", "Messages") */
19+
data class Header(val title: String) : ConversationListEntry()
20+
21+
/** A single conversation item */
22+
data class ConversationEntry(val model: ConversationModel) : ConversationListEntry()
23+
24+
/** A message search result */
25+
data class MessageResultEntry(val result: SearchMessageEntry) : ConversationListEntry()
26+
27+
/** A contact / user search result */
28+
data class ContactEntry(val participant: Participant) : ConversationListEntry()
29+
30+
/** "Load more" button at the end of message search results */
31+
data object LoadMore : ConversationListEntry()
32+
}

app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import androidx.compose.animation.scaleOut
1616
import androidx.compose.animation.slideInVertically
1717
import androidx.compose.animation.slideOutVertically
1818
import androidx.compose.foundation.layout.Spacer
19+
import androidx.compose.foundation.layout.padding
1920
import androidx.compose.foundation.layout.width
2021
import androidx.compose.foundation.shape.RoundedCornerShape
2122
import androidx.compose.material3.Button
@@ -31,10 +32,12 @@ import androidx.compose.ui.res.dimensionResource
3132
import androidx.compose.ui.res.painterResource
3233
import androidx.compose.ui.res.stringResource
3334
import androidx.compose.ui.tooling.preview.Preview
35+
import androidx.compose.ui.unit.dp
3436
import com.nextcloud.talk.R
3537

3638
private const val DISABLED_ALPHA = 0.38f
3739
private const val FAB_ANIM_DURATION = 200
40+
private const val UNREAD_MENTIONS_HORIZONTAL_SPACING = 88
3841

3942
@Composable
4043
fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> Unit) {
@@ -64,6 +67,7 @@ fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit) {
6467
) {
6568
Button(
6669
onClick = onClick,
70+
modifier = Modifier.padding(horizontal = UNREAD_MENTIONS_HORIZONTAL_SPACING.dp),
6771
colors = ButtonDefaults.buttonColors(
6872
containerColor = MaterialTheme.colorScheme.primary
6973
),

0 commit comments

Comments
 (0)