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
@@ -13,6 +13,8 @@ struct RoomTimelineItemProperties: Hashable {
var replyDetails: TimelineItemReplyDetails?
// Whether it's part of a thread or not
var isThreaded = false
// Information about the thread this message is the root of, if any
var threadSummary: TimelineItemThreadSummary?
/// Whether the item has been edited.
var isEdited = false
/// The aggregated reactions that have been sent for this item.
@@ -0,0 +1,14 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
enum TimelineEventContent: Hashable {
case message(EventBasedMessageTimelineItemContentType)
case poll(question: String)
case redacted
}
@@ -25,9 +25,3 @@ enum TimelineItemReplyDetails: Hashable {
}
}
}
enum TimelineEventContent: Hashable {
case message(EventBasedMessageTimelineItemContentType)
case poll(question: String)
case redacted
}
@@ -0,0 +1,15 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import MatrixRustSDK
enum TimelineItemThreadSummary: Hashable {
case notLoaded
case loading
case loaded(senderID: String, sender: TimelineItemSender, latestEventContent: TimelineEventContent)
case error(message: String)
}
@@ -17,13 +17,13 @@ struct LocationRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatabl
let sender: TimelineItemSender
let content: LocationRoomTimelineItemContent
var properties = RoomTimelineItemProperties()
var body: String {
content.body
}
var properties = RoomTimelineItemProperties()
var contentType: EventBasedMessageTimelineItemContentType {
.location(content)
}
@@ -114,8 +114,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender,
content: buildTextTimelineItemContent(textMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -136,8 +137,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender,
content: buildImageTimelineItemContent(imageMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -158,8 +160,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender,
content: buildVideoTimelineItemContent(videoMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -180,8 +183,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(audioMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -201,8 +205,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(audioMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -223,8 +228,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender,
content: buildFileTimelineItemContent(fileMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -244,8 +250,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
content: buildNoticeTimelineItemContent(noticeMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -265,8 +272,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: emoteMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -286,8 +294,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
content: buildLocationTimelineItemContent(locationMessageContent),
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -312,8 +321,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
sender: eventItemProxy.sender,
imageInfo: imageInfo,
blurhash: info.blurhash,
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts),
@@ -370,8 +380,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: edited,
reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus,
@@ -389,8 +400,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil))
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary)))
}
private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
@@ -441,8 +453,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
sender: eventItemProxy.sender,
properties: .init(replyDetails: buildReplyToDetailsFromDetailsIfAvailable(details: messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil))
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary)))
}
// MARK: - Message events content
@@ -628,6 +641,46 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
shieldState.flatMap(EncryptionAuthenticity.init)
}
private func buildTimelineItemThreadSummary(_ threadSummary: MatrixRustSDK.ThreadSummary?) -> TimelineItemThreadSummary? {
guard let threadSummary else { return nil }
switch threadSummary.latestEvent() {
case .unavailable:
return .notLoaded
case .pending:
return .loading
case .ready(let senderID, let senderProfile, let content):
let sender = buildTimelineItemSender(senderID: senderID, senderProfile: senderProfile)
let latestEventContent: TimelineEventContent = switch content {
case .msgLike(let messageLikeContent):
switch messageLikeContent.kind {
case .message(let messageContent):
.message(buildMessageTimelineItemContent(messageType: messageContent.msgType,
senderID: senderID,
senderDisplayName: sender.displayName))
case .poll(let question, _, _, _, _, _, _):
.poll(question: question)
case .sticker(let body, _, _):
.message(.text(.init(body: body)))
case .redacted:
.redacted
default:
.message(.text(.init(body: L10n.commonUnsupportedEvent)))
}
default:
.message(.text(.init(body: L10n.commonUnsupportedEvent)))
}
return .loaded(senderID: senderID,
sender: sender,
latestEventContent: latestEventContent)
case .error(let message):
return .error(message: message)
}
}
// MARK: - Other Events
private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
@@ -707,7 +760,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
// MARK: - Reply details
func buildReply(details: InReplyToDetails) -> TimelineItemReply {
private func buildTimelineItemReplyDetails(_ details: MatrixRustSDK.InReplyToDetails?) -> TimelineItemReplyDetails? {
guard let details else {
return nil
}
return buildTimelineItemReply(details).details
}
func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply {
let isThreaded = details.event().isThreaded
switch details.event() {
case .unavailable:
@@ -715,19 +776,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
case .pending:
return .init(details: .loading(eventID: details.eventId()), isThreaded: isThreaded)
case let .ready(timelineItem, senderID, senderProfile):
let sender: TimelineItemSender
switch senderProfile {
case let .ready(displayName, isDisplayNameAmbiguous, avatarUrl):
sender = TimelineItemSender(id: senderID,
displayName: displayName,
isDisplayNameAmbiguous: isDisplayNameAmbiguous,
avatarURL: avatarUrl.flatMap(URL.init(string:)))
default:
sender = TimelineItemSender(id: senderID,
displayName: nil,
isDisplayNameAmbiguous: false,
avatarURL: nil)
}
let sender = buildTimelineItemSender(senderID: senderID, senderProfile: senderProfile)
let replyContent: TimelineEventContent
@@ -735,7 +784,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
case .msgLike(let messageLikeContent):
switch messageLikeContent.kind {
case .message(let messageContent):
return .init(details: timelineItemReplyDetails(sender: sender, eventID: details.eventId(), messageType: messageContent.msgType), isThreaded: isThreaded)
let replyContent = buildMessageTimelineItemContent(messageType: messageContent.msgType,
senderID: sender.id,
senderDisplayName: sender.displayName)
return .init(details: .loaded(sender: sender,
eventID: details.eventId(),
eventContent: .message(replyContent)),
isThreaded: isThreaded)
case .poll(let question, _, _, _, _, _, _):
replyContent = .poll(question: question)
case .sticker(let body, _, _):
@@ -755,45 +810,48 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
}
}
private func buildReplyToDetailsFromDetailsIfAvailable(details: InReplyToDetails?) -> TimelineItemReplyDetails? {
guard let details else {
return nil
// MARK: - Helpers
private func buildTimelineItemSender(senderID: String, senderProfile: ProfileDetails?) -> TimelineItemSender {
switch senderProfile {
case let .ready(displayName, isDisplayNameAmbiguous, avatarUrl):
return TimelineItemSender(id: senderID,
displayName: displayName,
isDisplayNameAmbiguous: isDisplayNameAmbiguous,
avatarURL: avatarUrl.flatMap(URL.init(string:)))
default:
return TimelineItemSender(id: senderID,
displayName: nil,
isDisplayNameAmbiguous: false,
avatarURL: nil)
}
return buildReply(details: details).details
}
private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails {
let replyContent: EventBasedMessageTimelineItemContentType
private func buildMessageTimelineItemContent(messageType: MessageType?, senderID: String, senderDisplayName: String?) -> EventBasedMessageTimelineItemContentType {
switch messageType {
case .audio(let content):
if content.voice != nil {
replyContent = .voice(buildAudioTimelineItemContent(content))
.voice(buildAudioTimelineItemContent(content))
} else {
replyContent = .audio(buildAudioTimelineItemContent(content))
.audio(buildAudioTimelineItemContent(content))
}
case .emote(let content):
replyContent = .emote(buildEmoteTimelineItemContent(senderDisplayName: sender.displayName, senderID: sender.id, messageContent: content))
.emote(buildEmoteTimelineItemContent(senderDisplayName: senderDisplayName, senderID: senderID, messageContent: content))
case .file(let content):
replyContent = .file(buildFileTimelineItemContent(content))
.file(buildFileTimelineItemContent(content))
case .image(let content):
replyContent = .image(buildImageTimelineItemContent(content))
.image(buildImageTimelineItemContent(content))
case .notice(let content):
replyContent = .notice(buildNoticeTimelineItemContent(content))
.notice(buildNoticeTimelineItemContent(content))
case .text(let content):
replyContent = .text(buildTextTimelineItemContent(content))
.text(buildTextTimelineItemContent(content))
case .video(let content):
replyContent = .video(buildVideoTimelineItemContent(content))
.video(buildVideoTimelineItemContent(content))
case .location(let content):
replyContent = .location(buildLocationTimelineItemContent(content))
.location(buildLocationTimelineItemContent(content))
case .other, .none:
replyContent = .text(.init(body: L10n.commonUnsupportedEvent))
.text(.init(body: L10n.commonUnsupportedEvent))
}
return .loaded(sender: sender,
eventID: eventID,
eventContent: .message(replyContent))
}
}
@@ -11,5 +11,5 @@ import MatrixRustSDK
protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy, isDM: Bool) -> RoomTimelineItemProtocol?
func buildReply(details: InReplyToDetails) -> TimelineItemReply
func buildTimelineItemReply(_ details: MatrixRustSDK.InReplyToDetails) -> TimelineItemReply
}