Files
element-x-ios/ElementX/Sources/Screens/Timeline/TimelineModels.swift
T
Stefan Ceriu 806d676299 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
2025-04-16 16:19:29 +03:00

296 lines
11 KiB
Swift

//
// Copyright 2022-2024 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 Combine
import MatrixRustSDK
import OrderedCollections
import SwiftUI
enum TimelineViewModelAction {
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL)
case displaySenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String)
}
enum TimelineViewPollAction {
case selectOption(pollStartID: String, optionID: String)
case end(pollStartID: String)
case edit(pollStartID: String, poll: Poll)
}
enum TimelineAudioPlayerAction {
case playPause(itemID: TimelineItemIdentifier)
case seek(itemID: TimelineItemIdentifier, progress: Double)
}
enum TimelineViewAction {
case itemAppeared(itemID: TimelineItemIdentifier)
case itemDisappeared(itemID: TimelineItemIdentifier)
case mediaTapped(itemID: TimelineItemIdentifier)
case itemSendInfoTapped(itemID: TimelineItemIdentifier)
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards
case paginateForwards
case scrollToBottom
case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
case tappedOnSenderDetails(userID: String)
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String)
case displayEmojiPicker(itemID: TimelineItemIdentifier)
case displayReadReceipts(itemID: TimelineItemIdentifier)
case displayThread(itemID: TimelineItemIdentifier)
case handlePasteOrDrop(provider: NSItemProvider)
case handlePollAction(TimelineViewPollAction)
case handleAudioPlayerAction(TimelineAudioPlayerAction)
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String)
/// Switch back to a live timeline (from a detached one).
case focusLive
/// The timeline scrolled to reveal the focussed item.
case scrolledToFocussedItem
/// The table view has loaded the first items for a new timeline.
case hasSwitchedTimeline
case hasScrolled(direction: ScrollDirection)
case setOpenURLAction(OpenURLAction)
}
enum TimelineComposerAction {
case setMode(mode: ComposerMode)
case setText(plainText: String, htmlText: String?)
case setFocus
case removeFocus
case clear
}
struct TimelineViewState: BindableState {
let timelineKind: TimelineKind
var roomID: String
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
var showReadReceipts = false
var isDirectOneToOneRoom = false
var timelineState: TimelineState // check the doc before changing this
var ownUserID: String
var canCurrentUserRedactOthers = false
var canCurrentUserRedactSelf = false
var canCurrentUserPin = false
var canCurrentUserKick = false
var canCurrentUserBan = false
var isViewSourceEnabled: Bool
var areThreadsEnabled: Bool
var hideTimelineMedia: Bool
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
// It's updated from the room info, so it's faster than using the timeline
var pinnedEventIDs: Set<String> = []
/// an openURL closure which opens URLs first using the App's environment rather than skipping out to external apps
var openURL: OpenURLAction?
/// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
/// A closure that updates the associated pill context
var pillContextUpdater: (@MainActor (PillContext) -> Void)?
/// A closure that returns the associated room name give its id
var roomNameForIDResolver: (@MainActor (String) -> String?)?
/// A closure that returns the associated room name give its alias
var roomNameForAliasResolver: (@MainActor (String) -> String?)?
var emojiProvider: EmojiProviderProtocol
var mapTilerConfiguration: MapTilerConfiguration
var bindings: TimelineViewStateBindings
}
struct TimelineViewStateBindings {
var isScrolledToBottom = true
/// The state of wether reactions listed on the timeline are expanded/collapsed.
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
var alertInfo: AlertInfo<TimelineAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
var actionMenuInfo: TimelineItemActionMenuInfo?
var reactionSummaryInfo: ReactionSummaryInfo?
var readReceiptsSummaryInfo: ReadReceiptSummaryInfo?
var manageMemberViewModel: ManageRoomMemberSheetViewModel?
}
struct TimelineItemActionMenuInfo: Equatable, Identifiable {
static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool {
lhs.id == rhs.id
}
let item: EventBasedTimelineItemProtocol
var id: TimelineItemIdentifier {
item.id
}
}
struct ReactionSummaryInfo: Identifiable {
let reactions: [AggregatedReaction]
let selectedKey: String
var id: String {
selectedKey
}
}
struct ReadReceiptSummaryInfo: Identifiable {
let orderedReceipts: [ReadReceipt]
let id: TimelineItemIdentifier
}
enum TimelineAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed
case encryptionAuthenticity(String)
}
struct RoomMemberState {
let displayName: String?
let avatarURL: URL?
}
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
struct TimelineState {
var isLive = true
var paginationState = PaginationState.initial
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
var isSwitchingTimelines = false
struct FocussedEvent: Equatable {
enum Appearance {
/// The event should be shown using an animated scroll.
case animated
/// The event should be shown immediately, without any animation.
case immediate
/// The event has already been shown.
case hasAppeared
}
/// The ID of the event.
let eventID: String
/// How the event should be shown, or whether it has already appeared.
var appearance: Appearance
}
/// A focussed event that was navigated to via a permalink.
var focussedEvent: FocussedEvent?
// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var itemsDictionary = OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>()
var uniqueIDs: [TimelineItemIdentifier.UniqueID] {
itemsDictionary.keys.elements
}
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
func hasLoadedItem(with eventID: String) -> Bool {
itemViewStates.contains { $0.identifier.eventID == eventID }
}
}
enum ScrollDirection: Equatable {
case top
case bottom
}
extension TimelineViewState {
/// The string shown as the message preview.
///
/// This converts the formatted body to a plain string to remove formatting
/// and render with a consistent font size. This conversion is done to avoid
/// showing markdown characters in the preview for messages with formatting.
func buildMessagePreview(formattedBody: AttributedString?, plainBody: String) -> String {
guard let formattedBody,
let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else {
return plainBody
}
let range = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttributes(in: range) { attributes, range, _ in
if let userID = attributes[.MatrixUserID] as? String {
if let displayName = members[userID]?.displayName {
attributedString.replaceCharacters(in: range, with: "@\(displayName)")
} else {
attributedString.replaceCharacters(in: range, with: userID)
}
}
if attributes[.MatrixAllUsersMention] as? Bool == true {
attributedString.replaceCharacters(in: range, with: PillUtilities.atRoom)
}
if let roomAlias = attributes[.MatrixRoomAlias] as? String {
let roomName = roomNameForAliasResolver?(roomAlias)
attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomAlias))
}
if let roomID = attributes[.MatrixRoomID] as? String {
let roomName = roomNameForIDResolver?(roomID)
attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomID))
}
if let eventOnRoomID = attributes[.MatrixEventOnRoomID] as? EventOnRoomIDAttribute.Value {
let roomID = eventOnRoomID.roomID
let roomName = roomNameForIDResolver?(roomID)
attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: roomID))
}
if let eventOnRoomAlias = attributes[.MatrixEventOnRoomAlias] as? EventOnRoomAliasAttribute.Value {
let roomAlias = eventOnRoomAlias.alias
let roomName = roomNameForAliasResolver?(roomAlias)
attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: eventOnRoomAlias.alias))
}
}
return attributedString.string
}
}