Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
Expand Down Expand Up @@ -89,11 +88,8 @@ import io.element.android.libraries.recentemojis.api.AddRecentEmoji
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
Expand Down Expand Up @@ -168,12 +164,6 @@ class MessagesPresenter(
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
val threadsList by produceState(persistentListOf()) {
room.threadsListService.subscribeToItemUpdates()
.onStart { room.threadsListService.paginate() }
.collectLatest { value = it.toImmutableList() }
}

val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()

Expand Down Expand Up @@ -323,7 +313,7 @@ class MessagesPresenter(
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
threads = Threads(
hasThreads = canOpenThreadList && threadsList.isNotEmpty(),
hasThreads = canOpenThreadList,
Comment thread
bxdxnn marked this conversation as resolved.
Outdated
// TODO calculate this properly based on the thread list and the read state of each thread
hasUnreadThreads = false,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,29 @@ package io.element.android.features.messages.impl.threads.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.threads.ThreadListDiff
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber

@Inject
Expand All @@ -34,53 +41,53 @@ class ThreadsListPresenter(
private val timelineItemContentFactory: TimelineItemContentFactory,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<ThreadsListState> {
@Composable
override fun present(): ThreadsListState {
val coroutineScope = rememberCoroutineScope()
val threadsListService = room.threadsListService

var hasLoadedFirstPage by remember { mutableStateOf(false) }

val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) {
threadsListService.subscribeToItemUpdates()
var isFirstDiff = true
Comment thread
bxdxnn marked this conversation as resolved.
Outdated
val rows = mutableListOf<ThreadListRowItem>()
threadsListService.subscribeToItemDiffs()
.onStart { threadsListService.paginate() }
.collect { items ->
Timber.d("Received thread list update with ${items.size} items")
value = items.map { item ->
val rootTimelineEvent = item.rootEvent.content?.let {
timelineItemContentFactory.create(
itemContent = it,
eventId = item.rootEvent.eventId,
isEditable = false,
sender = item.rootEvent.senderId,
senderProfile = item.rootEvent.senderProfile,
)
}
val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) }

val latestTimelineEvent = item.latestEvent?.content?.let {
timelineItemContentFactory.create(
itemContent = it,
eventId = item.latestEvent!!.eventId,
isEditable = false,
sender = item.latestEvent!!.senderId,
senderProfile = item.latestEvent!!.senderProfile,
)
.collect { diffs ->
Timber.d("Received thread list diffs: $diffs")
Comment thread
bxdxnn marked this conversation as resolved.
Outdated
withContext(coroutineDispatchers.computation) {
for (diff in diffs) {
when (diff) {
is ThreadListDiff.Reset -> {
rows.clear()
rows.addAll(diff.values.map { it.toRowItem() })
}
is ThreadListDiff.Append -> {
rows.addAll(diff.values.map { it.toRowItem() })
}
is ThreadListDiff.Clear -> rows.clear()
is ThreadListDiff.Insert -> {
rows.add(diff.index, diff.value.toRowItem())
}
is ThreadListDiff.Set -> {
rows[diff.index] = diff.value.toRowItem()
}
is ThreadListDiff.Remove -> rows.removeAt(diff.index)
is ThreadListDiff.PushBack -> rows.add(diff.value.toRowItem())
is ThreadListDiff.PushFront -> rows.add(0, diff.value.toRowItem())
is ThreadListDiff.PopBack -> if (rows.isNotEmpty()) rows.removeAt(rows.lastIndex)
is ThreadListDiff.PopFront -> if (rows.isNotEmpty()) rows.removeAt(0)
is ThreadListDiff.Truncate -> if (diff.length < rows.size) rows.subList(diff.length, rows.size).clear()
}
}
val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) }

val formattedTimestamp = dateFormatter.format(
timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
)

ThreadListRowItem(
item = item,
rootEventText = rootEventText,
latestEventText = latestEventText,
formattedTimestamp = formattedTimestamp,
)
}.toImmutableList()
}
value = rows.toImmutableList()
if (isFirstDiff) {
isFirstDiff = false
hasLoadedFirstPage = true
}
}
}

Expand Down Expand Up @@ -115,9 +122,39 @@ class ThreadsListPresenter(
roomName = roomInfo.name ?: room.roomId.value,
roomAvatarUrl = roomInfo.avatarUrl,
isRoomTombstoned = roomInfo.successorRoom != null,
hasLoadedFirstPage = hasLoadedFirstPage,
eventSink = ::handleEvent,
)
}

private suspend fun ThreadListItem.toRowItem() = ThreadListRowItem(
item = this,
rootEventText = rootEvent.content?.let { content ->
timelineItemContentFactory.create(
itemContent = content,
eventId = rootEvent.eventId,
isEditable = false,
sender = rootEvent.senderId,
senderProfile = rootEvent.senderProfile,
).let { messageSummaryFormatter.format(it) }
},
latestEventText = latestEvent?.let { latestEvent ->
latestEvent.content?.let { content ->
timelineItemContentFactory.create(
itemContent = content,
eventId = latestEvent.eventId,
isEditable = false,
sender = latestEvent.senderId,
senderProfile = latestEvent.senderProfile,
).let { messageSummaryFormatter.format(it) }
}
},
formattedTimestamp = dateFormatter.format(
timestamp = latestEvent?.timestamp ?: rootEvent.timestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
),
)
}

data class ThreadsListState(
Expand All @@ -126,6 +163,7 @@ data class ThreadsListState(
val roomAvatarUrl: String?,
val isRoomTombstoned: Boolean,
val threads: ImmutableList<ThreadListRowItem>,
val hasLoadedFirstPage: Boolean = false,
val eventSink: (ThreadsListEvents) -> Unit,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,25 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
Expand Down Expand Up @@ -67,6 +72,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay

Expand Down Expand Up @@ -129,26 +135,34 @@ fun ThreadsListView(
)
}
) { padding ->
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
state = lazyListState,
) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
ThreadListItemRow(
threadItem = row,
onClick = onThreadClick,
)
if (state.threads.isEmpty() && state.hasLoadedFirstPage) {
EmptyThreadList(
modifier = Modifier
.fillMaxSize()
.padding(padding),
)
} else {
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
state = lazyListState,
) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
ThreadListItemRow(
threadItem = row,
onClick = onThreadClick,
)

if (index < state.threads.size - 1) {
HorizontalDivider()
if (index < state.threads.size - 1) {
HorizontalDivider()
}
}
}
}

ScrollHelper(lazyListState) {
state.eventSink(ThreadsListEvents.Paginate)
ScrollHelper(lazyListState) {
state.eventSink(ThreadsListEvents.Paginate)
}
}
}
}
Expand All @@ -159,20 +173,52 @@ private fun ScrollHelper(
onPaginate: () -> Unit,
) {
val updatedOnPaginate by rememberUpdatedState(onPaginate)
val lastVisibleItemIndex by remember {
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
}
val shouldPaginate by remember {
derivedStateOf {
val canLoadNewItems = listState.isScrollInProgress || listState.layoutInfo.totalItemsCount == 0
canLoadNewItems && lastVisibleItemIndex >= listState.layoutInfo.totalItemsCount - 1
LaunchedEffect(Unit) {
snapshotFlow {
val layoutInfo = listState.layoutInfo
val lastVisibleItemIndex = listState.firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size - 1
val canLoadNewItems = listState.isScrollInProgress || layoutInfo.totalItemsCount == 0
canLoadNewItems && lastVisibleItemIndex >= layoutInfo.totalItemsCount - 1
}.collect { shouldPaginate ->
if (shouldPaginate) {
updatedOnPaginate()
delay(400L)
}
}
}
LaunchedEffect(shouldPaginate, lastVisibleItemIndex) {
if (shouldPaginate) {
updatedOnPaginate()
delay(400L)
}
}

@Composable
private fun EmptyThreadList(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.screen_thread_list_empty_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
val subtitleTemplate = stringResource(R.string.screen_thread_list_empty_subtitle)
val replyInThread = stringResource(CommonStrings.action_reply_in_thread)
Text(
text = buildAnnotatedString {
val parts = subtitleTemplate.split("%1\$s")
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(replyInThread)
}
if (parts.size > 1) {
append(parts[1])
}
},
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}

Expand Down Expand Up @@ -325,6 +371,27 @@ internal fun ThreadsListViewPreview() {
roomAvatarUrl = null,
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
isRoomTombstoned = false,
hasLoadedFirstPage = true,
eventSink = {},
),
onThreadClick = {},
onBackClick = {},
)
}
}

@PreviewsDayNight
@Composable
internal fun ThreadsListViewEmptyPreview() {
Comment thread
bxdxnn marked this conversation as resolved.
Outdated
ElementPreview {
ThreadsListView(
state = ThreadsListState(
roomId = RoomId("!room-id:server"),
roomName = ROOM_NAME,
roomAvatarUrl = null,
threads = persistentListOf(),
isRoomTombstoned = false,
hasLoadedFirstPage = true,
eventSink = {},
),
onThreadClick = {},
Expand Down
Loading