Skip to content

Commit 80470b3

Browse files
Feature: add room threads list (#6575)
Add threads list screen for rooms: - Add `ThreadsListService` to subscribe to thread changes in the room. - Create `ThreadsListView` and its associated node a presenters (the UI may change). - Add a menu icon in the room screen to open it. This is still pending info about unread threads, so several UI components related to it will be hidden. * Add feature flag and use it to hide the access to this new screen --------- Co-authored-by: ElementBot <android@element.io>
1 parent be775d6 commit 80470b3

File tree

35 files changed

+1357
-45
lines changed

35 files changed

+1357
-45
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel
3939
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
4040
import io.element.android.features.messages.impl.report.ReportMessageNode
4141
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
42+
import io.element.android.features.messages.impl.threads.list.ThreadsListNode
4243
import io.element.android.features.messages.impl.timeline.TimelineController
4344
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
4445
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -179,6 +180,9 @@ class MessagesFlowNode(
179180

180181
@Parcelize
181182
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
183+
184+
@Parcelize
185+
data object ThreadsList : NavTarget
182186
}
183187

184188
private val callback: MessagesEntryPoint.Callback = callback()
@@ -294,6 +298,10 @@ class MessagesFlowNode(
294298
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
295299
}
296300

301+
override fun navigateToThreadsList() {
302+
backstack.push(NavTarget.ThreadsList)
303+
}
304+
297305
override fun navigateToDeveloperSettings() {
298306
callback.navigateToDeveloperSettings()
299307
}
@@ -517,6 +525,14 @@ class MessagesFlowNode(
517525
}
518526
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
519527
}
528+
NavTarget.ThreadsList -> {
529+
val callback = object : ThreadsListNode.Callback {
530+
override fun openThread(threadId: ThreadId) {
531+
backstack.push(NavTarget.Thread(threadId, focusedEventId = null))
532+
}
533+
}
534+
createNode<ThreadsListNode>(buildContext, listOf(callback))
535+
}
520536
}
521537
}
522538

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ class MessagesNode(
131131
fun navigateToPinnedMessagesList()
132132
fun navigateToKnockRequestsList()
133133
fun navigateToDeveloperSettings()
134+
135+
fun navigateToThreadsList()
134136
}
135137

136138
override fun onBuilt() {
@@ -299,6 +301,7 @@ class MessagesNode(
299301
onViewRequestsClick = callback::navigateToKnockRequestsList,
300302
)
301303
},
304+
onThreadsListClick = callback::navigateToThreadsList,
302305
)
303306
roomMemberModerationRenderer.Render(
304307
state = state.roomMemberModerationState,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
1616
import androidx.compose.runtime.derivedStateOf
1717
import androidx.compose.runtime.getValue
1818
import androidx.compose.runtime.mutableStateOf
19+
import androidx.compose.runtime.produceState
1920
import androidx.compose.runtime.remember
2021
import androidx.compose.runtime.rememberCoroutineScope
2122
import androidx.compose.runtime.saveable.rememberSaveable
@@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject
2728
import im.vector.app.features.analytics.plan.PinUnpinAction
2829
import io.element.android.appconfig.MessageComposerConfig
2930
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
31+
import io.element.android.features.messages.impl.MessagesState.Threads
3032
import io.element.android.features.messages.impl.actionlist.ActionListState
3133
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
3234
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
@@ -85,8 +87,11 @@ import io.element.android.libraries.recentemojis.api.AddRecentEmoji
8587
import io.element.android.libraries.textcomposer.model.MessageComposerMode
8688
import io.element.android.libraries.ui.strings.CommonStrings
8789
import io.element.android.services.analytics.api.AnalyticsService
90+
import kotlinx.collections.immutable.persistentListOf
8891
import kotlinx.collections.immutable.toImmutableList
8992
import kotlinx.coroutines.CoroutineScope
93+
import kotlinx.coroutines.flow.collectLatest
94+
import kotlinx.coroutines.flow.onStart
9095
import kotlinx.coroutines.launch
9196
import kotlinx.coroutines.withContext
9297
import timber.log.Timber
@@ -160,6 +165,13 @@ class MessagesPresenter(
160165
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
161166
val roomCallState = roomCallStatePresenter.present()
162167
val roomMemberModerationState = roomMemberModerationPresenter.present()
168+
val threadsList by produceState(persistentListOf()) {
169+
room.threadsListService.subscribeToItemUpdates()
170+
.onStart { room.threadsListService.paginate() }
171+
.collectLatest { value = it.toImmutableList() }
172+
}
173+
174+
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
163175

164176
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
165177
perms.userEventPermissions()
@@ -294,6 +306,11 @@ class MessagesPresenter(
294306
roomMemberModerationState = roomMemberModerationState,
295307
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
296308
successorRoom = roomInfo.successorRoom,
309+
threads = Threads(
310+
hasThreads = canOpenThreadList && threadsList.isNotEmpty(),
311+
// TODO calculate this properly based on the thread list and the read state of each thread
312+
hasUnreadThreads = false,
313+
),
297314
eventSink = ::handleEvent,
298315
)
299316
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,15 @@ data class MessagesState(
5757
/** Type of "shared history" icon to show in the top bar. */
5858
val topBarSharedHistoryIcon: SharedHistoryIcon,
5959
val successorRoom: SuccessorRoom?,
60+
val threads: Threads,
6061
val eventSink: (MessagesEvent) -> Unit
6162
) {
6263
val isTombstoned = successorRoom != null
64+
65+
data class Threads(
66+
val hasThreads: Boolean,
67+
val hasUnreadThreads: Boolean,
68+
)
6369
}
6470

6571
/** Type of "shared history" icon to show in the top bar. */

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ fun aMessagesState(
122122
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
123123
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
124124
successorRoom: SuccessorRoom? = null,
125+
threads: MessagesState.Threads = MessagesState.Threads(
126+
hasThreads = false,
127+
hasUnreadThreads = false,
128+
),
125129
eventSink: (MessagesEvent) -> Unit = {},
126130
) = MessagesState(
127131
roomId = RoomId("!id:domain"),
@@ -150,6 +154,7 @@ fun aMessagesState(
150154
roomMemberModerationState = roomMemberModerationState,
151155
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
152156
successorRoom = successorRoom,
157+
threads = threads,
153158
eventSink = eventSink,
154159
)
155160

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import androidx.compose.animation.AnimatedVisibility
1212
import androidx.compose.animation.expandVertically
1313
import androidx.compose.animation.shrinkVertically
1414
import androidx.compose.foundation.background
15+
import androidx.compose.foundation.clickable
1516
import androidx.compose.foundation.layout.Arrangement
1617
import androidx.compose.foundation.layout.Box
1718
import androidx.compose.foundation.layout.Column
1819
import androidx.compose.foundation.layout.Row
20+
import androidx.compose.foundation.layout.Spacer
1921
import androidx.compose.foundation.layout.WindowInsets
2022
import androidx.compose.foundation.layout.consumeWindowInsets
2123
import androidx.compose.foundation.layout.fillMaxSize
@@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
2628
import androidx.compose.foundation.layout.padding
2729
import androidx.compose.foundation.layout.statusBars
2830
import androidx.compose.foundation.layout.systemBarsPadding
31+
import androidx.compose.foundation.layout.width
2932
import androidx.compose.material3.MaterialTheme
3033
import androidx.compose.runtime.Composable
3134
import androidx.compose.runtime.LaunchedEffect
@@ -52,6 +55,7 @@ import androidx.compose.ui.tooling.preview.Preview
5255
import androidx.compose.ui.tooling.preview.PreviewParameter
5356
import androidx.compose.ui.unit.dp
5457
import io.element.android.compound.theme.ElementTheme
58+
import io.element.android.compound.tokens.generated.CompoundIcons
5559
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
5660
import io.element.android.features.messages.impl.actionlist.ActionListEvent
5761
import io.element.android.features.messages.impl.actionlist.ActionListView
@@ -74,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.aGroupedEvents
7478
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
7579
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
7680
import io.element.android.features.messages.impl.timeline.aTimelineState
81+
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
7782
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
7883
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
7984
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
@@ -88,6 +93,7 @@ import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
8893
import io.element.android.features.messages.impl.topbars.ThreadTopBar
8994
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
9095
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
96+
import io.element.android.features.roomcall.api.RoomCallState
9197
import io.element.android.libraries.androidutils.ui.hideKeyboard
9298
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
9399
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
@@ -99,6 +105,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
99105
import io.element.android.libraries.designsystem.text.toAnnotatedString
100106
import io.element.android.libraries.designsystem.text.toDp
101107
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
108+
import io.element.android.libraries.designsystem.theme.components.Icon
102109
import io.element.android.libraries.designsystem.theme.components.Scaffold
103110
import io.element.android.libraries.designsystem.theme.components.Text
104111
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
@@ -133,6 +140,7 @@ fun MessagesView(
133140
onCreatePollClick: () -> Unit,
134141
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
135142
onViewAllPinnedMessagesClick: () -> Unit,
143+
onThreadsListClick: () -> Unit,
136144
modifier: Modifier = Modifier,
137145
forceJumpToBottomVisibility: Boolean = false,
138146
knockRequestsBannerView: @Composable () -> Unit,
@@ -224,12 +232,18 @@ fun MessagesView(
224232
roomAvatar = state.roomAvatar,
225233
isTombstoned = state.isTombstoned,
226234
heroes = state.heroes,
227-
roomCallState = state.roomCallState,
228235
dmUserIdentityState = state.dmUserVerificationState,
229236
sharedHistoryIcon = state.topBarSharedHistoryIcon,
230237
onBackClick = { hidingKeyboard { onBackClick() } },
231238
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
232-
onJoinCallClick = onJoinCallClick,
239+
menuActions = {
240+
MessagesMenuActions(
241+
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
242+
roomCallState = state.roomCallState,
243+
onJoinCallClick = onJoinCallClick,
244+
onThreadsListClick = onThreadsListClick
245+
)
246+
}
233247
)
234248
}
235249
},
@@ -397,6 +411,28 @@ fun MessagesView(
397411
)
398412
}
399413

414+
@Composable
415+
internal fun MessagesMenuActions(
416+
displayThreads: Boolean,
417+
roomCallState: RoomCallState,
418+
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
419+
onThreadsListClick: () -> Unit,
420+
) {
421+
if (displayThreads) {
422+
Icon(
423+
modifier = Modifier.clickable(enabled = true, onClick = onThreadsListClick),
424+
imageVector = CompoundIcons.ThreadsSolid(),
425+
contentDescription = stringResource(CommonStrings.common_threads),
426+
)
427+
Spacer(Modifier.width(8.dp))
428+
}
429+
CallMenuItem(
430+
roomCallState = roomCallState,
431+
onJoinCallClick = onJoinCallClick,
432+
)
433+
Spacer(Modifier.width(8.dp))
434+
}
435+
400436
@Composable
401437
private fun ReinviteDialog(state: MessagesState) {
402438
if (state.showReinvitePrompt) {
@@ -601,6 +637,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
601637
onViewAllPinnedMessagesClick = { },
602638
forceJumpToBottomVisibility = true,
603639
knockRequestsBannerView = {},
640+
onThreadsListClick = {},
604641
)
605642
}
606643

@@ -652,7 +689,8 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
652689
onSendLocationClick = {},
653690
onCreatePollClick = {},
654691
onJoinCallClick = {},
655-
onViewAllPinnedMessagesClick = { },
692+
onViewAllPinnedMessagesClick = {},
693+
onThreadsListClick = {},
656694
forceJumpToBottomVisibility = true,
657695
knockRequestsBannerView = {},
658696
)

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ internal fun MessagesViewWithIdentityChangePreview(
4141
onCreatePollClick = {},
4242
onJoinCallClick = {},
4343
onViewAllPinnedMessagesClick = {},
44-
knockRequestsBannerView = {}
44+
knockRequestsBannerView = {},
45+
onThreadsListClick = {},
4546
)
4647
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ class ThreadedMessagesNode(
300300
onViewAllPinnedMessagesClick = {},
301301
modifier = modifier,
302302
knockRequestsBannerView = {},
303+
onThreadsListClick = {},
303304
)
304305

305306
roomMemberModerationRenderer.Render(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright (c) 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.threads.list
9+
10+
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
11+
12+
data class ThreadListRowItem(
13+
val item: ThreadListItem,
14+
val rootEventText: String?,
15+
val latestEventText: String?,
16+
val formattedTimestamp: String,
17+
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2026 Element Creations Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.threads.list
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Modifier
12+
import com.bumble.appyx.core.modality.BuildContext
13+
import com.bumble.appyx.core.node.Node
14+
import com.bumble.appyx.core.plugin.Plugin
15+
import dev.zacsweers.metro.Assisted
16+
import dev.zacsweers.metro.AssistedInject
17+
import io.element.android.annotations.ContributesNode
18+
import io.element.android.libraries.architecture.callback
19+
import io.element.android.libraries.di.RoomScope
20+
import io.element.android.libraries.matrix.api.core.ThreadId
21+
22+
@ContributesNode(RoomScope::class)
23+
@AssistedInject
24+
class ThreadsListNode(
25+
@Assisted buildContext: BuildContext,
26+
@Assisted plugins: List<Plugin>,
27+
private val presenter: ThreadsListPresenter,
28+
) : Node(buildContext, plugins = plugins) {
29+
interface Callback : Plugin {
30+
fun openThread(threadId: ThreadId)
31+
}
32+
33+
private val callback: Callback = callback()
34+
35+
@Composable
36+
override fun View(modifier: Modifier) {
37+
ThreadsListView(
38+
state = presenter.present(),
39+
modifier = modifier,
40+
onThreadClick = callback::openThread,
41+
onBackClick = this::navigateUp,
42+
)
43+
}
44+
}

0 commit comments

Comments
 (0)