Introduce a TimelineItemThreadSummary object (#4032)

* Introduce a `TimelineItemThreadSummary` object to hold details about threads starting from that particular item

This patch introduces a thread summary object that will be available on main timeline messages that are the root for a given thread.
It currently provides the latest message content and sender for that thread but it will grow to provide info on the number of replies, unreads etc.

It also add a new UI component called `TimelineThreadSummaryView` that makes use of this data and is in turn used by the bubbled styler to render it in the timeline.

The rest of the PR is about refactoring on the `RoomTimelineItemFactory` so that replies and thread summaries use similar paths and builders.

* Add a feature flag for threads

* Address PR comments

* Converge on single implementation for message previews
This commit is contained in:
Stefan Ceriu
2025-04-16 16:19:29 +03:00
committed by GitHub
parent 8c9773e4de
commit 806d676299
34 changed files with 689 additions and 134 deletions
@@ -119,6 +119,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
// Workaround to stop the message long press stealing the touch from the reaction buttons
.onTapGesture { }
}
if let threadSummary = timelineItem.properties.threadSummary {
TimelineThreadSummaryView(threadSummary: threadSummary) {
context.send(viewAction: .displayThread(itemID: timelineItem.id))
}
.padding(5)
}
}
}
@@ -340,10 +347,14 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
.padding(.bottom, 20)
replies
.previewDisplayName("Replies")
threads
threadDecorator
.previewDisplayName("Thread decorator")
.previewLayout(.fixed(width: 390, height: 1700))
.padding(.bottom, 20)
threadSummary
.previewDisplayName("Thread summary")
.previewLayout(.fixed(width: 390, height: 1700))
.padding(.bottom, 20)
encryptionAuthenticity
.previewDisplayName("Encryption Indicators")
pinned
@@ -395,13 +406,25 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
.environment(\.timelineContext, viewModel.context)
}
static var threads: some View {
static var threadDecorator: some View {
ScrollView {
MockTimelineContent(isThreaded: true)
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
static var threadSummary: some View {
ScrollView {
let threadSummary = TimelineItemThreadSummary.loaded(senderID: "@alice:matrix.org",
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
latestEventContent: .message(.text(.init(body: "This is a threaded message"))))
MockTimelineContent(threadSummary: threadSummary)
}
.environmentObject(viewModelWithPins.context)
.environment(\.timelineContext, viewModel.context)
}
static var pinned: some View {
ScrollView {
@@ -493,6 +516,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
private struct MockTimelineContent: View {
var isThreaded = false
var isPinned = false
var threadSummary: TimelineItemThreadSummary?
var body: some View {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: makeItemIdentifier(),
@@ -502,7 +526,9 @@ private struct MockTimelineContent: View {
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)),
groupStyle: .single))
AudioRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
@@ -517,7 +543,9 @@ private struct MockTimelineContent: View {
source: nil,
fileSize: nil,
contentType: nil),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
FileRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
@@ -531,7 +559,9 @@ private struct MockTimelineContent: View {
fileSize: nil,
thumbnailSource: nil,
contentType: nil),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
ImageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
@@ -542,7 +572,9 @@ private struct MockTimelineContent: View {
content: .init(filename: "image.jpg",
imageInfo: .mockImage,
thumbnailInfo: nil),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
@@ -554,7 +586,9 @@ private struct MockTimelineContent: View {
geoURI: .init(latitude: 41.902782,
longitude: 12.496366),
description: "Location description description description description description description description description"),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
@@ -564,7 +598,9 @@ private struct MockTimelineContent: View {
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
VoiceMessageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
@@ -578,7 +614,9 @@ private struct MockTimelineContent: View {
source: nil,
fileSize: nil,
contentType: nil),
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)),
playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent),
title: L10n.commonVoiceMessage,
duration: 10,