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>
This commit is contained in:
parent
6a57220d5c
commit
15419bb675
35 changed files with 1357 additions and 45 deletions
|
|
@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel
|
||||||
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
|
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
|
||||||
import io.element.android.features.messages.impl.report.ReportMessageNode
|
import io.element.android.features.messages.impl.report.ReportMessageNode
|
||||||
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
|
import io.element.android.features.messages.impl.threads.ThreadedMessagesNode
|
||||||
|
import io.element.android.features.messages.impl.threads.list.ThreadsListNode
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
@ -179,6 +180,9 @@ class MessagesFlowNode(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object ThreadsList : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: MessagesEntryPoint.Callback = callback()
|
private val callback: MessagesEntryPoint.Callback = callback()
|
||||||
|
|
@ -294,6 +298,10 @@ class MessagesFlowNode(
|
||||||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun navigateToThreadsList() {
|
||||||
|
backstack.push(NavTarget.ThreadsList)
|
||||||
|
}
|
||||||
|
|
||||||
override fun navigateToDeveloperSettings() {
|
override fun navigateToDeveloperSettings() {
|
||||||
callback.navigateToDeveloperSettings()
|
callback.navigateToDeveloperSettings()
|
||||||
}
|
}
|
||||||
|
|
@ -517,6 +525,14 @@ class MessagesFlowNode(
|
||||||
}
|
}
|
||||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||||
}
|
}
|
||||||
|
NavTarget.ThreadsList -> {
|
||||||
|
val callback = object : ThreadsListNode.Callback {
|
||||||
|
override fun openThread(threadId: ThreadId) {
|
||||||
|
backstack.push(NavTarget.Thread(threadId, focusedEventId = null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<ThreadsListNode>(buildContext, listOf(callback))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ class MessagesNode(
|
||||||
fun navigateToPinnedMessagesList()
|
fun navigateToPinnedMessagesList()
|
||||||
fun navigateToKnockRequestsList()
|
fun navigateToKnockRequestsList()
|
||||||
fun navigateToDeveloperSettings()
|
fun navigateToDeveloperSettings()
|
||||||
|
|
||||||
|
fun navigateToThreadsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuilt() {
|
override fun onBuilt() {
|
||||||
|
|
@ -299,6 +301,7 @@ class MessagesNode(
|
||||||
onViewRequestsClick = callback::navigateToKnockRequestsList,
|
onViewRequestsClick = callback::navigateToKnockRequestsList,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onThreadsListClick = callback::navigateToThreadsList,
|
||||||
)
|
)
|
||||||
roomMemberModerationRenderer.Render(
|
roomMemberModerationRenderer.Render(
|
||||||
state = state.roomMemberModerationState,
|
state = state.roomMemberModerationState,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
|
@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.PinUnpinAction
|
import im.vector.app.features.analytics.plan.PinUnpinAction
|
||||||
import io.element.android.appconfig.MessageComposerConfig
|
import io.element.android.appconfig.MessageComposerConfig
|
||||||
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
|
||||||
|
import io.element.android.features.messages.impl.MessagesState.Threads
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
|
|
@ -85,8 +87,11 @@ import io.element.android.libraries.recentemojis.api.AddRecentEmoji
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -160,6 +165,13 @@ class MessagesPresenter(
|
||||||
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
||||||
val roomCallState = roomCallStatePresenter.present()
|
val roomCallState = roomCallStatePresenter.present()
|
||||||
val roomMemberModerationState = roomMemberModerationPresenter.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 userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
|
||||||
perms.userEventPermissions()
|
perms.userEventPermissions()
|
||||||
|
|
@ -294,6 +306,11 @@ class MessagesPresenter(
|
||||||
roomMemberModerationState = roomMemberModerationState,
|
roomMemberModerationState = roomMemberModerationState,
|
||||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||||
successorRoom = roomInfo.successorRoom,
|
successorRoom = roomInfo.successorRoom,
|
||||||
|
threads = Threads(
|
||||||
|
hasThreads = canOpenThreadList && threadsList.isNotEmpty(),
|
||||||
|
// TODO calculate this properly based on the thread list and the read state of each thread
|
||||||
|
hasUnreadThreads = false,
|
||||||
|
),
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,15 @@ data class MessagesState(
|
||||||
/** Type of "shared history" icon to show in the top bar. */
|
/** Type of "shared history" icon to show in the top bar. */
|
||||||
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
||||||
val successorRoom: SuccessorRoom?,
|
val successorRoom: SuccessorRoom?,
|
||||||
|
val threads: Threads,
|
||||||
val eventSink: (MessagesEvent) -> Unit
|
val eventSink: (MessagesEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val isTombstoned = successorRoom != null
|
val isTombstoned = successorRoom != null
|
||||||
|
|
||||||
|
data class Threads(
|
||||||
|
val hasThreads: Boolean,
|
||||||
|
val hasUnreadThreads: Boolean,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type of "shared history" icon to show in the top bar. */
|
/** Type of "shared history" icon to show in the top bar. */
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@ fun aMessagesState(
|
||||||
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||||
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
||||||
successorRoom: SuccessorRoom? = null,
|
successorRoom: SuccessorRoom? = null,
|
||||||
|
threads: MessagesState.Threads = MessagesState.Threads(
|
||||||
|
hasThreads = false,
|
||||||
|
hasUnreadThreads = false,
|
||||||
|
),
|
||||||
eventSink: (MessagesEvent) -> Unit = {},
|
eventSink: (MessagesEvent) -> Unit = {},
|
||||||
) = MessagesState(
|
) = MessagesState(
|
||||||
roomId = RoomId("!id:domain"),
|
roomId = RoomId("!id:domain"),
|
||||||
|
|
@ -150,6 +154,7 @@ fun aMessagesState(
|
||||||
roomMemberModerationState = roomMemberModerationState,
|
roomMemberModerationState = roomMemberModerationState,
|
||||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||||
successorRoom = successorRoom,
|
successorRoom = successorRoom,
|
||||||
|
threads = threads,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -52,6 +55,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
import io.element.android.features.messages.impl.actionlist.ActionListEvent
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
|
|
@ -74,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
|
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
||||||
|
|
@ -88,6 +93,7 @@ import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
|
||||||
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||||
|
import io.element.android.features.roomcall.api.RoomCallState
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||||
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
|
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout
|
||||||
|
|
@ -99,6 +105,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
import io.element.android.libraries.designsystem.text.toAnnotatedString
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
|
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
|
||||||
|
|
@ -133,6 +140,7 @@ fun MessagesView(
|
||||||
onCreatePollClick: () -> Unit,
|
onCreatePollClick: () -> Unit,
|
||||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||||
onViewAllPinnedMessagesClick: () -> Unit,
|
onViewAllPinnedMessagesClick: () -> Unit,
|
||||||
|
onThreadsListClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
forceJumpToBottomVisibility: Boolean = false,
|
forceJumpToBottomVisibility: Boolean = false,
|
||||||
knockRequestsBannerView: @Composable () -> Unit,
|
knockRequestsBannerView: @Composable () -> Unit,
|
||||||
|
|
@ -224,12 +232,18 @@ fun MessagesView(
|
||||||
roomAvatar = state.roomAvatar,
|
roomAvatar = state.roomAvatar,
|
||||||
isTombstoned = state.isTombstoned,
|
isTombstoned = state.isTombstoned,
|
||||||
heroes = state.heroes,
|
heroes = state.heroes,
|
||||||
roomCallState = state.roomCallState,
|
|
||||||
dmUserIdentityState = state.dmUserVerificationState,
|
dmUserIdentityState = state.dmUserVerificationState,
|
||||||
sharedHistoryIcon = state.topBarSharedHistoryIcon,
|
sharedHistoryIcon = state.topBarSharedHistoryIcon,
|
||||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
onBackClick = { hidingKeyboard { onBackClick() } },
|
||||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
||||||
onJoinCallClick = onJoinCallClick,
|
menuActions = {
|
||||||
|
MessagesMenuActions(
|
||||||
|
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
|
||||||
|
roomCallState = state.roomCallState,
|
||||||
|
onJoinCallClick = onJoinCallClick,
|
||||||
|
onThreadsListClick = onThreadsListClick
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -397,6 +411,28 @@ fun MessagesView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MessagesMenuActions(
|
||||||
|
displayThreads: Boolean,
|
||||||
|
roomCallState: RoomCallState,
|
||||||
|
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||||
|
onThreadsListClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (displayThreads) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.clickable(enabled = true, onClick = onThreadsListClick),
|
||||||
|
imageVector = CompoundIcons.ThreadsSolid(),
|
||||||
|
contentDescription = stringResource(CommonStrings.common_threads),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
CallMenuItem(
|
||||||
|
roomCallState = roomCallState,
|
||||||
|
onJoinCallClick = onJoinCallClick,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReinviteDialog(state: MessagesState) {
|
private fun ReinviteDialog(state: MessagesState) {
|
||||||
if (state.showReinvitePrompt) {
|
if (state.showReinvitePrompt) {
|
||||||
|
|
@ -601,6 +637,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
||||||
onViewAllPinnedMessagesClick = { },
|
onViewAllPinnedMessagesClick = { },
|
||||||
forceJumpToBottomVisibility = true,
|
forceJumpToBottomVisibility = true,
|
||||||
knockRequestsBannerView = {},
|
knockRequestsBannerView = {},
|
||||||
|
onThreadsListClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -652,7 +689,8 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
|
||||||
onSendLocationClick = {},
|
onSendLocationClick = {},
|
||||||
onCreatePollClick = {},
|
onCreatePollClick = {},
|
||||||
onJoinCallClick = {},
|
onJoinCallClick = {},
|
||||||
onViewAllPinnedMessagesClick = { },
|
onViewAllPinnedMessagesClick = {},
|
||||||
|
onThreadsListClick = {},
|
||||||
forceJumpToBottomVisibility = true,
|
forceJumpToBottomVisibility = true,
|
||||||
knockRequestsBannerView = {},
|
knockRequestsBannerView = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||||
onCreatePollClick = {},
|
onCreatePollClick = {},
|
||||||
onJoinCallClick = {},
|
onJoinCallClick = {},
|
||||||
onViewAllPinnedMessagesClick = {},
|
onViewAllPinnedMessagesClick = {},
|
||||||
knockRequestsBannerView = {}
|
knockRequestsBannerView = {},
|
||||||
|
onThreadsListClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,7 @@ class ThreadedMessagesNode(
|
||||||
onViewAllPinnedMessagesClick = {},
|
onViewAllPinnedMessagesClick = {},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
knockRequestsBannerView = {},
|
knockRequestsBannerView = {},
|
||||||
|
onThreadsListClick = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
roomMemberModerationRenderer.Render(
|
roomMemberModerationRenderer.Render(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.threads.list
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||||
|
|
||||||
|
data class ThreadListRowItem(
|
||||||
|
val item: ThreadListItem,
|
||||||
|
val rootEventText: String?,
|
||||||
|
val latestEventText: String?,
|
||||||
|
val formattedTimestamp: String,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.threads.list
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.Assisted
|
||||||
|
import dev.zacsweers.metro.AssistedInject
|
||||||
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class ThreadsListNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val presenter: ThreadsListPresenter,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun openThread(threadId: ThreadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback: Callback = callback()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
ThreadsListView(
|
||||||
|
state = presenter.present(),
|
||||||
|
modifier = modifier,
|
||||||
|
onThreadClick = callback::openThread,
|
||||||
|
onBackClick = this::navigateUp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.threads.list
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
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.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.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 timber.log.Timber
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class ThreadsListPresenter(
|
||||||
|
private val room: JoinedRoom,
|
||||||
|
private val timelineItemContentFactory: TimelineItemContentFactory,
|
||||||
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
|
private val dateFormatter: DateFormatter,
|
||||||
|
) : Presenter<ThreadsListState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): ThreadsListState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val threadsListService = room.threadsListService
|
||||||
|
|
||||||
|
val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) {
|
||||||
|
threadsListService.subscribeToItemUpdates()
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val paginationStatus by produceState<ThreadListPaginationStatus>(
|
||||||
|
initialValue = ThreadListPaginationStatus.Idle(hasMoreToLoad = true),
|
||||||
|
key1 = threadsListService
|
||||||
|
) {
|
||||||
|
threadsListService
|
||||||
|
.subscribeToPaginationUpdates()
|
||||||
|
.collect { value = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
threadsListService.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: ThreadsListEvents) {
|
||||||
|
when (event) {
|
||||||
|
ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
Timber.d("Paginating thread list: $paginationStatus")
|
||||||
|
threadsListService.paginate()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Timber.d("Not paginating since there is nothing else to load, current status: $paginationStatus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ThreadsListState(
|
||||||
|
threads = threads,
|
||||||
|
roomId = room.roomId,
|
||||||
|
roomName = roomInfo.name ?: room.roomId.value,
|
||||||
|
roomAvatarUrl = roomInfo.avatarUrl,
|
||||||
|
isRoomTombstoned = roomInfo.successorRoom != null,
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ThreadsListState(
|
||||||
|
val roomId: RoomId,
|
||||||
|
val roomName: String,
|
||||||
|
val roomAvatarUrl: String?,
|
||||||
|
val isRoomTombstoned: Boolean,
|
||||||
|
val threads: ImmutableList<ThreadListRowItem>,
|
||||||
|
val eventSink: (ThreadsListEvents) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface ThreadsListEvents {
|
||||||
|
data object Paginate : ThreadsListEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.threads.list
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.height
|
||||||
|
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.itemsIndexed
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
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.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.core.asEventId
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
|
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.toImmutableList
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ThreadsListView(
|
||||||
|
state: ThreadsListState,
|
||||||
|
onThreadClick: (ThreadId) -> Unit,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Avatar(
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = state.roomId.value,
|
||||||
|
name = state.roomName,
|
||||||
|
url = state.roomAvatarUrl,
|
||||||
|
size = AvatarSize.CurrentUserTopBar,
|
||||||
|
),
|
||||||
|
avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.common_threads),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.roomName,
|
||||||
|
style = ElementTheme.typography.fontBodyXsRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onBackClick)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { 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 (index < state.threads.size - 1) {
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollHelper(lazyListState) {
|
||||||
|
state.eventSink(ThreadsListEvents.Paginate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScrollHelper(
|
||||||
|
listState: LazyListState,
|
||||||
|
onPaginate: () -> Unit,
|
||||||
|
) {
|
||||||
|
val lastVisibleItemIndex by remember {
|
||||||
|
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
|
||||||
|
}
|
||||||
|
val needsPagination by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0
|
||||||
|
canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(needsPagination, lastVisibleItemIndex) {
|
||||||
|
if (needsPagination) {
|
||||||
|
onPaginate()
|
||||||
|
delay(400L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThreadListItemRow(
|
||||||
|
threadItem: ThreadListRowItem,
|
||||||
|
onClick: (ThreadId) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { onClick(threadItem.item.threadId) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp, bottom = 8.dp, start = 16.dp, end = 16.dp),
|
||||||
|
) {
|
||||||
|
val rootEvent = threadItem.item.rootEvent
|
||||||
|
val senderProfile = rootEvent.senderProfile
|
||||||
|
Avatar(
|
||||||
|
modifier = Modifier.align(Alignment.CenterVertically),
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = rootEvent.senderId.value,
|
||||||
|
name = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
|
||||||
|
url = senderProfile.getAvatarUrl(),
|
||||||
|
size = AvatarSize.ThreadsListItem,
|
||||||
|
),
|
||||||
|
avatarType = AvatarType.User,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// TODO actually compute these values based on the thread state (not available yet)
|
||||||
|
val hasMentions = false
|
||||||
|
val hasUnreadNotifications = false
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
text = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = threadItem.formattedTimestamp,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
color = if (hasUnreadNotifications || hasMentions) ElementTheme.colors.textActionAccent else ElementTheme.colors.textSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
text = threadItem.rootEventText.orEmpty(),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(7.dp)
|
||||||
|
) {
|
||||||
|
if (hasMentions) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
imageVector = CompoundIcons.Mention(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.textActionAccent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
UnreadIndicatorAtom(
|
||||||
|
size = 14.dp,
|
||||||
|
isVisible = hasUnreadNotifications,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${threadItem.item.numberOfReplies}",
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = CompoundIcons.ThreadsSolid(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconSecondary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
threadItem.item.latestEvent?.let { latestEvent ->
|
||||||
|
Avatar(
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = latestEvent.senderId.value,
|
||||||
|
name = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId),
|
||||||
|
url = latestEvent.senderProfile.getAvatarUrl(),
|
||||||
|
size = AvatarSize.TimelineThreadLatestEventSender,
|
||||||
|
),
|
||||||
|
avatarType = AvatarType.User,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = threadItem.latestEventText.orEmpty(),
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun ThreadsListViewPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
ThreadsListView(
|
||||||
|
state = ThreadsListState(
|
||||||
|
roomId = RoomId("!room-id:server"),
|
||||||
|
roomName = "Room name",
|
||||||
|
roomAvatarUrl = null,
|
||||||
|
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
|
||||||
|
isRoomTombstoned = false,
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
|
onThreadClick = {},
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun ThreadListItemRowPreview() {
|
||||||
|
ElementPreview {
|
||||||
|
ThreadListItemRow(
|
||||||
|
threadItem = aThreadListRowItem(),
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aThreadListRowItem(
|
||||||
|
threadId: ThreadId = ThreadId("\$a-thread-id"),
|
||||||
|
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
|
||||||
|
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
|
||||||
|
numberOfReplies: Long = 42,
|
||||||
|
rootEventText: String? = "Hello world!",
|
||||||
|
latestEventText: String? = "Hello again!",
|
||||||
|
formattedTimestamp: String = "12:34",
|
||||||
|
) = ThreadListRowItem(
|
||||||
|
item = aThreadListItem(
|
||||||
|
threadId = threadId,
|
||||||
|
rootEvent = rootEvent,
|
||||||
|
latestEvent = latestEvent,
|
||||||
|
numberOfReplies = numberOfReplies,
|
||||||
|
),
|
||||||
|
rootEventText = rootEventText,
|
||||||
|
latestEventText = latestEventText,
|
||||||
|
formattedTimestamp = formattedTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aThreadListItem(
|
||||||
|
threadId: ThreadId = ThreadId("\$a-thread-id"),
|
||||||
|
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
|
||||||
|
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
|
||||||
|
numberOfReplies: Long = 42,
|
||||||
|
) = ThreadListItem(
|
||||||
|
rootEvent = rootEvent,
|
||||||
|
latestEvent = latestEvent,
|
||||||
|
numberOfReplies = numberOfReplies,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aThreadListItemEvent(
|
||||||
|
threadId: ThreadId = ThreadId("\$a-thread-id"),
|
||||||
|
senderId: UserId = UserId("@a-user-id:server"),
|
||||||
|
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null),
|
||||||
|
isOwn: Boolean = false,
|
||||||
|
content: EventContent = MessageContent(
|
||||||
|
body = "Hello world!",
|
||||||
|
inReplyTo = null,
|
||||||
|
isEdited = false,
|
||||||
|
threadInfo = null,
|
||||||
|
type = TextMessageType("Hello world!", null),
|
||||||
|
),
|
||||||
|
timestamp: Long = 0L,
|
||||||
|
) = ThreadListItemEvent(
|
||||||
|
eventId = threadId.asEventId(),
|
||||||
|
senderId = senderId,
|
||||||
|
senderProfile = senderProfile,
|
||||||
|
isOwn = isOwn,
|
||||||
|
content = content,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
|
@ -12,10 +12,9 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -30,8 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.messages.impl.MessagesMenuActions
|
||||||
import io.element.android.features.messages.impl.SharedHistoryIcon
|
import io.element.android.features.messages.impl.SharedHistoryIcon
|
||||||
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
|
|
||||||
import io.element.android.features.roomcall.api.RoomCallState
|
import io.element.android.features.roomcall.api.RoomCallState
|
||||||
import io.element.android.features.roomcall.api.aStandByCallState
|
import io.element.android.features.roomcall.api.aStandByCallState
|
||||||
import io.element.android.features.roomcall.api.anOngoingCallState
|
import io.element.android.features.roomcall.api.anOngoingCallState
|
||||||
|
|
@ -62,13 +61,12 @@ internal fun MessagesViewTopBar(
|
||||||
roomAvatar: AvatarData,
|
roomAvatar: AvatarData,
|
||||||
isTombstoned: Boolean,
|
isTombstoned: Boolean,
|
||||||
heroes: ImmutableList<AvatarData>,
|
heroes: ImmutableList<AvatarData>,
|
||||||
roomCallState: RoomCallState,
|
|
||||||
dmUserIdentityState: IdentityState?,
|
dmUserIdentityState: IdentityState?,
|
||||||
sharedHistoryIcon: SharedHistoryIcon,
|
sharedHistoryIcon: SharedHistoryIcon,
|
||||||
onRoomDetailsClick: () -> Unit,
|
onRoomDetailsClick: () -> Unit,
|
||||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
menuActions: @Composable RowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -126,13 +124,7 @@ internal fun MessagesViewTopBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = menuActions,
|
||||||
CallMenuItem(
|
|
||||||
roomCallState = roomCallState,
|
|
||||||
onJoinCallClick = onJoinCallClick,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
},
|
|
||||||
windowInsets = WindowInsets(0.dp)
|
windowInsets = WindowInsets(0.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -186,17 +178,24 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
||||||
roomCallState: RoomCallState = RoomCallState.Unavailable,
|
roomCallState: RoomCallState = RoomCallState.Unavailable,
|
||||||
dmUserIdentityState: IdentityState? = null,
|
dmUserIdentityState: IdentityState? = null,
|
||||||
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
||||||
|
displayThreads: Boolean = false,
|
||||||
) = MessagesViewTopBar(
|
) = MessagesViewTopBar(
|
||||||
roomName = roomName,
|
roomName = roomName,
|
||||||
roomAvatar = roomAvatar,
|
roomAvatar = roomAvatar,
|
||||||
isTombstoned = isTombstoned,
|
isTombstoned = isTombstoned,
|
||||||
heroes = heroes,
|
heroes = heroes,
|
||||||
roomCallState = roomCallState,
|
|
||||||
dmUserIdentityState = dmUserIdentityState,
|
dmUserIdentityState = dmUserIdentityState,
|
||||||
sharedHistoryIcon = sharedHistoryIcon,
|
sharedHistoryIcon = sharedHistoryIcon,
|
||||||
onRoomDetailsClick = {},
|
onRoomDetailsClick = {},
|
||||||
onJoinCallClick = {},
|
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
|
menuActions = {
|
||||||
|
MessagesMenuActions(
|
||||||
|
roomCallState = roomCallState,
|
||||||
|
displayThreads = displayThreads,
|
||||||
|
onJoinCallClick = {},
|
||||||
|
onThreadsListClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
Column {
|
Column {
|
||||||
AMessagesViewTopBar()
|
AMessagesViewTopBar()
|
||||||
|
|
@ -237,5 +236,9 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
||||||
roomName = "A room with world_readable history",
|
roomName = "A room with world_readable history",
|
||||||
sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
|
sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
|
||||||
)
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
AMessagesViewTopBar(
|
||||||
|
displayThreads = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
|
||||||
|
import io.element.android.features.messages.impl.threads.list.aThreadListItem
|
||||||
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
|
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
|
||||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
|
|
@ -88,6 +89,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||||
|
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
|
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||||
|
|
@ -110,6 +112,7 @@ import io.element.android.tests.testutils.testWithLifecycleOwner
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
|
@ -1258,6 +1261,35 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - only has threads enabled if the feature flag is on`() = runTest {
|
||||||
|
val itemsFlow = MutableStateFlow(listOf(aThreadListItem()))
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
threadsListService = FakeThreadsListService(items = itemsFlow)
|
||||||
|
)
|
||||||
|
val featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.Threads.key to false)
|
||||||
|
)
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
joinedRoom = room,
|
||||||
|
featureFlagService = featureFlagService
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
// The feature flag is disabled, so even if the thread list has items, it will return it doesn't have any
|
||||||
|
assertThat(initialState.threads.hasThreads).isFalse()
|
||||||
|
|
||||||
|
// Enable the feature flag, now it should reflect the thread list state
|
||||||
|
featureFlagService.setFeatureEnabled(FeatureFlags.RoomThreadList, true)
|
||||||
|
skipItems(1)
|
||||||
|
assertThat(awaitItem().threads.hasThreads).isTrue()
|
||||||
|
|
||||||
|
// And if we remove the items, it should update accordingly
|
||||||
|
itemsFlow.value = emptyList()
|
||||||
|
assertThat(awaitItem().threads.hasThreads).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun roomPermissions(
|
private fun roomPermissions(
|
||||||
canStartCall: Boolean = true,
|
canStartCall: Boolean = true,
|
||||||
canRedactOther: Boolean = true,
|
canRedactOther: Boolean = true,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ import io.element.android.tests.testutils.assertNoNodeWithText
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.ensureCalledOnce
|
import io.element.android.tests.testutils.ensureCalledOnce
|
||||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.pressBack
|
import io.element.android.tests.testutils.pressBack
|
||||||
import io.element.android.tests.testutils.setSafeContent
|
import io.element.android.tests.testutils.setSafeContent
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -606,6 +607,23 @@ class MessagesViewTest {
|
||||||
eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId))
|
eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on threads list button calls the expected function`() {
|
||||||
|
val state = aMessagesState(
|
||||||
|
threads = MessagesState.Threads(
|
||||||
|
hasThreads = true,
|
||||||
|
hasUnreadThreads = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val onThreadsListClicked = lambdaRecorder<Unit> {}
|
||||||
|
rule.setMessagesView(
|
||||||
|
state = state,
|
||||||
|
onThreadsListClicked = onThreadsListClicked,
|
||||||
|
)
|
||||||
|
rule.onNodeWithContentDescription("Threads").performClick()
|
||||||
|
onThreadsListClicked.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `no banner shown when there is no successor room`() {
|
fun `no banner shown when there is no successor room`() {
|
||||||
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
||||||
|
|
@ -630,6 +648,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
||||||
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
|
onCreatePollClick: () -> Unit = EnsureNeverCalled(),
|
||||||
onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
|
||||||
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
|
||||||
|
onThreadsListClicked: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
setSafeContent {
|
setSafeContent {
|
||||||
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
|
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
|
||||||
|
|
@ -646,6 +665,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
||||||
onJoinCallClick = onJoinCallClick,
|
onJoinCallClick = onJoinCallClick,
|
||||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||||
knockRequestsBannerView = {},
|
knockRequestsBannerView = {},
|
||||||
|
onThreadsListClick = onThreadsListClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,38 +48,42 @@ internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Crea
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun aTimelineItemContentFactory(
|
||||||
|
timelineEventFormatter: TimelineEventFormatter = aTimelineEventFormatter(),
|
||||||
|
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||||
|
): TimelineItemContentFactory = TimelineItemContentFactory(
|
||||||
|
messageFactory = TimelineItemContentMessageFactory(
|
||||||
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||||
|
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||||
|
permalinkParser = FakePermalinkParser(),
|
||||||
|
textPillificationHelper = FakeTextPillificationHelper(),
|
||||||
|
),
|
||||||
|
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||||
|
stickerFactory = TimelineItemContentStickerFactory(
|
||||||
|
fileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
|
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
|
||||||
|
),
|
||||||
|
pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()),
|
||||||
|
utdFactory = TimelineItemContentUTDFactory(),
|
||||||
|
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
|
||||||
|
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
|
||||||
|
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
|
||||||
|
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
||||||
|
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||||
|
sessionId = matrixClient.sessionId,
|
||||||
|
)
|
||||||
|
|
||||||
internal fun TestScope.aTimelineItemsFactory(
|
internal fun TestScope.aTimelineItemsFactory(
|
||||||
config: TimelineItemsFactoryConfig,
|
config: TimelineItemsFactoryConfig,
|
||||||
): TimelineItemsFactory {
|
): TimelineItemsFactory {
|
||||||
val timelineEventFormatter = aTimelineEventFormatter()
|
|
||||||
val matrixClient = FakeMatrixClient()
|
val matrixClient = FakeMatrixClient()
|
||||||
return TimelineItemsFactory(
|
return TimelineItemsFactory(
|
||||||
dispatchers = testCoroutineDispatchers(),
|
dispatchers = testCoroutineDispatchers(),
|
||||||
eventItemFactoryCreator = object : TimelineItemEventFactory.Creator {
|
eventItemFactoryCreator = object : TimelineItemEventFactory.Creator {
|
||||||
override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory {
|
override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory {
|
||||||
return TimelineItemEventFactory(
|
return TimelineItemEventFactory(
|
||||||
contentFactory = TimelineItemContentFactory(
|
contentFactory = aTimelineItemContentFactory(matrixClient = matrixClient),
|
||||||
messageFactory = TimelineItemContentMessageFactory(
|
|
||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
|
||||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
|
||||||
permalinkParser = FakePermalinkParser(),
|
|
||||||
textPillificationHelper = FakeTextPillificationHelper(),
|
|
||||||
),
|
|
||||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
|
||||||
stickerFactory = TimelineItemContentStickerFactory(
|
|
||||||
fileSizeFormatter = FakeFileSizeFormatter(),
|
|
||||||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation()
|
|
||||||
),
|
|
||||||
pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()),
|
|
||||||
utdFactory = TimelineItemContentUTDFactory(),
|
|
||||||
roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter),
|
|
||||||
profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter),
|
|
||||||
stateFactory = TimelineItemContentStateFactory(timelineEventFormatter),
|
|
||||||
failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(),
|
|
||||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
|
||||||
sessionId = matrixClient.sessionId,
|
|
||||||
),
|
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
dateFormatter = FakeDateFormatter(),
|
dateFormatter = FakeDateFormatter(),
|
||||||
permalinkParser = FakePermalinkParser(),
|
permalinkParser = FakePermalinkParser(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.threads
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.fixtures.aTimelineItemContentFactory
|
||||||
|
import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter
|
||||||
|
import io.element.android.features.messages.impl.threads.list.ThreadsListEvents
|
||||||
|
import io.element.android.features.messages.impl.threads.list.ThreadsListPresenter
|
||||||
|
import io.element.android.features.messages.impl.threads.list.aThreadListItem
|
||||||
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ThreadsListPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
createThreadsListPresenter().test {
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(threads).isEmpty()
|
||||||
|
assertThat(roomId).isEqualTo(A_ROOM_ID)
|
||||||
|
assertThat(roomName).isEqualTo(A_ROOM_NAME)
|
||||||
|
assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - paginate`() = runTest {
|
||||||
|
val paginateRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
val threadsListService = FakeThreadsListService(paginate = paginateRecorder)
|
||||||
|
val room = FakeJoinedRoom(threadsListService = threadsListService)
|
||||||
|
createThreadsListPresenter(room).test {
|
||||||
|
val initialItem = awaitItem()
|
||||||
|
|
||||||
|
// Pagination is automatically triggered on start, so we should have one call to paginate already
|
||||||
|
paginateRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
initialItem.eventSink(ThreadsListEvents.Paginate)
|
||||||
|
|
||||||
|
// Simulate a pagination result
|
||||||
|
threadsListService.emit(listOf(aThreadListItem()))
|
||||||
|
|
||||||
|
// We should have a second call to paginate after the event is sent
|
||||||
|
paginateRecorder.assertions().isCalledExactly(2)
|
||||||
|
|
||||||
|
// And we receive the new items
|
||||||
|
assertThat(awaitItem().threads).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createThreadsListPresenter(
|
||||||
|
room: FakeJoinedRoom = FakeJoinedRoom(),
|
||||||
|
): ThreadsListPresenter {
|
||||||
|
return ThreadsListPresenter(
|
||||||
|
room = room,
|
||||||
|
timelineItemContentFactory = aTimelineItemContentFactory(),
|
||||||
|
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||||
|
dateFormatter = FakeDateFormatter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,10 +13,12 @@ import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
enum class AvatarSize(val dp: Dp) {
|
enum class AvatarSize(val dp: Dp) {
|
||||||
CurrentUserTopBar(32.dp),
|
CurrentUserTopBar(32.dp),
|
||||||
|
CurrentRoomTopBar(32.dp),
|
||||||
|
|
||||||
IncomingCall(140.dp),
|
IncomingCall(140.dp),
|
||||||
RoomDetailsHeader(96.dp),
|
RoomDetailsHeader(96.dp),
|
||||||
RoomListItem(52.dp),
|
RoomListItem(52.dp),
|
||||||
|
ThreadsListItem(52.dp),
|
||||||
|
|
||||||
SpaceListItem(52.dp),
|
SpaceListItem(52.dp),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,4 +155,11 @@ enum class FeatureFlags(
|
||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
RoomThreadList(
|
||||||
|
key = "feature.room_thread_list",
|
||||||
|
title = "Add a list of threads in a room",
|
||||||
|
description = "Add a new screen with a list of threads in a room.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
||||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
|
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
|
||||||
*/
|
*/
|
||||||
val liveTimeline: Timeline
|
val liveTimeline: Timeline
|
||||||
|
|
||||||
|
val threadsListService: ThreadsListService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new timeline.
|
* Create a new timeline.
|
||||||
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.
|
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.threads
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ThreadListItem(
|
||||||
|
val rootEvent: ThreadListItemEvent,
|
||||||
|
val latestEvent: ThreadListItemEvent?,
|
||||||
|
val numberOfReplies: Long,
|
||||||
|
) {
|
||||||
|
val threadId = rootEvent.eventId.toThreadId()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ThreadListItemEvent(
|
||||||
|
val eventId: EventId,
|
||||||
|
val senderId: UserId,
|
||||||
|
val senderProfile: ProfileDetails,
|
||||||
|
val isOwn: Boolean,
|
||||||
|
val content: EventContent?,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.threads
|
||||||
|
|
||||||
|
sealed interface ThreadListPaginationStatus {
|
||||||
|
data class Idle(
|
||||||
|
val hasMoreToLoad: Boolean,
|
||||||
|
) : ThreadListPaginationStatus
|
||||||
|
|
||||||
|
data object Loading : ThreadListPaginationStatus
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.room.threads
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface ThreadsListService {
|
||||||
|
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
|
||||||
|
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
|
||||||
|
suspend fun paginate(): Result<Unit>
|
||||||
|
suspend fun reset(): Result<Unit>
|
||||||
|
fun destroy()
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
|
@ -44,8 +45,10 @@ import io.element.android.libraries.matrix.impl.room.history.map
|
||||||
import io.element.android.libraries.matrix.impl.room.join.map
|
import io.element.android.libraries.matrix.impl.room.join.map
|
||||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||||
|
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
|
||||||
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
||||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||||
|
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||||
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||||
|
|
@ -145,6 +148,12 @@ class JoinedRustRoom(
|
||||||
|
|
||||||
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
|
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
|
||||||
|
|
||||||
|
override val threadsListService: ThreadsListService = RustThreadsListService(
|
||||||
|
inner = innerRoom.threadListService(),
|
||||||
|
contentMapper = TimelineEventContentMapper(),
|
||||||
|
roomCoroutineScope = roomCoroutineScope,
|
||||||
|
)
|
||||||
|
|
||||||
override val syncUpdateFlow = flow {
|
override val syncUpdateFlow = flow {
|
||||||
var counter = 0L
|
var counter = 0L
|
||||||
liveTimeline.onSyncedEventReceived.collect {
|
liveTimeline.onSyncedEventReceived.collect {
|
||||||
|
|
@ -528,6 +537,7 @@ class JoinedRustRoom(
|
||||||
override fun destroy() {
|
override fun destroy() {
|
||||||
baseRoom.destroy()
|
baseRoom.destroy()
|
||||||
liveInnerTimeline.destroy()
|
liveInnerTimeline.destroy()
|
||||||
|
threadsListService.destroy()
|
||||||
Timber.d("Room $roomId destroyed")
|
Timber.d("Room $roomId destroyed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.room.threads
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||||
|
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||||
|
import io.element.android.libraries.matrix.impl.timeline.item.event.map
|
||||||
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||||
|
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
|
||||||
|
|
||||||
|
class RustThreadsListService(
|
||||||
|
private val inner: InnerThreadListService,
|
||||||
|
private val roomCoroutineScope: CoroutineScope,
|
||||||
|
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
|
||||||
|
) : ThreadsListService {
|
||||||
|
private var itemSubscriptionJob: Job? = null
|
||||||
|
|
||||||
|
private val items = MutableStateFlow<List<ThreadListItem>>(emptyList())
|
||||||
|
|
||||||
|
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
|
||||||
|
if (itemSubscriptionJob?.isActive != true) {
|
||||||
|
itemSubscriptionJob = doSubscribeToItemUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doSubscribeToItemUpdates(): Job {
|
||||||
|
val updatesFlow = mxCallbackFlow {
|
||||||
|
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
|
||||||
|
override fun onUpdate(diff: List<ThreadListUpdate>) {
|
||||||
|
trySend(diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatesFlow
|
||||||
|
.onStart { items.value = inner.items().map { it.map(contentMapper) } }
|
||||||
|
.onEach { diff ->
|
||||||
|
val updated = items.value.toMutableList()
|
||||||
|
updated.apply(diff, contentMapper)
|
||||||
|
items.value = updated
|
||||||
|
}
|
||||||
|
.launchIn(roomCoroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
|
||||||
|
return mxCallbackFlow {
|
||||||
|
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
|
||||||
|
override fun onUpdate(state: ThreadListPaginationState) {
|
||||||
|
trySend(state.map())
|
||||||
|
}
|
||||||
|
}).also {
|
||||||
|
// Send the initial state
|
||||||
|
trySend(inner.paginationState().map())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
|
||||||
|
inner.paginate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
|
||||||
|
inner.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun destroy() {
|
||||||
|
itemSubscriptionJob?.cancel()
|
||||||
|
inner.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MutableList<ThreadListItem>.apply(
|
||||||
|
diff: List<ThreadListUpdate>,
|
||||||
|
contentMapper: TimelineEventContentMapper
|
||||||
|
) {
|
||||||
|
for (diffItem in diff) {
|
||||||
|
when (diffItem) {
|
||||||
|
is ThreadListUpdate.Append -> {
|
||||||
|
val newItems = diffItem.values.map { it.map(contentMapper) }
|
||||||
|
addAll(newItems)
|
||||||
|
}
|
||||||
|
ThreadListUpdate.Clear -> clear()
|
||||||
|
is ThreadListUpdate.Insert -> {
|
||||||
|
add(diffItem.index.toInt(), diffItem.value.map(contentMapper))
|
||||||
|
}
|
||||||
|
ThreadListUpdate.PopBack -> {
|
||||||
|
removeAt(lastIndex)
|
||||||
|
}
|
||||||
|
ThreadListUpdate.PopFront -> {
|
||||||
|
removeAt(0)
|
||||||
|
}
|
||||||
|
is ThreadListUpdate.PushBack -> {
|
||||||
|
add(diffItem.value.map(contentMapper))
|
||||||
|
}
|
||||||
|
is ThreadListUpdate.PushFront -> {
|
||||||
|
add(0, diffItem.value.map(contentMapper))
|
||||||
|
}
|
||||||
|
is ThreadListUpdate.Remove -> {
|
||||||
|
removeAt(diffItem.index.toInt())
|
||||||
|
}
|
||||||
|
is ThreadListUpdate.Reset -> {
|
||||||
|
clear()
|
||||||
|
addAll(diffItem.values.map { it.map(contentMapper) })
|
||||||
|
}
|
||||||
|
is ThreadListUpdate.Set -> {
|
||||||
|
set(diffItem.index.toInt(), diffItem.value.map(contentMapper))
|
||||||
|
}
|
||||||
|
is ThreadListUpdate.Truncate -> {
|
||||||
|
subList(diffItem.length.toInt(), size).clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
|
||||||
|
rootEvent = rootEvent.map(contentMapper),
|
||||||
|
latestEvent = latestEvent?.map(contentMapper),
|
||||||
|
numberOfReplies = numReplies.toLong(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
|
||||||
|
eventId = EventId(eventId),
|
||||||
|
senderId = UserId(sender),
|
||||||
|
isOwn = isOwn,
|
||||||
|
senderProfile = senderProfile.map(),
|
||||||
|
content = content?.let(contentMapper::map),
|
||||||
|
timestamp = timestamp.toLong(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
|
||||||
|
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
|
||||||
|
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||||
|
|
||||||
|
import org.matrix.rustcomponents.sdk.NoHandle
|
||||||
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListItem
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListService
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||||
|
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||||
|
|
||||||
|
class FakeFfiThreadListService(
|
||||||
|
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
|
||||||
|
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
|
||||||
|
private val items: () -> List<ThreadListItem> = { emptyList() },
|
||||||
|
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
|
||||||
|
private val paginate: suspend () -> Unit = {},
|
||||||
|
private val reset: suspend () -> Unit = {},
|
||||||
|
private val destroy: () -> Unit = {},
|
||||||
|
) : ThreadListService(NoHandle) {
|
||||||
|
private var itemsListener: ThreadListEntriesListener? = null
|
||||||
|
private var paginationStateListener: ThreadListPaginationStateListener? = null
|
||||||
|
|
||||||
|
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
|
||||||
|
itemsListener = listener
|
||||||
|
return subscribeToItemsUpdates.invoke(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
|
||||||
|
paginationStateListener = listener
|
||||||
|
return subscribeToPaginationStateUpdates.invoke(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun items(): List<ThreadListItem> = items.invoke()
|
||||||
|
|
||||||
|
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
|
||||||
|
|
||||||
|
override suspend fun paginate() = paginate.invoke()
|
||||||
|
|
||||||
|
override suspend fun reset() = reset.invoke()
|
||||||
|
|
||||||
|
override fun destroy() = destroy.invoke()
|
||||||
|
|
||||||
|
fun emitUpdates(updates: List<ThreadListUpdate>) {
|
||||||
|
itemsListener?.onUpdate(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitPaginationState(state: ThreadListPaginationState) {
|
||||||
|
paginationStateListener?.onUpdate(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.room.threads
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||||
|
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike
|
||||||
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle
|
||||||
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import org.matrix.rustcomponents.sdk.ProfileDetails
|
||||||
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListItem
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListItemEvent
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||||
|
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||||
|
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class RustThreadsListServiceTest {
|
||||||
|
@Test
|
||||||
|
fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest {
|
||||||
|
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||||
|
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
|
||||||
|
val service = createThreadsListService(inner = inner)
|
||||||
|
|
||||||
|
service.subscribeToItemUpdates().test {
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
inner.emitUpdates(listOf(aRustThreadListUpdate()))
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnusedFlow")
|
||||||
|
@Test
|
||||||
|
fun `subscribing to item updates twice only calls the FFI method once`() = runTest {
|
||||||
|
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||||
|
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
|
||||||
|
val service = createThreadsListService(inner = inner)
|
||||||
|
|
||||||
|
service.subscribeToItemUpdates()
|
||||||
|
service.subscribeToItemUpdates()
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest {
|
||||||
|
val subscribeToPaginationUpdatesRecorder = lambdaRecorder<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||||
|
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
|
||||||
|
val service = createThreadsListService(inner = inner)
|
||||||
|
|
||||||
|
service.subscribeToPaginationUpdates().test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
inner.emitPaginationState(ThreadListPaginationState.Loading)
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `paginate calls the FFI method`() = runTest {
|
||||||
|
val paginateRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
|
||||||
|
val service = createThreadsListService(inner = inner)
|
||||||
|
|
||||||
|
service.paginate()
|
||||||
|
|
||||||
|
paginateRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset calls the FFI method`() = runTest {
|
||||||
|
val resetRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val inner = FakeFfiThreadListService(reset = resetRecorder)
|
||||||
|
val service = createThreadsListService(inner = inner)
|
||||||
|
|
||||||
|
service.reset()
|
||||||
|
|
||||||
|
resetRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `destroy calls the FFI method`() = runTest {
|
||||||
|
val destroyRecorder = lambdaRecorder<Unit> {}
|
||||||
|
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
|
||||||
|
val service = createThreadsListService(inner = inner)
|
||||||
|
|
||||||
|
service.destroy()
|
||||||
|
|
||||||
|
destroyRecorder.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createThreadsListService(
|
||||||
|
inner: FakeFfiThreadListService = FakeFfiThreadListService(),
|
||||||
|
) = RustThreadsListService(
|
||||||
|
inner = inner,
|
||||||
|
roomCoroutineScope = backgroundScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
|
||||||
|
values = listOf(
|
||||||
|
ThreadListItem(
|
||||||
|
rootEvent = ThreadListItemEvent(
|
||||||
|
eventId = AN_EVENT_ID.value,
|
||||||
|
timestamp = A_TIMESTAMP.toULong(),
|
||||||
|
sender = A_USER_ID.value,
|
||||||
|
senderProfile = ProfileDetails.Pending,
|
||||||
|
isOwn = true,
|
||||||
|
content = aRustTimelineItemContentMsgLike(),
|
||||||
|
),
|
||||||
|
numReplies = 0u,
|
||||||
|
latestEvent = null,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||||
|
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
|
@ -56,6 +57,7 @@ class FakeJoinedRoom(
|
||||||
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
|
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
|
||||||
MutableStateFlow(RoomNotificationSettingsState.Unknown),
|
MutableStateFlow(RoomNotificationSettingsState.Unknown),
|
||||||
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
|
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
|
||||||
|
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
|
||||||
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||||
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
|
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
|
||||||
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
|
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.test.room.threads
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||||
|
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FakeThreadsListService(
|
||||||
|
private val items: MutableStateFlow<List<ThreadListItem>> = MutableStateFlow(emptyList()),
|
||||||
|
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
|
||||||
|
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
|
||||||
|
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
|
||||||
|
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
|
||||||
|
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
|
||||||
|
private val destroy: () -> Unit = {},
|
||||||
|
) : ThreadsListService {
|
||||||
|
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
|
||||||
|
return subscribeToItemUpdates.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
|
||||||
|
return subscribeToPaginationUpdates.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun paginate(): Result<Unit> {
|
||||||
|
return paginate.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reset(): Result<Unit> {
|
||||||
|
return reset.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun destroy() {
|
||||||
|
return destroy.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun emit(items: List<ThreadListItem>) {
|
||||||
|
this.items.emit(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c523f3a502600b837c07ecd5804831da2d9aba5a74886b7001affbb90169112a
|
||||||
|
size 12455
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:528c2f3183a153b9129606806fc457265819ececd71cf5021d1d843970c0b774
|
||||||
|
size 12366
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4c02999b2d0eba92f1e9b1f5fac52bcff303401f177459d1a439b7db906e5c2a
|
||||||
|
size 64622
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a7f6b64bfe0b47546a009efe23060ba091adbad32288ce546cb3b030d7a9ec88
|
||||||
|
size 66177
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3136c95bc9134eba4cfdfe8d552473a54287c356bd3895291b9cd4ec11969d9c
|
oid sha256:e26887bd81e10726414e1833029b4b51e22e534684a230777ca50f86024af994
|
||||||
size 52706
|
size 56430
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6770e720477c2547f593c626cfe3bdafb9b7c78d0b66e910fb9eb1163730045f
|
oid sha256:ce44cf850169736008a3f3fc21a2be4fb044badfae631b0284ce379b325879df
|
||||||
size 51707
|
size 55533
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue