Skip to content

Commit 6742c7b

Browse files
Merge pull request #5989 from nextcloud/chore/noid/migrateConvListPartsToComposables
⚒️🎨 Migrate conversation list to Composables
2 parents bede5d5 + 5147916 commit 6742c7b

39 files changed

Lines changed: 5129 additions & 3031 deletions

app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public void login() throws InterruptedException {
6767

6868
try {
6969
// Delete account if exists
70-
onView(withId(R.id.switch_account_button)).perform(click());
70+
onView(withContentDescription(R.string.nc_settings)).perform(click());
7171
onView(withId(R.id.settings_remove_account)).perform(click());
7272
onView(withText(R.string.nc_settings_remove)).perform(click());
7373
// The remove button must be clicked two times
@@ -120,7 +120,7 @@ public void login() throws InterruptedException {
120120

121121
Thread.sleep(5 * 1000);
122122

123-
onView(withId(R.id.switch_account_button)).perform(click());
123+
onView(withContentDescription(R.string.nc_settings)).perform(click());
124124
onView(withId(R.id.user_name)).check(matches(withText("User One")));
125125

126126
activityScenario.onActivity(activity -> {

app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt

Lines changed: 0 additions & 531 deletions
This file was deleted.

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

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

0 commit comments

Comments
 (0)