diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 07eb88e2c..f21313cbd 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -62,6 +62,7 @@ "common_developer_options" = "Developer options"; "common_edited_suffix" = "(edited)"; "common_editing" = "Editing"; +"common_emote" = "* %1$@ %2$@"; "common_encryption_enabled" = "Encryption enabled"; "common_error" = "Error"; "common_file" = "File"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 8829ddb89..744ad4313 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -138,6 +138,10 @@ public enum L10n { public static var commonEditedSuffix: String { return L10n.tr("Localizable", "common_edited_suffix") } /// Editing public static var commonEditing: String { return L10n.tr("Localizable", "common_editing") } + /// * %1$@ %2$@ + public static func commonEmote(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "common_emote", String(describing: p1), String(describing: p2)) + } /// Encryption enabled public static var commonEncryptionEnabled: String { return L10n.tr("Localizable", "common_encryption_enabled") } /// Error diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 3200f523a..7bd889b2c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -838,6 +838,22 @@ class RoomProxyMock: RoomProxyProtocol { return acceptInvitationReturnValue } } + //MARK: - fetchDetails + + var fetchDetailsForCallsCount = 0 + var fetchDetailsForCalled: Bool { + return fetchDetailsForCallsCount > 0 + } + var fetchDetailsForReceivedEventID: String? + var fetchDetailsForReceivedInvocations: [String] = [] + var fetchDetailsForClosure: ((String) -> Void)? + + func fetchDetails(for eventID: String) { + fetchDetailsForCallsCount += 1 + fetchDetailsForReceivedEventID = eventID + fetchDetailsForReceivedInvocations.append(eventID) + fetchDetailsForClosure?(eventID) + } } class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol { var callbacks: PassthroughSubject { diff --git a/ElementX/Sources/Other/Extensions/AttributedString.swift b/ElementX/Sources/Other/Extensions/AttributedString.swift index d346f5ea1..bb838ddba 100644 --- a/ElementX/Sources/Other/Extensions/AttributedString.swift +++ b/ElementX/Sources/Other/Extensions/AttributedString.swift @@ -28,10 +28,8 @@ extension AttributedString { } let isBlockquote = value != nil - /// This is a temporary workaround until replies are retrieved from the SDK. - let isReply = isBlockquote && attributedString.characters.starts(with: "In reply to @") - return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: isBlockquote, isReply: isReply) + return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: isBlockquote) } } diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift index 0152bf187..1c244265c 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilderProtocol.swift @@ -19,7 +19,6 @@ import Foundation struct AttributedStringBuilderComponent: Hashable { let attributedString: AttributedString let isBlockquote: Bool - let isReply: Bool } protocol AttributedStringBuilderProtocol { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 2e720b0aa..b7eb92f79 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -257,10 +257,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } var actions: [TimelineItemContextMenuAction] = [ - .react, .copy, .reply - // Disabled for FOSDEM - // .quote, .copyPermalink + .react, .reply, .copyPermalink ] + + if timelineItem is EventBasedMessageTimelineItemProtocol { + actions.append(contentsOf: [.copy, .quote]) + } if item.isEditable { actions.append(.edit) @@ -282,28 +284,40 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol return .init(actions: actions, debugActions: debugActions) } - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemID: String) { guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemID }), - let item = timelineItem as? EventBasedTimelineItemProtocol else { + let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else { return } switch action { case .react: - callback?(.displayEmojiPicker(itemID: item.id)) + callback?(.displayEmojiPicker(itemID: eventTimelineItem.id)) case .copy: - UIPasteboard.general.string = item.body + guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { + return + } + + UIPasteboard.general.string = messageTimelineItem.body case .edit: + guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { + return + } + state.bindings.composerFocused = true - state.bindings.composerText = item.body - state.composerMode = .edit(originalItemId: item.id) + state.bindings.composerText = messageTimelineItem.body + state.composerMode = .edit(originalItemId: messageTimelineItem.id) case .quote: + guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { + return + } + state.bindings.composerFocused = true - state.bindings.composerText = "> \(item.body)" + state.bindings.composerText = "> \(messageTimelineItem.body)\n\n" case .copyPermalink: do { - let permalink = try PermalinkBuilder.permalinkTo(eventIdentifier: item.id, roomIdentifier: timelineController.roomID) + let permalink = try PermalinkBuilder.permalinkTo(eventIdentifier: eventTimelineItem.id, roomIdentifier: timelineController.roomID) UIPasteboard.general.url = permalink } catch { displayError(.alert(L10n.errorFailedCreatingThePermalink)) @@ -314,9 +328,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } case .reply: state.bindings.composerFocused = true - state.composerMode = .reply(id: item.id, displayName: item.sender.displayName ?? item.sender.id) + state.composerMode = .reply(id: eventTimelineItem.id, displayName: eventTimelineItem.sender.displayName ?? eventTimelineItem.sender.id) case .viewSource: - let debugInfo = timelineController.debugInfo(for: item.id) + let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id) MXLog.info(debugInfo) state.bindings.debugInfo = debugInfo case .retryDecryption(let sessionID): @@ -324,7 +338,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol await timelineController.retryDecryption(for: sessionID) } case .report: - callback?(.displayReportContent(itemID: itemID, senderID: item.sender.id)) + callback?(.displayReportContent(itemID: itemID, senderID: eventTimelineItem.sender.id)) } if action.switchToDefaultComposer { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift new file mode 100644 index 000000000..b77cd1535 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift @@ -0,0 +1,73 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct TimelineReplyView: View { + let timelineItemReplyDetails: TimelineItemReplyDetails? + + var body: some View { + if let timelineItemReplyDetails { + switch timelineItemReplyDetails { + case .loaded(let sender, let content): + switch content { + case .audio(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + case .emote(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody)) + case .file(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + case .image(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + case .notice(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody)) + case .text(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody)) + case .video(let content): + TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil)) + } + default: + Text("Missing in-reply-to details") + .font(.compound.bodyMD) + .foregroundColor(.element.secondaryContent) + .padding() + } + } + } + + private func attributedString(for sender: TimelineItemSender, body: String, formattedBody: AttributedString?) -> AttributedString { + var attributedHeading = AttributedString("\(sender.displayName ?? sender.id)\n") + attributedHeading.font = .compound.bodyMD.bold() + attributedHeading.foregroundColor = .element.primaryContent + + var formattedBody = formattedBody ?? AttributedString(body) + formattedBody.font = .compound.bodyMD + formattedBody.foregroundColor = .element.secondaryContent + + attributedHeading += formattedBody + + return attributedHeading + } + + private struct TimelineTextReplyView: View { + let attributedText: AttributedString + + var body: some View { + FormattedBodyText(attributedString: attributedText) + .lineLimit(3) + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 3ab522529..4d7b6bd46 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -102,13 +102,13 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var styledContent: some View { if shouldAvoidBubbling { - content() + contentWithReply .bubbleStyle(inset: false, cornerRadius: cornerRadius, corners: roundedCorners) } else { VStack(alignment: .trailing, spacing: 4) { - content() + contentWithReply if timelineItem.properties.isEdited { Text(L10n.commonEditedSuffix) @@ -123,6 +123,20 @@ struct TimelineItemBubbledStylerView: View { } } + @ViewBuilder + var contentWithReply: some View { + VStack(alignment: .leading, spacing: 4.0) { + if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol, + let replyDetails = messageTimelineItem.replyDetails { + TimelineReplyView(timelineItemReplyDetails: replyDetails) + .background(Color.element.background) + .cornerRadius(8) + } + + content() + } + } + private var messageBubbleTopPadding: CGFloat { guard timelineItem.isOutgoing else { return 0 } return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0 diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 856443f62..97c5cd855 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -30,7 +30,7 @@ struct TimelineItemPlainStylerView: View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { - content() + contentWithReply Spacer() @@ -42,7 +42,24 @@ struct TimelineItemPlainStylerView: View { } } } - + + @ViewBuilder + var contentWithReply: some View { + VStack(alignment: .leading) { + if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol, + let replyDetails = messageTimelineItem.replyDetails { + HStack(spacing: 4.0) { + Rectangle() + .foregroundColor(.element.accent) + .frame(width: 4.0) + TimelineReplyView(timelineItemReplyDetails: replyDetails) + } + } + + content() + } + } + @ViewBuilder private var header: some View { if shouldShowSenderDetails { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift index df6b9c2d6..948d16206 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift @@ -25,7 +25,7 @@ struct AudioRoomTimelineView: View { HStack { Image(systemName: "waveform") .foregroundColor(.element.primaryContent) - FormattedBodyText(text: timelineItem.body) + FormattedBodyText(text: timelineItem.content.body) } .padding(.vertical, 12) .padding(.horizontal, 6) @@ -35,20 +35,18 @@ struct AudioRoomTimelineView: View { struct AudioRoomTimelineView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock - + static var previews: some View { body.environmentObject(viewModel.context) body.timelineStyle(.plain).environmentObject(viewModel.context) } - + static var body: some View { AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString, - body: "audio.ogg", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - duration: 300, - source: nil)) + content: .init(body: "audio.ogg", duration: 300, source: nil, contentType: nil))) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift index 7528dc78d..5308eebdd 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift @@ -22,10 +22,10 @@ struct EmoteRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - if let attributedString = timelineItem.formattedBody { + if let attributedString = timelineItem.content.formattedBody { FormattedBodyText(attributedString: attributedString) } else { - FormattedBodyText(text: timelineItem.body) + FormattedBodyText(text: timelineItem.content.body) } } } @@ -53,10 +53,10 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider { private static func itemWith(text: String, timestamp: String, senderId: String) -> EmoteRoomTimelineItem { EmoteRoomTimelineItem(id: UUID().uuidString, - body: text, timestamp: timestamp, isOutgoing: false, isEditable: false, - sender: .init(id: senderId)) + sender: .init(id: senderId), + content: .init(body: text)) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift index 8276d499a..814ec7b63 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift @@ -25,7 +25,7 @@ struct FileRoomTimelineView: View { HStack { Image(systemName: "doc.text.fill") .foregroundColor(.element.primaryContent) - FormattedBodyText(text: timelineItem.body) + FormattedBodyText(text: timelineItem.content.body) } .padding(.vertical, 12) .padding(.horizontal, 6) @@ -44,31 +44,25 @@ struct FileRoomTimelineView_Previews: PreviewProvider { static var body: some View { VStack(spacing: 20.0) { FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, - body: "document.pdf", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil, - thumbnailSource: nil)) + content: .init(body: "document.pdf", source: nil, thumbnailSource: nil, contentType: nil))) FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, - body: "document.docx", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil, - thumbnailSource: nil)) + content: .init(body: "document.docx", source: nil, thumbnailSource: nil, contentType: nil))) FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, - body: "document.txt", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: nil, - thumbnailSource: nil)) + content: .init(body: "document.txt", source: nil, thumbnailSource: nil, contentType: nil))) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift index 966861203..e5c6e3b4e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift @@ -100,7 +100,7 @@ struct FormattedBodyText: View { // The rendered blockquote with a greedy width. The custom layout prevents the // infinite width from increasing the overall width of the view. Text(component.attributedString.mergingAttributes(blockquoteAttributes)) - .blockquoteFormatting(isReply: component.isReply) + .blockquoteFormatting() .frame(maxWidth: .infinity, alignment: .leading) .background(Color.element.background) .cornerRadius(8) @@ -119,7 +119,7 @@ struct FormattedBodyText: View { ForEach(attributedComponents, id: \.self) { component in if component.isBlockquote { Text(component.attributedString.mergingAttributes(blockquoteAttributes)) - .blockquoteFormatting(isReply: component.isReply) + .blockquoteFormatting() .layoutPriority(LayoutPriority.hiddenBlockquote) .hidden() } @@ -164,9 +164,8 @@ extension FormattedBodyText { } private extension View { - func blockquoteFormatting(isReply: Bool) -> some View { - lineLimit(isReply ? 3 : nil) - .foregroundColor(.element.tertiaryContent) + func blockquoteFormatting() -> some View { + foregroundColor(.element.tertiaryContent) .fixedSize(horizontal: false, vertical: true) .padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 15d9b1e59..3dc2af91c 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -23,16 +23,24 @@ struct ImageRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - LoadableImage(mediaSource: timelineItem.source, - blurhash: timelineItem.blurhash, + LoadableImage(mediaSource: source, + blurhash: timelineItem.content.blurhash, imageProvider: context.imageProvider) { placeholder } - .frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity))) - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + .frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity))) + .aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit) } } + var source: MediaSourceProxy { + guard timelineItem.content.contentType != .gif, let thumbnailSource = timelineItem.content.thumbnailSource else { + return timelineItem.content.source + } + + return thumbnailSource + } + var placeholder: some View { ZStack { Rectangle() @@ -56,31 +64,30 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { static var body: some View { VStack(spacing: 20.0) { ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, - body: "Some image", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"))) - - ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, - body: "Some other image", - timestamp: "Now", - isOutgoing: false, - isEditable: false, - sender: .init(id: "Bob"), - source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"))) + content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil))) ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, - body: "Blurhashed image", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), - aspectRatio: 0.7, - blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW", - contentType: .gif)) + content: .init(body: "Some other image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil))) + + ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + sender: .init(id: "Bob"), + content: .init(body: "Blurhashed image", + source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), + thumbnailSource: nil, + aspectRatio: 0.7, + blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW", + contentType: .gif))) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index 2d8e5f9e0..02af5592d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -31,10 +31,10 @@ struct NoticeRoomTimelineView: View { Image(systemName: "info.bubble").padding(.top, 2.0) .foregroundColor(.element.secondaryContent) - if let attributedString = timelineItem.formattedBody { + if let attributedString = timelineItem.content.formattedBody { FormattedBodyText(attributedString: attributedString) } else { - FormattedBodyText(text: timelineItem.body) + FormattedBodyText(text: timelineItem.content.body) } } .padding(.leading, 4) // Trailing padding is provided by FormattedBodyText @@ -64,10 +64,10 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider { private static func itemWith(text: String, timestamp: String, senderId: String) -> NoticeRoomTimelineItem { NoticeRoomTimelineItem(id: UUID().uuidString, - body: text, timestamp: timestamp, isOutgoing: false, isEditable: false, - sender: .init(id: senderId)) + sender: .init(id: senderId), + content: .init(body: text)) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift index 30ffdf3d9..1f529377d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift @@ -43,21 +43,21 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { VStack(alignment: .leading, spacing: 0) { RoomTimelineViewProvider.separator(.init(text: "Today"), .single) RoomTimelineViewProvider.text(.init(id: "", - body: "This is another message", timestamp: "", isOutgoing: true, isEditable: false, - sender: .init(id: "1", displayName: "Bob")), .single) + sender: .init(id: "1", displayName: "Bob"), + content: .init(body: "This is another message")), .single) ReadMarkerRoomTimelineView(timelineItem: item) RoomTimelineViewProvider.separator(.init(text: "Today"), .single) RoomTimelineViewProvider.text(.init(id: "", - body: "This is a message", timestamp: "", isOutgoing: false, isEditable: false, - sender: .init(id: "", displayName: "Alice")), .single) + sender: .init(id: "", displayName: "Alice"), + content: .init(body: "This is a message")), .single) } .padding(.horizontal, 8) .environmentObject(viewModel.context) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index c53a7fc78..a0632cbd9 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -22,10 +22,10 @@ struct TextRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { - if let attributedString = timelineItem.formattedBody { + if let attributedString = timelineItem.content.formattedBody { FormattedBodyText(attributedString: attributedString) } else { - FormattedBodyText(text: timelineItem.body) + FormattedBodyText(text: timelineItem.content.body) } } } @@ -65,10 +65,10 @@ struct TextRoomTimelineView_Previews: PreviewProvider { private static func itemWith(text: String, timestamp: String, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem { TextRoomTimelineItem(id: UUID().uuidString, - body: text, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isOutgoing, - sender: .init(id: senderId)) + sender: .init(id: senderId), + content: .init(body: text)) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index 885f662ba..ef3e19fe0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -24,16 +24,16 @@ struct VideoRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { thumbnail - .frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity))) - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + .frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity))) + .aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit) } } @ViewBuilder var thumbnail: some View { - if let thumbnailSource = timelineItem.thumbnailSource { + if let thumbnailSource = timelineItem.content.thumbnailSource { LoadableImage(mediaSource: thumbnailSource, - blurhash: timelineItem.blurhash, + blurhash: timelineItem.content.blurhash, imageProvider: context.imageProvider) { imageView in imageView .overlay { playIcon } @@ -76,36 +76,25 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { static var body: some View { VStack(spacing: 20.0) { VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, - body: "Some video", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - duration: 21, - source: nil, - thumbnailSource: nil)) + content: .init(body: "Some video", duration: 21, source: nil, thumbnailSource: nil))) VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, - body: "Some other video", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - duration: 22, - source: nil, - thumbnailSource: nil)) + content: .init(body: "Some other video", duration: 22, source: nil, thumbnailSource: nil))) VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, - body: "Blurhashed video", timestamp: "Now", isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), - duration: 23, - source: nil, - thumbnailSource: nil, - aspectRatio: 0.7, - blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")) + content: .init(body: "Blurhashed video", duration: 23, source: nil, thumbnailSource: nil, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))) } } } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index c7a25067d..ac542b139 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -428,6 +428,19 @@ class RoomProxy: RoomProxyProtocol { } } + func fetchDetails(for eventID: String) { + Task { + await Task.dispatch(on: .global()) { + do { + MXLog.info("Fetching event details for \(eventID)") + try self.room.fetchEventDetails(eventId: eventID) + } catch { + MXLog.error("Failed fetching event details for \(eventID) with error: \(error)") + } + } + } + } + // MARK: - Private /// Force the timeline to load member details so it can populate sender profiles whenever we add a timeline listener diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index bf5c0e50b..091e14bda 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -102,6 +102,8 @@ protocol RoomProxyProtocol { func rejectInvitation() async -> Result func acceptInvitation() async -> Result + + func fetchDetails(for eventID: String) } extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift index 6bac60b09..33ee0b465 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift @@ -45,7 +45,7 @@ struct RoomEventStringBuilder { // Message types that don't need a prefix. case .emote(content: let content): let senderDisplayName = sender.displayName ?? sender.id - return AttributedString("* \(senderDisplayName) \(content.body)") + return AttributedString(L10n.commonEmote(senderDisplayName, content.body)) // Message types that should be prefixed with the sender's name. case .image: message = L10n.commonImage diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index ab64331a0..d7ab1b80e 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -21,61 +21,61 @@ enum RoomTimelineItemFixtures { static var `default`: [RoomTimelineItemProtocol] = [ SeparatorRoomTimelineItem(text: "Yesterday"), TextRoomTimelineItem(id: UUID().uuidString, - body: "That looks so good!", timestamp: "10:10 AM", isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Jacob"), + content: .init(body: "That looks so good!"), properties: RoomTimelineItemProperties(isEdited: true)), TextRoomTimelineItem(id: UUID().uuidString, - body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗", timestamp: "10:11 AM", isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Helena"), + content: .init(body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗"), properties: RoomTimelineItemProperties(reactions: [ AggregatedReaction(key: "🙌", count: 1, isHighlighted: true) ])), TextRoomTimelineItem(id: UUID().uuidString, - body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/", timestamp: "10:11 AM", isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Helena"), + content: .init(body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/"), properties: RoomTimelineItemProperties(reactions: [ AggregatedReaction(key: "🙏", count: 1, isHighlighted: false), AggregatedReaction(key: "🙌", count: 2, isHighlighted: true) ])), SeparatorRoomTimelineItem(text: "Today"), TextRoomTimelineItem(id: UUID().uuidString, - body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!", timestamp: "5 PM", isOutgoing: false, isEditable: false, - sender: .init(id: "", displayName: "Helena")), + sender: .init(id: "", displayName: "Helena"), + content: .init(body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!")), TextRoomTimelineItem(id: UUID().uuidString, - body: "And John's speech was amazing!", - timestamp: "5 PM", - isOutgoing: true, - isEditable: true, - sender: .init(id: "", displayName: "Bob")), - TextRoomTimelineItem(id: UUID().uuidString, - body: "New home office set up!", timestamp: "5 PM", isOutgoing: true, isEditable: true, sender: .init(id: "", displayName: "Bob"), + content: .init(body: "And John's speech was amazing!")), + TextRoomTimelineItem(id: UUID().uuidString, + timestamp: "5 PM", + isOutgoing: true, + isEditable: true, + sender: .init(id: "", displayName: "Bob"), + content: .init(body: "New home office set up!"), properties: RoomTimelineItemProperties(reactions: [ AggregatedReaction(key: "🙏", count: 1, isHighlighted: false), AggregatedReaction(key: "😁", count: 3, isHighlighted: false) ])), TextRoomTimelineItem(id: UUID().uuidString, - body: "", - formattedBody: AttributedStringBuilder().fromHTML("Hol' up
New home office set up!
That's amazing! Congrats 🥳"), timestamp: "5 PM", isOutgoing: false, isEditable: false, - sender: .init(id: "", displayName: "Helena")) + sender: .init(id: "", displayName: "Helena"), + content: .init(body: "", + formattedBody: AttributedStringBuilder().fromHTML("Hol' up
New home office set up!
That's amazing! Congrats 🥳"))) ] /// A small chunk of events, containing 2 text items. @@ -187,10 +187,10 @@ enum RoomTimelineItemFixtures { private extension TextRoomTimelineItem { init(text: String, senderDisplayName: String) { self.init(id: UUID().uuidString, - body: text, timestamp: "10:47 am", isOutgoing: senderDisplayName == "Alice", isEditable: false, - sender: .init(id: "", displayName: senderDisplayName)) + sender: .init(id: "", displayName: senderDisplayName), + content: .init(body: text)) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 316532de6..fb3230ed2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -96,7 +96,21 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } - func processItemAppearance(_ itemID: String) async { } + func processItemAppearance(_ itemID: String) async { + guard let timelineItem = timelineItems.first(where: { $0.id == itemID }) else { + return + } + + // Fetch replied-to event details if unavailable at the point of displaying it in the timeline + if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol { + switch messageTimelineItem.replyDetails { + case .notLoaded, .error: + roomProxy.fetchDetails(for: timelineItem.id) + default: + break + } + } + } func processItemDisappearance(_ itemID: String) { } @@ -109,18 +123,18 @@ class RoomTimelineController: RoomTimelineControllerProtocol { var body: String switch timelineItem { case let item as ImageRoomTimelineItem: - source = item.source - body = item.body + source = item.content.source + body = item.content.body case let item as VideoRoomTimelineItem: - source = item.source - body = item.body + source = item.content.source + body = item.content.body case let item as FileRoomTimelineItem: - source = item.source - body = item.body + source = item.content.source + body = item.content.body case let item as AudioRoomTimelineItem: // For now we are just displaying audio messages with the File preview until we create a timeline player for them. - source = item.source - body = item.body + source = item.content.source + body = item.content.body default: return .none } @@ -218,7 +232,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { let isLastItem = index == collapsibleChunks.indices.last let items = collapsibleChunk.compactMap { itemProxy in - let timelineItem = buildTimelineItemFor(itemProxy: itemProxy) + let timelineItem = buildTimelineItem(for: itemProxy) if timelineItem is PaginationIndicatorRoomTimelineItem { isBackPaginating = true @@ -253,10 +267,10 @@ class RoomTimelineController: RoomTimelineControllerProtocol { callbacks.send(.isBackPaginating(isBackPaginating)) } - private func buildTimelineItemFor(itemProxy: TimelineItemProxy) -> RoomTimelineItemProtocol? { + private func buildTimelineItem(for itemProxy: TimelineItemProxy) -> RoomTimelineItemProtocol? { switch itemProxy { - case .event(let eventItemProxy): - return timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItemProxy) + case .event(let eventTimelineItem): + return timelineItemFactory.buildTimelineItem(for: eventTimelineItem) case .virtual(let virtualItem): switch virtualItem { case .dayDivider(let timestamp): diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift deleted file mode 100644 index 309bc502f..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import CoreGraphics -import Foundation -import MatrixRustSDK -import UniformTypeIdentifiers - -/// A protocol that contains the base `m.room.message` event content properties. -/// The `CustomStringConvertible` conformance is to redact specific properties from the logs. -protocol MessageContentProtocol: RoomMessageEventContentProtocol, CustomStringConvertible { - var body: String { get } -} - -/// A timeline item that represents an `m.room.message` event. -struct MessageTimelineItem { - let item: MatrixRustSDK.EventTimelineItemProtocol - let content: Content - - var id: String { - item.uniqueIdentifier() - } - - var body: String { - content.body - } - - var isEdited: Bool { - item.content().asMessage()?.isEdited() == true - } - - var sender: String { - item.sender() - } - - var timestamp: Date { - Date(timeIntervalSince1970: TimeInterval(item.timestamp() / 1000)) - } -} - -// MARK: - Formatted Text - -/// A protocol that contains the expected event content properties for a formatted message. -protocol FormattedMessageContentProtocol: MessageContentProtocol { - var formatted: FormattedBody? { get } -} - -extension MatrixRustSDK.TextMessageContent: FormattedMessageContentProtocol { } -extension MatrixRustSDK.EmoteMessageContent: FormattedMessageContentProtocol { } -extension MatrixRustSDK.NoticeMessageContent: FormattedMessageContentProtocol { } - -/// A timeline item that represents an `m.room.message` event where -/// the `msgtype` would likely contain a formatted body. -extension MessageTimelineItem where Content: FormattedMessageContentProtocol { - var htmlBody: String? { - guard content.formatted?.format == .html else { return nil } - return content.formatted?.body - } -} - -// MARK: - Media - -extension MatrixRustSDK.ImageMessageContent: MessageContentProtocol { } - -/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.image`. -extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent { - var source: MediaSourceProxy { - .init(source: content.source, mimeType: content.info?.mimetype) - } - - var width: CGFloat? { - content.info?.width.map(CGFloat.init) - } - - var height: CGFloat? { - content.info?.height.map(CGFloat.init) - } - - var blurhash: String? { - content.info?.blurhash - } - - var contentType: UTType? { - UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) - } -} - -extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { } - -/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.video`. -extension MessageTimelineItem where Content == MatrixRustSDK.VideoMessageContent { - var source: MediaSourceProxy { - .init(source: content.source, mimeType: content.info?.mimetype) - } - - var thumbnailSource: MediaSourceProxy? { - guard let src = content.info?.thumbnailSource else { - return nil - } - return .init(source: src, mimeType: content.info?.thumbnailInfo?.mimetype) - } - - var duration: UInt64 { - content.info?.duration ?? 0 - } - - var width: CGFloat? { - content.info?.width.map(CGFloat.init) - } - - var height: CGFloat? { - content.info?.height.map(CGFloat.init) - } - - var blurhash: String? { - content.info?.blurhash - } - - var contentType: UTType? { - UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) - } -} - -extension MatrixRustSDK.FileMessageContent: MessageContentProtocol { } - -/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.file`. -extension MessageTimelineItem where Content == MatrixRustSDK.FileMessageContent { - var source: MediaSourceProxy { - .init(source: content.source, mimeType: content.info?.mimetype) - } - - var thumbnailSource: MediaSourceProxy? { - guard let src = content.info?.thumbnailSource else { - return nil - } - return .init(source: src, mimeType: content.info?.thumbnailInfo?.mimetype) - } - - var contentType: UTType? { - UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) - } -} - -extension MatrixRustSDK.AudioMessageContent: MessageContentProtocol { } - -/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.audio`. -extension MessageTimelineItem where Content == MatrixRustSDK.AudioMessageContent { - var source: MediaSourceProxy { - .init(source: content.source, mimeType: content.info?.mimetype) - } - - var duration: UInt64 { - content.info?.duration ?? 0 - } - - var contentType: UTType? { - UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift new file mode 100644 index 000000000..cb5dbf838 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/TimelineItemReplyDetails.swift @@ -0,0 +1,34 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum TimelineItemReplyContent: Hashable { + case audio(AudioRoomTimelineItemContent) + case emote(EmoteRoomTimelineItemContent) + case file(FileRoomTimelineItemContent) + case image(ImageRoomTimelineItemContent) + case notice(NoticeRoomTimelineItemContent) + case text(TextRoomTimelineItemContent) + case video(VideoRoomTimelineItemContent) +} + +enum TimelineItemReplyDetails: Hashable { + case notLoaded(eventID: String) + case loading(eventID: String) + case loaded(sender: TimelineItemSender, content: TimelineItemReplyContent) + case error(eventID: String, message: String) +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index 56962932f..16adc91a2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -65,15 +65,7 @@ struct EventTimelineItemProxy { return .sent(elapsedTime: Date().timeIntervalSince1970 - timestamp.timeIntervalSince1970) } } - - var body: String? { - content.asMessage()?.body() - } - - var isMessage: Bool { - content.asMessage() != nil - } - + var isRoomState: Bool { content.kind().isRoomState } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift new file mode 100644 index 000000000..1028b92c4 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol { + var body: String { get } + var replyDetails: TimelineItemReplyDetails? { get } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 2851b4333..756bd064a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -18,7 +18,6 @@ import Foundation import UIKit protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringConvertible { - var body: String { get } var timestamp: String { get } var isOutgoing: Bool { get } var isEditable: Bool { get } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift index 0e0d2efc9..fee7b72de 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift @@ -15,19 +15,21 @@ // import Foundation -import UniformTypeIdentifiers -struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct AudioRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender - - let duration: UInt64 - let source: MediaSourceProxy? - var contentType: UTType? + + let content: AudioRoomTimelineItemContent + + var replyDetails: TimelineItemReplyDetails? var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift new file mode 100644 index 000000000..af43801a9 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItemContent.swift @@ -0,0 +1,25 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import UniformTypeIdentifiers + +struct AudioRoomTimelineItemContent: Hashable { + let body: String + let duration: UInt64 + let source: MediaSourceProxy? + let contentType: UTType? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift index 205e32214..569c79bb1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift @@ -16,15 +16,21 @@ import UIKit -struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct EmoteRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String - var formattedBody: AttributedString? let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender + let content: EmoteRoomTimelineItemContent + + var replyDetails: TimelineItemReplyDetails? + var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItemContent.swift new file mode 100644 index 000000000..d190e369b --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItemContent.swift @@ -0,0 +1,22 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +struct EmoteRoomTimelineItemContent: Hashable { + let body: String + var formattedBody: AttributedString? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift index 9d8a567f1..7daa4bfc5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift @@ -17,18 +17,21 @@ import UIKit import UniformTypeIdentifiers -struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct FileRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender - - let source: MediaSourceProxy? - let thumbnailSource: MediaSourceProxy? - var contentType: UTType? + + let content: FileRoomTimelineItemContent + + var replyDetails: TimelineItemReplyDetails? var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItemContent.swift new file mode 100644 index 000000000..60139d75b --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItemContent.swift @@ -0,0 +1,25 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UniformTypeIdentifiers + +struct FileRoomTimelineItemContent: Hashable { + let body: String + let source: MediaSourceProxy? + let thumbnailSource: MediaSourceProxy? + let contentType: UTType? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index 4bb8e9416..cb0c26ca8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -17,22 +17,21 @@ import UIKit import UniformTypeIdentifiers -struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct ImageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender - let source: MediaSourceProxy + let content: ImageRoomTimelineItemContent - var width: CGFloat? - var height: CGFloat? - var aspectRatio: CGFloat? - var blurhash: String? - var contentType: UTType? + var replyDetails: TimelineItemReplyDetails? var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItemContent.swift new file mode 100644 index 000000000..a852ada8c --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItemContent.swift @@ -0,0 +1,29 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UniformTypeIdentifiers + +struct ImageRoomTimelineItemContent: Hashable { + let body: String + let source: MediaSourceProxy + let thumbnailSource: MediaSourceProxy? + var width: CGFloat? + var height: CGFloat? + var aspectRatio: CGFloat? + var blurhash: String? + var contentType: UTType? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift index 3993c0255..1e40c3ea1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift @@ -16,15 +16,21 @@ import UIKit -struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct NoticeRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String - var formattedBody: AttributedString? let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender + let content: NoticeRoomTimelineItemContent + + var replyDetails: TimelineItemReplyDetails? + var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItemContent.swift new file mode 100644 index 000000000..256b63fe3 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItemContent.swift @@ -0,0 +1,22 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +struct NoticeRoomTimelineItemContent: Hashable { + let body: String + var formattedBody: AttributedString? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift index 9c46fe8a5..600edd632 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift @@ -16,15 +16,22 @@ import UIKit -struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct TextRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String - var formattedBody: AttributedString? + let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender + let content: TextRoomTimelineItemContent + + var replyDetails: TimelineItemReplyDetails? + var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItemContent.swift new file mode 100644 index 000000000..c7f541e97 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItemContent.swift @@ -0,0 +1,22 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +struct TextRoomTimelineItemContent: Hashable { + let body: String + var formattedBody: AttributedString? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index f135b56b0..d54be6dea 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -17,24 +17,21 @@ import UIKit import UniformTypeIdentifiers -struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { +struct VideoRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Identifiable, Hashable { let id: String - let body: String let timestamp: String let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender - let duration: UInt64 - let source: MediaSourceProxy? - let thumbnailSource: MediaSourceProxy? + let content: VideoRoomTimelineItemContent - var width: CGFloat? - var height: CGFloat? - var aspectRatio: CGFloat? - var blurhash: String? - var contentType: UTType? + var replyDetails: TimelineItemReplyDetails? var properties = RoomTimelineItemProperties() + + var body: String { + content.body + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItemContent.swift new file mode 100644 index 000000000..cdd53dab8 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItemContent.swift @@ -0,0 +1,30 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UniformTypeIdentifiers + +struct VideoRoomTimelineItemContent: Hashable { + let body: String + let duration: UInt64 + let source: MediaSourceProxy? + let thumbnailSource: MediaSourceProxy? + var width: CGFloat? + var height: CGFloat? + var aspectRatio: CGFloat? + var blurhash: String? + var contentType: UTType? +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 1d1b44fbf..27a31b6e0 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -16,6 +16,7 @@ import MatrixRustSDK import UIKit +import UniformTypeIdentifiers struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private let mediaProvider: MediaProviderProtocol @@ -36,7 +37,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } // swiftlint:disable:next cyclomatic_complexity - func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? { + func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? { let isOutgoing = eventItemProxy.isOwn switch eventItemProxy.content.kind() { @@ -56,39 +57,37 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .failedToParseState(let eventType, _, let error): return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing) case .message: - guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") } + guard let messageTimelineItem = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") } - switch messageContent.msgtype() { + switch messageTimelineItem.msgtype() { case .text(content: let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .image(content: let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .video(let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .file(let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .notice(content: let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .emote(content: let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .audio(let content): - let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildAudioTimelineItem(eventItemProxy, message, isOutgoing) + return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) case .none: - return buildFallbackTimelineItem(eventItemProxy, isOutgoing) + return nil } case .state(let stateKey, let content): - return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing) + return buildStateTimelineItem(for: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing) case .roomMembership(userId: let userID, change: let change): - return buildStateMembershipChangeTimelineItemFor(eventItemProxy: eventItemProxy, member: userID, membershipChange: change, isOutgoing: isOutgoing) + return buildStateMembershipChangeTimelineItem(for: eventItemProxy, member: userID, membershipChange: change, isOutgoing: isOutgoing) case .profileChange(let displayName, let prevDisplayName, let avatarUrl, let prevAvatarUrl): - return buildStateProfileChangeTimelineItemFor(eventItemProxy: eventItemProxy, displayName: displayName, previousDisplayName: prevDisplayName, avatarURLString: avatarUrl, previousAvatarURLString: prevAvatarUrl, isOutgoing: isOutgoing) + return buildStateProfileChangeTimelineItem(for: eventItemProxy, + displayName: displayName, + previousDisplayName: prevDisplayName, + avatarURLString: avatarUrl, + previousAvatarURLString: prevAvatarUrl, + isOutgoing: isOutgoing) } } @@ -169,166 +168,117 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { sender: eventItemProxy.sender, properties: RoomTimelineItemProperties()) } - - private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - let formattedBody = attributedStringBuilder.fromPlain(eventItemProxy.body) - - return TextRoomTimelineItem(id: eventItemProxy.id, - body: eventItemProxy.body ?? "", - formattedBody: formattedBody, - timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - properties: RoomTimelineItemProperties(isEdited: eventItemProxy.content.asMessage()?.isEdited() ?? false, - reactions: aggregateReactions(eventItemProxy.reactions))) - } - private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) - - return TextRoomTimelineItem(id: message.id, - body: message.body, - formattedBody: formattedBody, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus)) - } - - private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - var aspectRatio: CGFloat? - if let width = message.width, - let height = message.height { - aspectRatio = width / height - } - - return ImageRoomTimelineItem(id: message.id, - body: message.body, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - source: message.source, - width: message.width, - height: message.height, - aspectRatio: aspectRatio, - blurhash: message.blurhash, - contentType: message.contentType, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus)) - } - - private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - var aspectRatio: CGFloat? - if let width = message.width, - let height = message.height { - aspectRatio = width / height - } - - return VideoRoomTimelineItem(id: message.id, - body: message.body, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - duration: message.duration, - source: message.source, - thumbnailSource: message.thumbnailSource, - width: message.width, - height: message.height, - aspectRatio: aspectRatio, - blurhash: message.blurhash, - contentType: message.contentType, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus)) - } - - private func buildAudioTimelineItem(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - AudioRoomTimelineItem(id: eventItemProxy.id, - body: message.body, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - duration: message.duration, - source: message.source, - contentType: message.contentType, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus)) - } - - private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - FileRoomTimelineItem(id: message.id, - body: message.body, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), + private func buildTextTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: TextMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + TextRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, - source: message.source, - thumbnailSource: message.thumbnailSource, - contentType: message.contentType, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, + content: buildTextTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) } - private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) - - return NoticeRoomTimelineItem(id: message.id, - body: message.body, - formattedBody: formattedBody, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus)) + private func buildImageTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: ImageMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + ImageRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + content: buildImageTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) } - private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, - _ message: MessageTimelineItem, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id - - var formattedBody: AttributedString? - if let htmlBody = message.htmlBody { - formattedBody = attributedStringBuilder.fromHTML("* \(name) \(htmlBody)") - } else { - formattedBody = attributedStringBuilder.fromPlain("* \(name) \(message.body)") - } - - return EmoteRoomTimelineItem(id: message.id, - body: message.body, - formattedBody: formattedBody, - timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - sender: eventItemProxy.sender, - properties: RoomTimelineItemProperties(isEdited: message.isEdited, - reactions: aggregateReactions(eventItemProxy.reactions), - deliveryStatus: eventItemProxy.deliveryStatus)) + private func buildVideoTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: VideoMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + VideoRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + content: buildVideoTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) + } + + private func buildAudioTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: AudioMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + AudioRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + content: buildAudioTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) + } + + private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: FileMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + FileRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + content: buildFileTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) + } + + private func buildNoticeTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: NoticeMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + NoticeRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + content: buildNoticeTimelineItemContent(messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) + } + + private func buildEmoteTimelineItem(for eventItemProxy: EventTimelineItemProxy, + _ messageTimelineItem: Message, + _ messageContent: EmoteMessageContent, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + EmoteRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: messageContent), + replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), + properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) } private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] { @@ -338,41 +288,129 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } } - // MARK: - State Events + // MARK: - Message events content - private func buildStateTimelineItemFor(eventItemProxy: EventTimelineItemProxy, - state: OtherState, - stateKey: String, - isOutgoing: Bool) -> RoomTimelineItemProtocol? { - guard let text = stateEventStringBuilder.buildString(for: state, stateKey: stateKey, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil } - return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing) + private func buildTextTimelineItemContent(_ messageContent: TextMessageContent) -> TextRoomTimelineItemContent { + let htmlBody = messageContent.formatted?.format == .html ? messageContent.formatted?.body : nil + let formattedBody = (htmlBody != nil ? attributedStringBuilder.fromHTML(htmlBody) : attributedStringBuilder.fromPlain(messageContent.body)) + + return .init(body: messageContent.body, formattedBody: formattedBody) } - private func buildStateMembershipChangeTimelineItemFor(eventItemProxy: EventTimelineItemProxy, - member: String, - membershipChange: MembershipChange?, - isOutgoing: Bool) -> RoomTimelineItemProtocol? { + private func buildAudioTimelineItemContent(_ messageContent: AudioMessageContent) -> AudioRoomTimelineItemContent { + AudioRoomTimelineItemContent(body: messageContent.body, + duration: messageContent.info?.duration ?? 0, + source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), + contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) + } + + private func buildImageTimelineItemContent(_ messageContent: ImageMessageContent) -> ImageRoomTimelineItemContent { + let thumbnailSource = messageContent.info?.thumbnailSource.map { MediaSourceProxy(source: $0, mimeType: messageContent.info?.thumbnailInfo?.mimetype) } + let width = messageContent.info?.width.map(CGFloat.init) + let height = messageContent.info?.height.map(CGFloat.init) + + var aspectRatio: CGFloat? + if let width, let height { + aspectRatio = width / height + } + + return .init(body: messageContent.body, + source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), + thumbnailSource: thumbnailSource, + width: width, + height: height, + aspectRatio: aspectRatio, + blurhash: messageContent.info?.blurhash, + contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) + } + + private func buildVideoTimelineItemContent(_ messageContent: VideoMessageContent) -> VideoRoomTimelineItemContent { + let thumbnailSource = messageContent.info?.thumbnailSource.map { MediaSourceProxy(source: $0, mimeType: messageContent.info?.thumbnailInfo?.mimetype) } + let width = messageContent.info?.width.map(CGFloat.init) + let height = messageContent.info?.height.map(CGFloat.init) + + var aspectRatio: CGFloat? + if let width, let height { + aspectRatio = width / height + } + + return .init(body: messageContent.body, + duration: messageContent.info?.duration ?? 0, + source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), + thumbnailSource: thumbnailSource, + width: width, + height: height, + aspectRatio: aspectRatio, + blurhash: messageContent.info?.blurhash, + contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) + } + + private func buildFileTimelineItemContent(_ messageContent: FileMessageContent) -> FileRoomTimelineItemContent { + let thumbnailSource = messageContent.info?.thumbnailSource.map { MediaSourceProxy(source: $0, mimeType: messageContent.info?.thumbnailInfo?.mimetype) } + + return .init(body: messageContent.body, + source: MediaSourceProxy(source: messageContent.source, mimeType: messageContent.info?.mimetype), + thumbnailSource: thumbnailSource, + contentType: UTType(mimeType: messageContent.info?.mimetype, fallbackFilename: messageContent.body)) + } + + private func buildNoticeTimelineItemContent(_ messageContent: NoticeMessageContent) -> NoticeRoomTimelineItemContent { + let htmlBody = messageContent.formatted?.format == .html ? messageContent.formatted?.body : nil + let formattedBody = (htmlBody != nil ? attributedStringBuilder.fromHTML(htmlBody) : attributedStringBuilder.fromPlain(messageContent.body)) + + return .init(body: messageContent.body, formattedBody: formattedBody) + } + + private func buildEmoteTimelineItemContent(senderDisplayName: String?, senderID: String, messageContent: EmoteMessageContent) -> EmoteRoomTimelineItemContent { + let name = senderDisplayName ?? senderID + + let htmlBody = messageContent.formatted?.format == .html ? messageContent.formatted?.body : nil + + var formattedBody: AttributedString? + if let htmlBody { + formattedBody = attributedStringBuilder.fromHTML(L10n.commonEmote(name, htmlBody)) + } else { + formattedBody = attributedStringBuilder.fromPlain(L10n.commonEmote(name, messageContent.body)) + } + + return .init(body: messageContent.body, formattedBody: formattedBody) + } + + // MARK: - State Events + + private func buildStateTimelineItem(for eventItemProxy: EventTimelineItemProxy, + state: OtherState, + stateKey: String, + isOutgoing: Bool) -> RoomTimelineItemProtocol? { + guard let text = stateEventStringBuilder.buildString(for: state, stateKey: stateKey, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil } + return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing) + } + + private func buildStateMembershipChangeTimelineItem(for eventItemProxy: EventTimelineItemProxy, + member: String, + membershipChange: MembershipChange?, + isOutgoing: Bool) -> RoomTimelineItemProtocol? { guard let text = stateEventStringBuilder.buildString(for: membershipChange, member: member, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil } - return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing) + return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing) } // swiftlint:disable:next function_parameter_count - private func buildStateProfileChangeTimelineItemFor(eventItemProxy: EventTimelineItemProxy, - displayName: String?, - previousDisplayName: String?, - avatarURLString: String?, - previousAvatarURLString: String?, - isOutgoing: Bool) -> RoomTimelineItemProtocol? { + private func buildStateProfileChangeTimelineItem(for eventItemProxy: EventTimelineItemProxy, + displayName: String?, + previousDisplayName: String?, + avatarURLString: String?, + previousAvatarURLString: String?, + isOutgoing: Bool) -> RoomTimelineItemProtocol? { guard let text = stateEventStringBuilder.buildProfileChangeString(displayName: displayName, previousDisplayName: previousDisplayName, avatarURLString: avatarURLString, previousAvatarURLString: previousAvatarURLString, member: eventItemProxy.sender.id, memberIsYou: isOutgoing) else { return nil } - return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing) + return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing) } - private func buildStateTimelineItem(eventItemProxy: EventTimelineItemProxy, text: String, isOutgoing: Bool) -> RoomTimelineItemProtocol { + private func buildStateTimelineItem(for eventItemProxy: EventTimelineItemProxy, text: String, isOutgoing: Bool) -> RoomTimelineItemProtocol { StateRoomTimelineItem(id: eventItemProxy.id, body: text, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), @@ -380,4 +418,54 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isEditable: false, sender: eventItemProxy.sender) } + + // MARK: - Reply details + + // swiftlint:disable:next cyclomatic_complexity + private func buildReplyToDetailsFrom(details: InReplyToDetails?) -> TimelineItemReplyDetails? { + guard let details else { return nil } + + switch details.event { + case .unavailable: + return .notLoaded(eventID: details.eventId) + case .pending: + return .loading(eventID: details.eventId) + case let .ready(message, senderID, senderProfile): + let sender: TimelineItemSender + switch senderProfile { + case let .ready(displayName, _, avatarUrl): + sender = TimelineItemSender(id: senderID, + displayName: displayName, + avatarURL: avatarUrl.flatMap(URL.init(string:))) + default: + sender = TimelineItemSender(id: senderID, + displayName: nil, + avatarURL: nil) + } + + let replyContent: TimelineItemReplyContent + switch message.msgtype() { + case .audio(let content): + replyContent = .audio(buildAudioTimelineItemContent(content)) + case .emote(let content): + replyContent = .emote(buildEmoteTimelineItemContent(senderDisplayName: sender.displayName, senderID: sender.id, messageContent: content)) + case .file(let content): + replyContent = .file(buildFileTimelineItemContent(content)) + case .image(let content): + replyContent = .image(buildImageTimelineItemContent(content)) + case .notice(let content): + replyContent = .notice(buildNoticeTimelineItemContent(content)) + case .text(let content): + replyContent = .text(buildTextTimelineItemContent(content)) + case .video(let content): + replyContent = .video(buildVideoTimelineItemContent(content)) + case .none: + return nil + } + + return .loaded(sender: sender, content: replyContent) + case let .error(message): + return .error(eventID: details.eventId, message: message) + } + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift index e07c08da2..71f461361 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift @@ -18,5 +18,5 @@ import Foundation @MainActor protocol RoomTimelineItemFactoryProtocol { - func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? + func buildTimelineItem(for eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? } diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index acceac215..0634310f8 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -341,7 +341,6 @@ class AttributedStringBuilderTests: XCTestCase { } XCTAssertTrue(component.isBlockquote, "The reply quote should be a blockquote.") - XCTAssertTrue(component.isReply, "The reply quote should be detected as a reply") } func testMultipleGroupedBlockquotes() { @@ -388,9 +387,6 @@ class AttributedStringBuilderTests: XCTestCase { let coalescedComponents = attributedString.formattedComponents XCTAssertEqual(coalescedComponents.count, 6) - for component in coalescedComponents where component.isReply { - XCTFail("None of the blockquotes should be detected as replies.") - } var numberOfBlockquotes = 0 for run in attributedString.runs where run.elementX.blockquote ?? false && run.link != nil { diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index 2782b38c9..0ad986ccc 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -186,37 +186,55 @@ class LoggingTests: XCTestCase { XCTFail(Constants.genericFailure) return } - + let content = try String(contentsOf: logFile) XCTAssertTrue(content.contains(roomSummary.id)) XCTAssertFalse(content.contains(roomName)) XCTAssertFalse(content.contains(lastMessage)) } + // swiftlint:disable:next function_body_length func testTimelineContentIsRedacted() throws { // Given timeline items that contain text let textAttributedString = "TextAttributed" - let textMessage = TextRoomTimelineItem(id: "mytextmessage", body: "TextString", - formattedBody: AttributedString(textAttributedString), - timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) + let textMessage = TextRoomTimelineItem(id: "mytextmessage", + timestamp: "", + isOutgoing: false, + isEditable: false, + sender: .init(id: "sender"), + content: .init(body: "TextString", formattedBody: AttributedString(textAttributedString))) let noticeAttributedString = "NoticeAttributed" - let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", body: "NoticeString", - formattedBody: AttributedString(noticeAttributedString), - timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) + let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", + timestamp: "", + isOutgoing: false, + isEditable: false, + sender: .init(id: "sender"), + content: .init(body: "NoticeString", formattedBody: AttributedString(noticeAttributedString))) let emoteAttributedString = "EmoteAttributed" - let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", body: "EmoteString", - formattedBody: AttributedString(emoteAttributedString), - timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) - let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString", - timestamp: "", isOutgoing: false, isEditable: false, + let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", + timestamp: "", + isOutgoing: false, + isEditable: false, sender: .init(id: "sender"), - source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif")) - let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString", - timestamp: "", isOutgoing: false, isEditable: false, - sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil) - let fileMessage = FileRoomTimelineItem(id: "myfilemessage", body: "FileString", - timestamp: "", isOutgoing: false, isEditable: false, - sender: .init(id: "sender"), source: nil, thumbnailSource: nil) + content: .init(body: "EmoteString", formattedBody: AttributedString(emoteAttributedString))) + let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", + timestamp: "", + isOutgoing: false, + isEditable: false, + sender: .init(id: "sender"), + content: .init(body: "ImageString", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), thumbnailSource: nil)) + let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", + timestamp: "", + isOutgoing: false, + isEditable: false, + sender: .init(id: "sender"), + content: .init(body: "VideoString", duration: 0, source: nil, thumbnailSource: nil)) + let fileMessage = FileRoomTimelineItem(id: "myfilemessage", + timestamp: "", + isOutgoing: false, + isEditable: false, + sender: .init(id: "sender"), + content: .init(body: "FileString", source: nil, thumbnailSource: nil, contentType: nil)) // When logging that value XCTAssert(MXLogger.logFiles.isEmpty) diff --git a/UnitTests/Sources/MessageTimelineItemTests.swift b/UnitTests/Sources/MessageTimelineItemTests.swift deleted file mode 100644 index ea3bb4b84..000000000 --- a/UnitTests/Sources/MessageTimelineItemTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// Copyright 2023 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -@testable import ElementX -import MatrixRustSDK -import XCTest - -class MessageTimelineItemTests: XCTestCase { - // MARK: Image - - func testImageContentType() { - let mimetype = "image/gif" - let imageContent = ImageMessageContent(body: "amazing.gif", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeImageInfo(mimetype: mimetype)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .gif) - } - - func testImageContentTypeWithoutMimetype() { - let imageContent = ImageMessageContent(body: "amazing.jpeg", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeImageInfo(mimetype: nil)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .jpeg) - } - - func makeImageInfo(mimetype: String?) -> ImageInfo { - ImageInfo(height: nil, - width: nil, - mimetype: mimetype, - size: nil, - thumbnailInfo: nil, - thumbnailSource: nil, - blurhash: nil) - } - - // MARK: Video - - func testVideoContentType() { - let mimetype = "video/x-msvideo" - let imageContent = VideoMessageContent(body: "amazing.avi", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeVideoInfo(mimetype: mimetype)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .avi) - } - - func testVideoContentTypeWithoutMimetype() { - let imageContent = VideoMessageContent(body: "amazing.mp4", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeVideoInfo(mimetype: nil)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .mpeg4Movie) - } - - func makeVideoInfo(mimetype: String?) -> VideoInfo { - VideoInfo(duration: nil, - height: nil, - width: nil, - mimetype: mimetype, - size: nil, - thumbnailInfo: nil, - thumbnailSource: nil, - blurhash: nil) - } - - // MARK: Audio - - func testAudioContentType() { - let mimetype = "audio/mp3" - let imageContent = AudioMessageContent(body: "amazing.mp3", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeAudioInfo(mimetype: mimetype)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .mp3) - } - - func testAudioContentTypeWithoutMimetype() { - let imageContent = AudioMessageContent(body: "amazing.m4a", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeAudioInfo(mimetype: nil)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - - // swiftlint:disable:next force_unwrapping - XCTAssertTrue(messageItem.contentType!.conforms(to: .mpeg4Audio)) - } - - func makeAudioInfo(mimetype: String?) -> AudioInfo { - AudioInfo(duration: nil, size: nil, mimetype: mimetype) - } - - // MARK: File - - func testFileContentType() { - let mimetype = "text" - let imageContent = FileMessageContent(body: "amazing.txt", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeFileInfo(mimetype: mimetype)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .plainText) - } - - func testFileContentTypeWithoutMimetype() { - let imageContent = FileMessageContent(body: "amazing.rtf", - source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), - info: makeFileInfo(mimetype: nil)) - let messageItem = MockEventTimelineItem.message(from: imageContent) - XCTAssertEqual(messageItem.contentType, .rtf) - } - - func makeFileInfo(mimetype: String?) -> FileInfo { - FileInfo(mimetype: mimetype, size: nil, thumbnailInfo: nil, thumbnailSource: nil) - } -} - -// MARK: - Mocks - -private struct MockEventTimelineItem: EventTimelineItemProtocol { - static func message(from content: Content) -> MessageTimelineItem { - let item = MockEventTimelineItem(underlyingContent: content) - return MessageTimelineItem(item: item, content: content) - } - - let underlyingContent: MessageContentProtocol - - // swiftlint:disable:next force_cast - func content() -> MatrixRustSDK.TimelineItemContent { underlyingContent as! TimelineItemContent } - - func eventId() -> String? { UUID().uuidString } - - func debugInfo() -> MatrixRustSDK.EventTimelineItemDebugInfo { .init(model: "MockEvent", originalJson: nil, latestEditJson: nil) } - - func isEditable() -> Bool { false } - - func isLocal() -> Bool { false } - - func isOwn() -> Bool { false } - - func isRemote() -> Bool { true } - - func localSendState() -> MatrixRustSDK.EventSendState? { nil } - - func raw() -> String? { nil } - - func reactions() -> [MatrixRustSDK.Reaction] { [] } - - func sender() -> String { "@user:server.com" } - - func senderProfile() -> MatrixRustSDK.ProfileDetails { .unavailable } - - func timestamp() -> UInt64 { 0 } - - func uniqueIdentifier() -> String { eventId() ?? "" } -} diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index f34de4f8a..cd921506a 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -155,11 +155,11 @@ class RoomScreenViewModelTests: XCTestCase { private extension TextRoomTimelineItem { init(text: String, sender: String, addReactions: Bool = false) { self.init(id: UUID().uuidString, - body: text, timestamp: "10:47 am", isOutgoing: sender == "bob", isEditable: sender == "bob", sender: .init(id: "@\(sender):server.com", displayName: sender), + content: .init(body: text), properties: RoomTimelineItemProperties(reactions: addReactions ? [ AggregatedReaction(key: "🦄", count: 1, isHighlighted: false) ] : []))