Threads - first iteration (#5165)

* Initial threads support: parse `ThreadSummary`.

Replace several `isThreaded` values with `EventThreadInfo`, which contains the info about the event either being the root of a thread or part of it.

* Add `Threaded` timeline mode

* Add a `liveTimeline` parameter to `TimelineController`'s  constructor. This way we can customise which timeline will be used as the 'live' one. Also add `@LiveTimeline` DI qualifier for the actual live timeline of the room.

* Create `ThreadedMessagesNode`. Allow opening a thread in a separate screen.

* Add the callbacks for the list menu actions - even if they're the wrong ones and will send the data to the room instead

* Send attachments and location in threads

* Fix polls in threads, add support for sending voice messages in threads

* Display thread summaries only when the feature flag is enabled

* Use 'Reply' instead of 'Reply in thread' when in threaded timeline mode

* Remove incorrect usage of `Timeline` in `MessageComposerPresenter`. This led to replies to threaded events not appearing as actual replies.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2025-08-19 15:35:48 +02:00
committed by GitHub
parent 8675bf5215
commit 285066c206
119 changed files with 1520 additions and 339 deletions

View File

@@ -8,7 +8,8 @@
package io.element.android.features.poll.api.actions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
interface EndPollAction {
suspend fun execute(pollStartId: EventId): Result<Unit>
suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit>
}

View File

@@ -8,7 +8,12 @@
package io.element.android.features.poll.api.actions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
interface SendPollResponseAction {
suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit>
suspend fun execute(
timeline: Timeline,
pollStartId: EventId,
answerId: String
): Result<Unit>
}

View File

@@ -10,9 +10,11 @@ package io.element.android.features.poll.api.create
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.timeline.Timeline
interface CreatePollEntryPoint : FeatureEntryPoint {
data class Params(
val timelineMode: Timeline.Mode,
val mode: CreatePollMode,
)

View File

@@ -12,17 +12,16 @@ import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultEndPollAction @Inject constructor(
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
) : EndPollAction {
override suspend fun execute(pollStartId: EventId): Result<Unit> {
return room.liveTimeline.endPoll(
override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
return timeline.endPoll(
pollStartId = pollStartId,
text = "The poll with event id: $pollStartId has ended."
).onSuccess {

View File

@@ -12,17 +12,16 @@ import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultSendPollResponseAction @Inject constructor(
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
) : SendPollResponseAction {
override suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> {
return room.liveTimeline.sendPollResponse(
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
return timeline.sendPollResponse(
pollStartId = pollStartId,
answers = listOf(answerId),
).onSuccess {

View File

@@ -21,6 +21,7 @@ import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
import java.util.concurrent.atomic.AtomicBoolean
@@ -31,7 +32,7 @@ class CreatePollNode @AssistedInject constructor(
presenterFactory: CreatePollPresenter.Factory,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val mode: CreatePollMode) : NodeInputs
data class Inputs(val mode: CreatePollMode, val timelineMode: Timeline.Mode) : NodeInputs
private val inputs: Inputs = inputs()
@@ -44,6 +45,7 @@ class CreatePollNode @AssistedInject constructor(
}
},
mode = inputs.mode,
timelineMode = inputs.timelineMode,
)
init {

View File

@@ -29,6 +29,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -37,17 +38,24 @@ import kotlinx.coroutines.launch
import timber.log.Timber
class CreatePollPresenter @AssistedInject constructor(
private val repository: PollRepository,
repositoryFactory: PollRepository.Factory,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit,
@Assisted private val mode: CreatePollMode,
@Assisted private val timelineMode: Timeline.Mode,
) : Presenter<CreatePollState> {
@AssistedFactory
interface Factory {
fun create(backNavigator: () -> Unit, mode: CreatePollMode): CreatePollPresenter
fun create(
timelineMode: Timeline.Mode,
backNavigator: () -> Unit,
mode: CreatePollMode
): CreatePollPresenter
}
private val repository = repositoryFactory.create(timelineMode)
@Composable
override fun present(): CreatePollState {
// The initial state of the form. In edit mode this will be populated with the poll being edited.

View File

@@ -23,7 +23,7 @@ class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
return object : CreatePollEntryPoint.NodeBuilder {
override fun params(params: CreatePollEntryPoint.Params): CreatePollEntryPoint.NodeBuilder {
plugins += CreatePollNode.Inputs(mode = params.mode)
plugins += CreatePollNode.Inputs(timelineMode = params.timelineMode, mode = params.mode)
return this
}

View File

@@ -7,24 +7,40 @@
package io.element.android.features.poll.impl.data
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class PollRepository @Inject constructor(
class PollRepository @AssistedInject constructor(
private val room: JoinedRoom,
private val timelineProvider: TimelineProvider,
private val defaultTimelineProvider: TimelineProvider,
@Assisted private val timelineMode: Timeline.Mode,
) {
@AssistedFactory
interface Factory {
fun create(
timelineMode: Timeline.Mode,
): PollRepository
}
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatchingExceptions {
timelineProvider
getTimelineProvider()
.getOrThrow()
.getActiveTimeline()
.timelineItems
.first()
@@ -42,30 +58,51 @@ class PollRepository @Inject constructor(
pollKind: PollKind,
maxSelections: Int,
): Result<Unit> = when (existingPollId) {
null -> room.liveTimeline.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
else -> timelineProvider
.getActiveTimeline()
.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
null -> getTimelineProvider().flatMap { timelineProvider ->
timelineProvider
.getActiveTimeline()
.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
else -> getTimelineProvider().flatMap { timelineProvider ->
timelineProvider.getActiveTimeline()
.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
}
suspend fun deletePoll(
pollStartId: EventId,
): Result<Unit> =
timelineProvider
.getActiveTimeline()
.redactEvent(
eventOrTransactionId = pollStartId.toEventOrTransactionId(),
reason = null,
)
getTimelineProvider().flatMap { timelineProvider ->
timelineProvider.getActiveTimeline()
.redactEvent(
eventOrTransactionId = pollStartId.toEventOrTransactionId(),
reason = null,
)
}
private suspend fun getTimelineProvider(): Result<TimelineProvider> {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
threadedTimelineResult.map { threadedTimeline ->
object : TimelineProvider {
private val flow = MutableStateFlow<Timeline?>(threadedTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline?> = flow
}
}
}
else -> Result.success(defaultTimelineProvider)
}
}
}

View File

@@ -25,6 +25,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@@ -52,7 +53,12 @@ class PollHistoryFlowNode @AssistedInject constructor(
return when (navTarget) {
is NavTarget.EditPoll -> {
createPollEntryPoint.nodeBuilder(this, buildContext)
.params(CreatePollEntryPoint.Params(mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId)))
.params(
CreatePollEntryPoint.Params(
timelineMode = Timeline.Mode.Live,
mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId)
)
)
.build()
}
NavTarget.Root -> {

View File

@@ -67,10 +67,14 @@ class PollHistoryPresenter @Inject constructor(
coroutineScope.loadMore(timeline)
}
is PollHistoryEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute(pollStartId = event.pollStartId, answerId = event.answerId)
sendPollResponseAction.execute(
timeline = timeline,
pollStartId = event.pollStartId,
answerId = event.answerId
)
}
is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute(pollStartId = event.pollStartId)
endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
}
is PollHistoryEvents.SelectFilter -> {
activeFilter = event.filter

View File

@@ -21,6 +21,7 @@ import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -551,12 +552,18 @@ class CreatePollPresenterTest {
private fun createCreatePollPresenter(
mode: CreatePollMode = CreatePollMode.NewPoll,
room: FakeJoinedRoom = fakeJoinedRoom,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
): CreatePollPresenter = CreatePollPresenter(
repository = PollRepository(room, LiveTimelineProvider(room)),
repositoryFactory = object : PollRepository.Factory {
override fun create(timelineMode: Timeline.Mode): PollRepository {
return PollRepository(room, LiveTimelineProvider(room), timelineMode)
}
},
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ },
mode = mode,
timelineMode = timelineMode,
)
}

View File

@@ -9,6 +9,7 @@ package io.element.android.features.poll.test.actions
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
class FakeEndPollAction : EndPollAction {
private var executionCount = 0
@@ -17,7 +18,7 @@ class FakeEndPollAction : EndPollAction {
assert(executionCount == count)
}
override suspend fun execute(pollStartId: EventId): Result<Unit> {
override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
executionCount++
return Result.success(Unit)
}

View File

@@ -9,6 +9,7 @@ package io.element.android.features.poll.test.actions
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
class FakeSendPollResponseAction : SendPollResponseAction {
private var executionCount = 0
@@ -17,7 +18,7 @@ class FakeSendPollResponseAction : SendPollResponseAction {
assert(executionCount == count)
}
override suspend fun execute(pollStartId: EventId, answerId: String): Result<Unit> {
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
executionCount++
return Result.success(Unit)
}