// // Copyright 2025 Element Creations Ltd. // Copyright 2023-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 Compound import SwiftUI struct TimelineThreadSummaryView: View { let threadSummary: TimelineItemThreadSummary var onTap: (() -> Void)? var body: some View { Button { onTap?() } label: { content } } @ViewBuilder private var content: some View { switch threadSummary { case .loaded(let senderID, let sender, let latestEventContent, let numberOfReplies): switch latestEventContent { case .message(let content): switch content { case .audio(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.caption ?? content.filename, formattedBody: content.formattedCaption, numberOfReplies: numberOfReplies) case .emote(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.body, formattedBody: content.formattedBody, numberOfReplies: numberOfReplies) case .file(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.caption ?? content.filename, formattedBody: content.formattedCaption, numberOfReplies: numberOfReplies) case .image(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.caption ?? content.filename, formattedBody: content.formattedCaption, numberOfReplies: numberOfReplies) case .notice(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.body, formattedBody: content.formattedBody, numberOfReplies: numberOfReplies) case .text(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.body, formattedBody: content.formattedBody, numberOfReplies: numberOfReplies) case .video(let content): ThreadView(senderID: senderID, sender: sender, plainBody: content.caption ?? content.filename, formattedBody: content.formattedCaption, numberOfReplies: numberOfReplies) case .voice: ThreadView(senderID: senderID, sender: sender, plainBody: L10n.commonVoiceMessage, formattedBody: nil, numberOfReplies: numberOfReplies) case .location: ThreadView(senderID: senderID, sender: sender, plainBody: L10n.commonSharedLocation, formattedBody: nil, numberOfReplies: numberOfReplies) } case .poll(let question): ThreadView(senderID: senderID, sender: sender, plainBody: question, formattedBody: nil, numberOfReplies: numberOfReplies) case .liveLocation: ThreadView(senderID: senderID, sender: sender, plainBody: L10n.commonSharedLiveLocation, formattedBody: nil, numberOfReplies: numberOfReplies) case .redacted: ThreadView(senderID: senderID, sender: sender, plainBody: L10n.commonMessageRemoved, formattedBody: nil, numberOfReplies: numberOfReplies) } default: LoadingThreadView() } } private struct LoadingThreadView: View { var body: some View { ThreadView(senderID: "@alice:matrix.org", sender: nil, plainBody: "Hello world", formattedBody: nil, numberOfReplies: 42) .redacted(reason: .placeholder) .accessibilityLabel(L10n.commonLoading) } } private struct ThreadView: View { @EnvironmentObject private var context: TimelineViewModel.Context let senderID: String let sender: TimelineItemSender? let plainBody: String let formattedBody: AttributedString? let numberOfReplies: Int var body: some View { HStack(spacing: 4) { CompoundIcon(\.threads, size: .xSmall, relativeTo: .compound.bodyXS) .foregroundColor(.compound.iconSecondary) .accessibilityLabel(L10n.commonThread) Text(L10n.commonReplies(numberOfReplies)) .font(.compound.bodyXSSemibold) .foregroundColor(.compound.textPrimary) LoadableAvatarImage(url: sender?.avatarURL, name: sender?.displayName, contentID: senderID, avatarSize: .user(on: .threadSummary), mediaProvider: context.mediaProvider) .accessibilityHidden(true) Text(sender?.disambiguatedDisplayName ?? senderID) .font(.compound.bodyXSSemibold) .foregroundColor(.compound.textPrimary) .accessibilityLabel(L10n.commonInReplyTo(sender?.disambiguatedDisplayName ?? senderID)) Text(context.viewState.buildMessagePreview(formattedBody: formattedBody, plainBody: plainBody)) .font(.compound.bodyXS) .foregroundColor(.compound.textSecondary) } .accessibilityElement(children: .combine) .lineLimit(1) .padding(.vertical, 7.0) .padding(.horizontal, 8.0) .background(Color.compound.bgSubtlePrimary) .cornerRadius(8) } } } struct TimelineThreadSummaryView_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel.mock static let attributedStringWithMention = { var attributedString = AttributedString("To be replaced") attributedString.userID = "@alice:matrix.org" return attributedString }() static let attributedStringWithAtRoomMention = { var attributedString = AttributedString("to be replaced") attributedString.allUsersMention = true return attributedString }() static let attributedStringWithRoomAliasMention = { var attributedString = AttributedString("to be replaced") attributedString.roomAlias = "#room:matrix.org" return attributedString }() static let attributedStringWithRoomIDMention = { var attributedString = AttributedString("to be replaced") attributedString.roomID = "!room:matrix.org" return attributedString }() static let attributedStringWithEventOnRoomIDMention = { var attributedString = AttributedString("to be replaced") attributedString.eventOnRoomID = .init(roomID: "!room:matrix.org", eventID: "$event") return attributedString }() static let attributedStringWithEventOnRoomAliasMention = { var attributedString = AttributedString("to be replaced") attributedString.eventOnRoomAlias = .init(alias: "#room:matrix.org", eventID: "$event") return attributedString }() static var previewItems: [TimelineThreadSummaryView] { [ TimelineThreadSummaryView(threadSummary: .notLoaded), TimelineThreadSummaryView(threadSummary: .loading), TimelineThreadSummaryView(threadSummary: .error(message: "Error")), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice McAliceFace"), latestEventContent: .message(.text(.init(body: "This is a very long, multi-lined, threaded message"))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "Hello world"))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.audio(.init(filename: "audio.m4a", caption: "Some audio", duration: 0, waveform: nil, source: nil, fileSize: nil, contentType: nil))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.file(.init(filename: "file.txt", caption: "Some file", source: nil, fileSize: nil, thumbnailSource: nil, contentType: nil))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.image(.init(filename: "image.jpg", caption: "Some image", imageInfo: .mockImage, thumbnailInfo: .mockThumbnail))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.video(.init(filename: "video.mp4", caption: "Some video", videoInfo: .mockVideo, thumbnailInfo: .mockVideoThumbnail))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.location(.init(body: ""))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .liveLocation, numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.voice(.init(filename: "voice-message.ogg", caption: "Some voice message", duration: 0, waveform: nil, source: nil, fileSize: nil, contentType: nil))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .poll(question: "Do you like polls?"), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .redacted, numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithRoomAliasMention))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithRoomIDMention))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithEventOnRoomIDMention))), numberOfReplies: 42)), TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org", sender: .init(id: "@alice:matrix.org", displayName: "Alice"), latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithEventOnRoomAliasMention))), numberOfReplies: 42)) ] } static var previews: some View { VStack(alignment: .leading, spacing: 20) { ForEach(0..