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:
Jorge Martin Espinosa 2025-08-22 16:07:13 +02:00 committed by GitHub
parent d97b2ab79c
commit 7a5a197e7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 290 additions and 17 deletions

View file

@ -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.SnackbarMessage
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.identity.IdentityState
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@ -115,6 +118,7 @@ class MessagesPresenter @AssistedInject constructor(
private val permalinkParser: PermalinkParser,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
) : Presenter<MessagesState> {
@AssistedFactory
interface Factory {
@ -318,8 +322,17 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.Reply -> 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.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)

View file

@ -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.DateFormatterMode
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.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
@ -68,6 +70,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val room: BaseRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
private val dateFormatter: DateFormatter,
private val featureFlagService: FeatureFlagService,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@ -95,6 +98,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
room.roomInfoFlow.map { it.pinnedEventIds }
}.collectAsState(initial = persistentListOf())
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
fun handleEvents(event: ActionListEvents) {
when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None
@ -104,6 +109,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
isDeveloperModeEnabled = isDeveloperModeEnabled,
pinnedEventIds = pinnedEventIds,
target = target,
isThreadsEnabled = isThreadsEnabled.value,
)
}
}
@ -119,7 +125,8 @@ class DefaultActionListPresenter @AssistedInject constructor(
usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean,
pinnedEventIds: ImmutableList<EventId>,
target: MutableState<ActionListState.Target>
target: MutableState<ActionListState.Target>,
isThreadsEnabled: Boolean,
) = launch {
target.value = ActionListState.Target.Loading(timelineItem)
@ -128,6 +135,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
usersEventPermissions = usersEventPermissions,
isDeveloperModeEnabled = isDeveloperModeEnabled,
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
isThreadsEnabled = isThreadsEnabled,
)
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
@ -155,14 +163,23 @@ class DefaultActionListPresenter @AssistedInject constructor(
usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean,
isEventPinned: Boolean,
isThreadsEnabled: Boolean,
): List<TimelineItemAction> {
val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther
return buildSet {
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)
} else {
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()) {

View file

@ -118,7 +118,7 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
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 {
mutableStateOf<AsyncData<ImmutableList<TimelineItem>>>(AsyncData.Uninitialized)

View file

@ -136,7 +136,7 @@ class TimelinePresenter @AssistedInject constructor(
}.collectAsState(initial = true)
val displayThreadSummaries by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
}
fun handleEvents(event: TimelineEvents) {