Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ce51411
chore(conv-list): Migrate status banner to Composable
AndyScherzinger Mar 20, 2026
7f09ed5
chore(conv-list): Migrate empty state to Composable
AndyScherzinger Mar 20, 2026
8e81c98
chore(conv-list): Migrate FAB to Composable
AndyScherzinger Mar 20, 2026
d429bc7
fix(conv-list): use searchBehaviorSubject.value
AndyScherzinger Mar 20, 2026
011d2ea
chore(conv-list): Migrate FAB to Composable
AndyScherzinger Mar 20, 2026
487cd52
chore(conv-list): Migrate Shimmer to Composable
AndyScherzinger Mar 20, 2026
5d04940
feat(conv-list): Migrate notification warning card to Composable
AndyScherzinger Mar 21, 2026
283dcd4
feat(conv-list): Migrate federation invitation card to Composable
AndyScherzinger Mar 21, 2026
36c2094
feat(conv-list): Create conversation item Composable
AndyScherzinger Mar 21, 2026
54a6b56
feat(conv-list): Create conversation list Composable
AndyScherzinger Mar 22, 2026
ba563d9
style(conv-list): resize fab to properly show the drop shadow
AndyScherzinger Mar 22, 2026
9ae89e8
feat(conv-list): Migrate top bars to Composable
AndyScherzinger Mar 23, 2026
5763c13
feat(conv-list): Migrate conversation list screen
AndyScherzinger Mar 23, 2026
f79ed58
style(unread): center button text and lower padding between icon and …
AndyScherzinger Mar 25, 2026
46b4813
style(conv-list): Move statusBarsPadding() from top bar to list scree…
AndyScherzinger Mar 25, 2026
f64e8be
style(conv-list): Ensure shimmer is hidden before conversation items …
AndyScherzinger Mar 25, 2026
459aa64
fix(conv-list): drop list position on pull-to-refresh and account swi…
AndyScherzinger Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public void login() throws InterruptedException {

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

Thread.sleep(5 * 1000);

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

activityScenario.onActivity(activity -> {
Expand Down

This file was deleted.

1,899 changes: 422 additions & 1,477 deletions app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.conversationlist.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import com.nextcloud.talk.R
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.SearchMessageEntry
import com.nextcloud.talk.models.json.participants.Participant
import com.nextcloud.talk.utils.ApiUtils

private const val MSG_KEY_EXCERPT_LENGTH = 20

/**
* The full conversation list: pull-to-refresh + LazyColumn.
* Replaces RecyclerView + FlexibleAdapter + SwipeRefreshLayout.
*/
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationList(
entries: List<ConversationListEntry>,
isRefreshing: Boolean,
currentUser: User,
credentials: String,
onConversationClick: (ConversationModel) -> Unit,
onConversationLongClick: (ConversationModel) -> Unit,
onMessageResultClick: (SearchMessageEntry) -> Unit,
onContactClick: (Participant) -> Unit,
onLoadMoreClick: () -> Unit,
onRefresh: () -> Unit,
searchQuery: String = "",
/** Called whenever scroll direction changes; true = scrolled down, false = scrolled up. */
onScrollChanged: (scrolledDown: Boolean) -> Unit = {},
/** Called when the list stops scrolling; delivers the last-visible item index. */
onScrollStopped: (lastVisibleIndex: Int) -> Unit = {},
listState: LazyListState = rememberLazyListState(),
/** Extra bottom padding added as LazyColumn contentPadding so the last item is reachable above the nav bar. */
contentBottomPadding: Dp = 0.dp
) {
var prevIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) }
var prevOffset by remember { mutableIntStateOf(listState.firstVisibleItemScrollOffset) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
if (index != prevIndex || offset != prevOffset) {
val scrolledDown = index > prevIndex || (index == prevIndex && offset > prevOffset)
onScrollChanged(scrolledDown)
prevIndex = index
prevOffset = offset
}
}
}

// Unread-bubble: notify Activity when scrolling stops
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (!isScrolling) {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
onScrollStopped(lastVisible)
}
}
}

PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = contentBottomPadding)
) {
items(
items = entries,
key = { entry ->
when (entry) {
is ConversationListEntry.Header ->
"header_${entry.title}"
is ConversationListEntry.ConversationEntry ->
"conv_${entry.model.token}"
is ConversationListEntry.MessageResultEntry ->
"msg_${entry.result.conversationToken}_" +
"${entry.result.messageId ?: entry.result.messageExcerpt.take(MSG_KEY_EXCERPT_LENGTH)}"
is ConversationListEntry.ContactEntry ->
"contact_${entry.participant.actorId}_${entry.participant.actorType}"
ConversationListEntry.LoadMore ->
"load_more"
}
}
) { entry ->
when (entry) {
is ConversationListEntry.Header ->
ConversationSectionHeader(title = entry.title)

is ConversationListEntry.ConversationEntry ->
ConversationListItem(
model = entry.model,
currentUser = currentUser,
callbacks = ConversationListItemCallbacks(
onClick = { onConversationClick(entry.model) },
onLongClick = { onConversationLongClick(entry.model) }
),
searchQuery = searchQuery
)

is ConversationListEntry.MessageResultEntry ->
MessageResultListItem(
result = entry.result,
credentials = credentials,
onClick = { onMessageResultClick(entry.result) }
)

is ConversationListEntry.ContactEntry ->
ContactResultListItem(
participant = entry.participant,
currentUser = currentUser,
credentials = credentials,
searchQuery = searchQuery,
onClick = { onContactClick(entry.participant) }
)

ConversationListEntry.LoadMore ->
LoadMoreListItem(onClick = onLoadMoreClick)
}
}
}
}
}

@Composable
private fun ConversationSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}

@Composable
private fun MessageResultListItem(result: SearchMessageEntry, credentials: String, onClick: () -> Unit) {
val primaryColor = MaterialTheme.colorScheme.primary
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(result.thumbnailURL)
.addHeader("Authorization", credentials)
.crossfade(true)
.transformations(CircleCropTransformation())
.build(),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
placeholder = painterResource(R.drawable.ic_user),
error = painterResource(R.drawable.ic_user)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = buildHighlightedText(result.title, result.searchTerm, primaryColor),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = colorResource(R.color.conversation_item_header)
)
Text(
text = buildHighlightedText(result.messageExcerpt, result.searchTerm, primaryColor),
style = MaterialTheme.typography.bodyMedium,
color = colorResource(R.color.textColorMaxContrast),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}

internal fun buildHighlightedText(text: String, searchTerm: String, highlightColor: Color): AnnotatedString =
buildAnnotatedString {
if (searchTerm.isBlank()) {
append(text)
return@buildAnnotatedString
}
val lowerText = text.lowercase()
val lowerTerm = searchTerm.lowercase()
var lastIndex = 0
var matchIndex = lowerText.indexOf(lowerTerm, lastIndex)
while (matchIndex != -1) {
append(text.substring(lastIndex, matchIndex))
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = highlightColor)) {
append(text.substring(matchIndex, matchIndex + searchTerm.length))
}
lastIndex = matchIndex + searchTerm.length
matchIndex = lowerText.indexOf(lowerTerm, lastIndex)
}
append(text.substring(lastIndex))
}

@Composable
private fun ContactResultListItem(
participant: Participant,
currentUser: User,
credentials: String,
searchQuery: String,
onClick: () -> Unit
) {
val primaryColor = MaterialTheme.colorScheme.primary
val avatarUrl = remember(currentUser.baseUrl, participant.actorId) {
ApiUtils.getUrlForAvatar(currentUser.baseUrl, participant.actorId, false)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.addHeader("Authorization", credentials)
.crossfade(true)
.transformations(CircleCropTransformation())
.build(),
contentDescription = participant.displayName,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
placeholder = painterResource(R.drawable.ic_user),
error = painterResource(R.drawable.ic_user)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = buildHighlightedText(participant.displayName ?: "", searchQuery, primaryColor),
style = MaterialTheme.typography.bodyLarge,
color = colorResource(R.color.conversation_item_header),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}

@Composable
private fun LoadMoreListItem(onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.load_more_results),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.conversationlist.ui

import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.SearchMessageEntry
import com.nextcloud.talk.models.json.participants.Participant

/**
* Sealed class that represents every possible entry in the conversation list LazyColumn.
*/
sealed class ConversationListEntry {
/** Section header (e.g. "Conversations", "Users", "Messages") */
data class Header(val title: String) : ConversationListEntry()

/** A single conversation item */
data class ConversationEntry(val model: ConversationModel) : ConversationListEntry()

/** A message search result */
data class MessageResultEntry(val result: SearchMessageEntry) : ConversationListEntry()

/** A contact / user search result */
data class ContactEntry(val participant: Participant) : ConversationListEntry()

/** "Load more" button at the end of message search results */
data object LoadMore : ConversationListEntry()
}
Loading
Loading