Allow replying to any remote message in a thread (#5201)
* Allow replying to any remote message in a thread. This will open the thread screen based on the selected event: - If it was already part of a thread, it will open that thread. - Otherwise, it'll open the thread timeline screen so you can start a thread from the event. * Add the feature flag to decide which action to perform. Also, rename the feature flag to something easier to understand. * Display the reply in thread action based on the feature flag too --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
52a94f79eb
commit
e9f065c479
12 changed files with 290 additions and 17 deletions
|
|
@ -63,6 +63,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
|
|
@ -115,6 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : Presenter<MessagesState> {
|
) : Presenter<MessagesState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -318,8 +322,17 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
|
||||||
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
|
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
|
||||||
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
|
||||||
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
|
TimelineItemAction.ReplyInThread -> {
|
||||||
|
val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||||
|
if (displayThreads) {
|
||||||
|
// Get either the thread id this event is in, or the event id if it's not in a thread so we can start one
|
||||||
|
val threadId = targetEvent.threadInfo.threadRootId ?: targetEvent.eventId!!.toThreadId()
|
||||||
|
navigator.onOpenThread(threadId, null)
|
||||||
|
} else {
|
||||||
|
handleActionReply(targetEvent, composerState, timelineProtectionState)
|
||||||
|
}
|
||||||
|
}
|
||||||
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
|
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
|
||||||
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||||
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
|
@ -68,6 +70,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
private val room: BaseRoom,
|
private val room: BaseRoom,
|
||||||
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
|
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
|
||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : ActionListPresenter {
|
) : ActionListPresenter {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
@ContributesBinding(RoomScope::class)
|
@ContributesBinding(RoomScope::class)
|
||||||
|
|
@ -95,6 +98,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
room.roomInfoFlow.map { it.pinnedEventIds }
|
room.roomInfoFlow.map { it.pinnedEventIds }
|
||||||
}.collectAsState(initial = persistentListOf())
|
}.collectAsState(initial = persistentListOf())
|
||||||
|
|
||||||
|
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||||
|
|
||||||
fun handleEvents(event: ActionListEvents) {
|
fun handleEvents(event: ActionListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
||||||
|
|
@ -104,6 +109,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
pinnedEventIds = pinnedEventIds,
|
pinnedEventIds = pinnedEventIds,
|
||||||
target = target,
|
target = target,
|
||||||
|
isThreadsEnabled = isThreadsEnabled.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +125,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
usersEventPermissions: UserEventPermissions,
|
usersEventPermissions: UserEventPermissions,
|
||||||
isDeveloperModeEnabled: Boolean,
|
isDeveloperModeEnabled: Boolean,
|
||||||
pinnedEventIds: ImmutableList<EventId>,
|
pinnedEventIds: ImmutableList<EventId>,
|
||||||
target: MutableState<ActionListState.Target>
|
target: MutableState<ActionListState.Target>,
|
||||||
|
isThreadsEnabled: Boolean,
|
||||||
) = launch {
|
) = launch {
|
||||||
target.value = ActionListState.Target.Loading(timelineItem)
|
target.value = ActionListState.Target.Loading(timelineItem)
|
||||||
|
|
||||||
|
|
@ -128,6 +135,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
usersEventPermissions = usersEventPermissions,
|
usersEventPermissions = usersEventPermissions,
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
|
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
|
||||||
|
isThreadsEnabled = isThreadsEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
|
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
|
||||||
|
|
@ -155,14 +163,23 @@ class DefaultActionListPresenter @AssistedInject constructor(
|
||||||
usersEventPermissions: UserEventPermissions,
|
usersEventPermissions: UserEventPermissions,
|
||||||
isDeveloperModeEnabled: Boolean,
|
isDeveloperModeEnabled: Boolean,
|
||||||
isEventPinned: Boolean,
|
isEventPinned: Boolean,
|
||||||
|
isThreadsEnabled: Boolean,
|
||||||
): List<TimelineItemAction> {
|
): List<TimelineItemAction> {
|
||||||
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
|
||||||
return buildSet {
|
return buildSet {
|
||||||
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
|
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
|
||||||
if (timelineMode !is Timeline.Mode.Thread && timelineItem.threadInfo.threadRootId != null) {
|
if (isThreadsEnabled && timelineMode !is Timeline.Mode.Thread && timelineItem.isRemote) {
|
||||||
|
// If threads are enabled, we can reply in thread if the item is remote
|
||||||
add(TimelineItemAction.ReplyInThread)
|
add(TimelineItemAction.ReplyInThread)
|
||||||
} else {
|
|
||||||
add(TimelineItemAction.Reply)
|
add(TimelineItemAction.Reply)
|
||||||
|
} else {
|
||||||
|
if (!isThreadsEnabled && timelineItem.threadInfo.threadRootId != null) {
|
||||||
|
// If threads are not enabled, we can reply in a thread if the item is already in the thread
|
||||||
|
add(TimelineItemAction.ReplyInThread)
|
||||||
|
} else {
|
||||||
|
// Otherwise, we can only reply in the room
|
||||||
|
add(TimelineItemAction.Reply)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
|
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
|
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
|
||||||
|
|
||||||
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.HideThreadedEvents).collectAsState(false)
|
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
|
||||||
|
|
||||||
var pinnedMessageItems by remember {
|
var pinnedMessageItems by remember {
|
||||||
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
|
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||||
}.collectAsState(initial = true)
|
}.collectAsState(initial = true)
|
||||||
|
|
||||||
val displayThreadSummaries by produceState(false) {
|
val displayThreadSummaries by produceState(false) {
|
||||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
|
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
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.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||||
|
|
@ -57,6 +61,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||||
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.timeline.item.EventThreadInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||||
|
|
@ -67,6 +72,7 @@ import io.element.android.libraries.matrix.test.A_CAPTION
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
|
@ -1158,6 +1164,74 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action reply in thread for an event in a thread`() = runTest {
|
||||||
|
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda),
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.Threads.key to true)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.HandleAction(
|
||||||
|
action = TimelineItemAction.ReplyInThread,
|
||||||
|
event = aMessageEvent(threadInfo = EventThreadInfo(A_THREAD_ID, null))
|
||||||
|
))
|
||||||
|
awaitItem()
|
||||||
|
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action reply in thread to start a new thread`() = runTest {
|
||||||
|
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda),
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.Threads.key to true)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.HandleAction(
|
||||||
|
action = TimelineItemAction.ReplyInThread,
|
||||||
|
event = aMessageEvent(
|
||||||
|
// The event id will be used as the thread id instead
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
threadInfo = EventThreadInfo(null, null),
|
||||||
|
)
|
||||||
|
))
|
||||||
|
awaitItem()
|
||||||
|
openThreadLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toThreadId()), value(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action reply in a thread with threads disabled`() = runTest {
|
||||||
|
val composerRecorder = EventsRecorder<MessageComposerEvents>()
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.Threads.key to false)
|
||||||
|
),
|
||||||
|
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||||
|
)
|
||||||
|
presenter.testWithLifecycleOwner {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReplyInThread, aMessageEvent()))
|
||||||
|
awaitItem()
|
||||||
|
composerRecorder.assertSingle(
|
||||||
|
MessageComposerEvents.SetMode(
|
||||||
|
composerMode = MessageComposerMode.Reply(
|
||||||
|
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
|
||||||
|
hideImage = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createMessagesPresenter(
|
private fun TestScope.createMessagesPresenter(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
|
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
|
||||||
|
|
@ -1189,6 +1263,7 @@ class MessagesPresenterTest {
|
||||||
aRoomMemberModerationState()
|
aRoomMemberModerationState()
|
||||||
},
|
},
|
||||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||||
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
actionListEventSink: (ActionListEvents) -> Unit = {},
|
actionListEventSink: (ActionListEvents) -> Unit = {},
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
|
|
@ -1217,6 +1292,7 @@ class MessagesPresenterTest {
|
||||||
permalinkParser = permalinkParser,
|
permalinkParser = permalinkParser,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||||
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
|
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
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.timeline.item.EventThreadInfo
|
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||||
|
|
@ -35,6 +37,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
|
|
@ -1245,8 +1248,12 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute for threaded timeline`() = runTest {
|
fun `present - compute for threaded timeline with threads enabled`() = runTest {
|
||||||
val presenter = createActionListPresenter(isDeveloperModeEnabled = false, timelineMode = Timeline.Mode.Thread(A_THREAD_ID))
|
val presenter = createActionListPresenter(
|
||||||
|
isDeveloperModeEnabled = false,
|
||||||
|
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
|
||||||
|
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -1290,12 +1297,171 @@ class ActionListPresenterTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute for remote timeline item with threads enabled`() = runTest {
|
||||||
|
val presenter = createActionListPresenter(
|
||||||
|
isDeveloperModeEnabled = false,
|
||||||
|
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
isMine = true,
|
||||||
|
isEditable = false,
|
||||||
|
content = aTimelineItemVoiceContent(
|
||||||
|
caption = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(messageEvent.isRemote).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink.invoke(
|
||||||
|
ActionListEvents.ComputeForMessage(
|
||||||
|
event = messageEvent,
|
||||||
|
userEventPermissions = aUserEventPermissions(
|
||||||
|
canRedactOwn = true,
|
||||||
|
canRedactOther = false,
|
||||||
|
canSendMessage = true,
|
||||||
|
canSendReaction = true,
|
||||||
|
canPinUnpin = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
event = messageEvent,
|
||||||
|
sentTimeFull = "0 Full true",
|
||||||
|
displayEmojiReactions = true,
|
||||||
|
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||||
|
actions = persistentListOf(
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.ReplyInThread,
|
||||||
|
TimelineItemAction.Forward,
|
||||||
|
TimelineItemAction.CopyLink,
|
||||||
|
TimelineItemAction.Pin,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute for remote timeline item already in thread with threads enabled`() = runTest {
|
||||||
|
val presenter = createActionListPresenter(
|
||||||
|
isDeveloperModeEnabled = false,
|
||||||
|
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
isMine = true,
|
||||||
|
isEditable = false,
|
||||||
|
content = aTimelineItemVoiceContent(
|
||||||
|
caption = null,
|
||||||
|
),
|
||||||
|
threadInfo = EventThreadInfo(A_THREAD_ID, null),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(messageEvent.isRemote).isTrue()
|
||||||
|
|
||||||
|
initialState.eventSink.invoke(
|
||||||
|
ActionListEvents.ComputeForMessage(
|
||||||
|
event = messageEvent,
|
||||||
|
userEventPermissions = aUserEventPermissions(
|
||||||
|
canRedactOwn = true,
|
||||||
|
canRedactOther = false,
|
||||||
|
canSendMessage = true,
|
||||||
|
canSendReaction = true,
|
||||||
|
canPinUnpin = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
event = messageEvent,
|
||||||
|
sentTimeFull = "0 Full true",
|
||||||
|
displayEmojiReactions = true,
|
||||||
|
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||||
|
actions = persistentListOf(
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.ReplyInThread,
|
||||||
|
TimelineItemAction.Forward,
|
||||||
|
TimelineItemAction.CopyLink,
|
||||||
|
TimelineItemAction.Pin,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute for local timeline item with threads enabled`() = runTest {
|
||||||
|
val presenter = createActionListPresenter(
|
||||||
|
isDeveloperModeEnabled = false,
|
||||||
|
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
eventId = null,
|
||||||
|
transactionId = A_TRANSACTION_ID,
|
||||||
|
isMine = true,
|
||||||
|
isEditable = false,
|
||||||
|
content = aTimelineItemVoiceContent(
|
||||||
|
caption = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(messageEvent.isRemote).isFalse()
|
||||||
|
|
||||||
|
initialState.eventSink.invoke(
|
||||||
|
ActionListEvents.ComputeForMessage(
|
||||||
|
event = messageEvent,
|
||||||
|
userEventPermissions = aUserEventPermissions(
|
||||||
|
canRedactOwn = true,
|
||||||
|
canRedactOther = false,
|
||||||
|
canSendMessage = true,
|
||||||
|
canSendReaction = true,
|
||||||
|
canPinUnpin = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
event = messageEvent,
|
||||||
|
sentTimeFull = "0 Full true",
|
||||||
|
displayEmojiReactions = true,
|
||||||
|
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||||
|
actions = persistentListOf(
|
||||||
|
// Can't reply in thread for local events
|
||||||
|
TimelineItemAction.Reply,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createActionListPresenter(
|
private fun createActionListPresenter(
|
||||||
isDeveloperModeEnabled: Boolean,
|
isDeveloperModeEnabled: Boolean,
|
||||||
room: BaseRoom = FakeBaseRoom(),
|
room: BaseRoom = FakeBaseRoom(),
|
||||||
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
timelineMode: Timeline.Mode = Timeline.Mode.Live,
|
||||||
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
): ActionListPresenter {
|
): ActionListPresenter {
|
||||||
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
|
val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled)
|
||||||
return DefaultActionListPresenter(
|
return DefaultActionListPresenter(
|
||||||
|
|
@ -1305,5 +1471,6 @@ private fun createActionListPresenter(
|
||||||
userSendFailureFactory = VerifiedUserSendFailureFactory(room),
|
userSendFailureFactory = VerifiedUserSendFailureFactory(room),
|
||||||
dateFormatter = FakeDateFormatter(),
|
dateFormatter = FakeDateFormatter(),
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ enum class FeatureFlags(
|
||||||
// False so it's displayed in the developer options screen
|
// False so it's displayed in the developer options screen
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
HideThreadedEvents(
|
Threads(
|
||||||
key = "feature.thread_timeline",
|
key = "feature.thread_timeline",
|
||||||
title = "Threads",
|
title = "Threads",
|
||||||
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
|
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ class RustMatrixClientFactory @Inject constructor(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite))
|
.enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite))
|
||||||
.threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents), threadSubscriptions = false)
|
.threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.Threads), threadSubscriptions = false)
|
||||||
.run {
|
.run {
|
||||||
// Apply sliding sync version settings
|
// Apply sliding sync version settings
|
||||||
when (slidingSyncType) {
|
when (slidingSyncType) {
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ class JoinedRustRoom(
|
||||||
override suspend fun createTimeline(
|
override suspend fun createTimeline(
|
||||||
createTimelineParams: CreateTimelineParams,
|
createTimelineParams: CreateTimelineParams,
|
||||||
): Result<Timeline> = withContext(roomDispatcher) {
|
): Result<Timeline> = withContext(roomDispatcher) {
|
||||||
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
|
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||||
val focus = when (createTimelineParams) {
|
val focus = when (createTimelineParams) {
|
||||||
is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents(
|
is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents(
|
||||||
maxEventsToLoad = 100u,
|
maxEventsToLoad = 100u,
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ class RustRoomFactory(
|
||||||
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
|
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
|
||||||
|
|
||||||
if (sdkRoom.membership() == Membership.JOINED) {
|
if (sdkRoom.membership() == Membership.JOINED) {
|
||||||
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
|
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
|
||||||
// Init the live timeline in the SDK from the Room
|
// Init the live timeline in the SDK from the Room
|
||||||
val timeline = sdkRoom.timelineWithConfiguration(
|
val timeline = sdkRoom.timelineWithConfiguration(
|
||||||
TimelineConfiguration(
|
TimelineConfiguration(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6e9f6f4fe633ca50c2a27cc2e2a0157880f698f91cf759a35fa3f4d49b805905
|
oid sha256:8da76de0773fc62f7eb73dd16756d6ccad8229db121eb39d43f7c27d4ab8b48b
|
||||||
size 31332
|
size 31339
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3f837b094f91617dba15cbaab3336aa501b0a834299a07fc6673c9b6b89b8905
|
oid sha256:6b5b92e446d5968fb115e49b2b2337a46a9a7f43577413924e7a1b1f3b752646
|
||||||
size 30702
|
size 30692
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue