Skip to content

Allow replying to any remote message in a thread #5201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -104,6 +109,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
isDeveloperModeEnabled = isDeveloperModeEnabled,
pinnedEventIds = pinnedEventIds,
target = target,
isThreadsEnabled = isThreadsEnabled.value,
)
}
}
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.AvatarSize
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.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.toThreadId
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.permalink.PermalinkParser
Expand All @@ -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.tombstone.SuccessorRoom
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.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
Expand All @@ -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_SESSION_ID
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_2
import io.element.android.libraries.matrix.test.core.aBuildMeta
Expand Down Expand Up @@ -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(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
Expand Down Expand Up @@ -1189,6 +1263,7 @@ class MessagesPresenterTest {
aRoomMemberModerationState()
},
encryptionService: FakeEncryptionService = FakeEncryptionService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
actionListEventSink: (ActionListEvents) -> Unit = {},
): MessagesPresenter {
return MessagesPresenter(
Expand Down Expand Up @@ -1217,6 +1292,7 @@ class MessagesPresenterTest {
permalinkParser = permalinkParser,
encryptionService = encryptionService,
analyticsService = analyticsService,
featureFlagService = featureFlagService,
)
}
}
Loading
Loading