Skip to content

Commit 7a5a197

Browse files
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 <[email protected]>
1 parent d97b2ab commit 7a5a197

File tree

12 files changed

+290
-17
lines changed

12 files changed

+290
-17
lines changed

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
6363
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
6464
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
6565
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
66+
import io.element.android.libraries.featureflag.api.FeatureFlagService
67+
import io.element.android.libraries.featureflag.api.FeatureFlags
68+
import io.element.android.libraries.matrix.api.core.toThreadId
6669
import io.element.android.libraries.matrix.api.encryption.EncryptionService
6770
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
6871
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@@ -115,6 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
115118
private val permalinkParser: PermalinkParser,
116119
private val analyticsService: AnalyticsService,
117120
private val encryptionService: EncryptionService,
121+
private val featureFlagService: FeatureFlagService,
118122
) : Presenter<MessagesState> {
119123
@AssistedFactory
120124
interface Factory {
@@ -318,8 +322,17 @@ class MessagesPresenter @AssistedInject constructor(
318322
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
319323
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
320324
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
321-
TimelineItemAction.Reply,
322-
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
325+
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
326+
TimelineItemAction.ReplyInThread -> {
327+
val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
328+
if (displayThreads) {
329+
// 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
330+
val threadId = targetEvent.threadInfo.threadRootId ?: targetEvent.eventId!!.toThreadId()
331+
navigator.onOpenThread(threadId, null)
332+
} else {
333+
handleActionReply(targetEvent, composerState, timelineProtectionState)
334+
}
335+
}
323336
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
324337
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
325338
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import io.element.android.libraries.architecture.Presenter
3939
import io.element.android.libraries.dateformatter.api.DateFormatter
4040
import io.element.android.libraries.dateformatter.api.DateFormatterMode
4141
import io.element.android.libraries.di.RoomScope
42+
import io.element.android.libraries.featureflag.api.FeatureFlagService
43+
import io.element.android.libraries.featureflag.api.FeatureFlags
4244
import io.element.android.libraries.matrix.api.core.EventId
4345
import io.element.android.libraries.matrix.api.room.BaseRoom
4446
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -68,6 +70,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
6870
private val room: BaseRoom,
6971
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
7072
private val dateFormatter: DateFormatter,
73+
private val featureFlagService: FeatureFlagService,
7174
) : ActionListPresenter {
7275
@AssistedFactory
7376
@ContributesBinding(RoomScope::class)
@@ -95,6 +98,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
9598
room.roomInfoFlow.map { it.pinnedEventIds }
9699
}.collectAsState(initial = persistentListOf())
97100

101+
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
102+
98103
fun handleEvents(event: ActionListEvents) {
99104
when (event) {
100105
ActionListEvents.Clear -> target.value = ActionListState.Target.None
@@ -104,6 +109,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
104109
isDeveloperModeEnabled = isDeveloperModeEnabled,
105110
pinnedEventIds = pinnedEventIds,
106111
target = target,
112+
isThreadsEnabled = isThreadsEnabled.value,
107113
)
108114
}
109115
}
@@ -119,7 +125,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
119125
usersEventPermissions: UserEventPermissions,
120126
isDeveloperModeEnabled: Boolean,
121127
pinnedEventIds: ImmutableList<EventId>,
122-
target: MutableState<ActionListState.Target>
128+
target: MutableState<ActionListState.Target>,
129+
isThreadsEnabled: Boolean,
123130
) = launch {
124131
target.value = ActionListState.Target.Loading(timelineItem)
125132

@@ -128,6 +135,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
128135
usersEventPermissions = usersEventPermissions,
129136
isDeveloperModeEnabled = isDeveloperModeEnabled,
130137
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
138+
isThreadsEnabled = isThreadsEnabled,
131139
)
132140

133141
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
@@ -155,14 +163,23 @@ class DefaultActionListPresenter @AssistedInject constructor(
155163
usersEventPermissions: UserEventPermissions,
156164
isDeveloperModeEnabled: Boolean,
157165
isEventPinned: Boolean,
166+
isThreadsEnabled: Boolean,
158167
): List<TimelineItemAction> {
159168
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
160169
return buildSet {
161170
if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) {
162-
if (timelineMode !is Timeline.Mode.Thread && timelineItem.threadInfo.threadRootId != null) {
171+
if (isThreadsEnabled && timelineMode !is Timeline.Mode.Thread && timelineItem.isRemote) {
172+
// If threads are enabled, we can reply in thread if the item is remote
163173
add(TimelineItemAction.ReplyInThread)
164-
} else {
165174
add(TimelineItemAction.Reply)
175+
} else {
176+
if (!isThreadsEnabled && timelineItem.threadInfo.threadRootId != null) {
177+
// If threads are not enabled, we can reply in a thread if the item is already in the thread
178+
add(TimelineItemAction.ReplyInThread)
179+
} else {
180+
// Otherwise, we can only reply in the room
181+
add(TimelineItemAction.Reply)
182+
}
166183
}
167184
}
168185
if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) {

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
118118
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
119119
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
120120

121-
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.HideThreadedEvents).collectAsState(false)
121+
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
122122

123123
var pinnedMessageItems by remember {
124124
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ class TimelinePresenter @AssistedInject constructor(
136136
}.collectAsState(initial = true)
137137

138138
val displayThreadSummaries by produceState(false) {
139-
value = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
139+
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
140140
}
141141

142142
fun handleEvents(event: TimelineEvents) {

features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes
4646
import io.element.android.libraries.designsystem.components.avatar.AvatarData
4747
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
4848
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
49+
import io.element.android.libraries.featureflag.api.FeatureFlags
50+
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
4951
import io.element.android.libraries.matrix.api.core.EventId
5052
import io.element.android.libraries.matrix.api.core.RoomId
53+
import io.element.android.libraries.matrix.api.core.ThreadId
5154
import io.element.android.libraries.matrix.api.core.UserId
55+
import io.element.android.libraries.matrix.api.core.toThreadId
5256
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
5357
import io.element.android.libraries.matrix.api.media.MediaSource
5458
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@@ -57,6 +61,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
5761
import io.element.android.libraries.matrix.api.room.RoomMembershipState
5862
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
5963
import io.element.android.libraries.matrix.api.timeline.Timeline
64+
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
6065
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
6166
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
6267
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -67,6 +72,7 @@ import io.element.android.libraries.matrix.test.A_CAPTION
6772
import io.element.android.libraries.matrix.test.A_ROOM_ID
6873
import io.element.android.libraries.matrix.test.A_SESSION_ID
6974
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
75+
import io.element.android.libraries.matrix.test.A_THREAD_ID
7076
import io.element.android.libraries.matrix.test.A_USER_ID
7177
import io.element.android.libraries.matrix.test.A_USER_ID_2
7278
import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -1158,6 +1164,74 @@ class MessagesPresenterTest {
11581164
}
11591165
}
11601166

1167+
@Test
1168+
fun `present - handle action reply in thread for an event in a thread`() = runTest {
1169+
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
1170+
val presenter = createMessagesPresenter(
1171+
navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda),
1172+
featureFlagService = FakeFeatureFlagService(
1173+
initialState = mapOf(FeatureFlags.Threads.key to true)
1174+
),
1175+
)
1176+
presenter.testWithLifecycleOwner {
1177+
val initialState = awaitItem()
1178+
initialState.eventSink(MessagesEvents.HandleAction(
1179+
action = TimelineItemAction.ReplyInThread,
1180+
event = aMessageEvent(threadInfo = EventThreadInfo(A_THREAD_ID, null))
1181+
))
1182+
awaitItem()
1183+
openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null))
1184+
}
1185+
}
1186+
1187+
@Test
1188+
fun `present - handle action reply in thread to start a new thread`() = runTest {
1189+
val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> }
1190+
val presenter = createMessagesPresenter(
1191+
navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda),
1192+
featureFlagService = FakeFeatureFlagService(
1193+
initialState = mapOf(FeatureFlags.Threads.key to true)
1194+
),
1195+
)
1196+
presenter.testWithLifecycleOwner {
1197+
val initialState = awaitItem()
1198+
initialState.eventSink(MessagesEvents.HandleAction(
1199+
action = TimelineItemAction.ReplyInThread,
1200+
event = aMessageEvent(
1201+
// The event id will be used as the thread id instead
1202+
eventId = AN_EVENT_ID,
1203+
threadInfo = EventThreadInfo(null, null),
1204+
)
1205+
))
1206+
awaitItem()
1207+
openThreadLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toThreadId()), value(null))
1208+
}
1209+
}
1210+
1211+
@Test
1212+
fun `present - handle action reply in a thread with threads disabled`() = runTest {
1213+
val composerRecorder = EventsRecorder<MessageComposerEvents>()
1214+
val presenter = createMessagesPresenter(
1215+
featureFlagService = FakeFeatureFlagService(
1216+
initialState = mapOf(FeatureFlags.Threads.key to false)
1217+
),
1218+
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
1219+
)
1220+
presenter.testWithLifecycleOwner {
1221+
val initialState = awaitItem()
1222+
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReplyInThread, aMessageEvent()))
1223+
awaitItem()
1224+
composerRecorder.assertSingle(
1225+
MessageComposerEvents.SetMode(
1226+
composerMode = MessageComposerMode.Reply(
1227+
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
1228+
hideImage = false,
1229+
)
1230+
)
1231+
)
1232+
}
1233+
}
1234+
11611235
private fun TestScope.createMessagesPresenter(
11621236
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
11631237
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
@@ -1189,6 +1263,7 @@ class MessagesPresenterTest {
11891263
aRoomMemberModerationState()
11901264
},
11911265
encryptionService: FakeEncryptionService = FakeEncryptionService(),
1266+
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
11921267
actionListEventSink: (ActionListEvents) -> Unit = {},
11931268
): MessagesPresenter {
11941269
return MessagesPresenter(
@@ -1217,6 +1292,7 @@ class MessagesPresenterTest {
12171292
permalinkParser = permalinkParser,
12181293
encryptionService = encryptionService,
12191294
analyticsService = analyticsService,
1295+
featureFlagService = featureFlagService,
12201296
)
12211297
}
12221298
}

0 commit comments

Comments
 (0)