diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 124426100..819396882 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -443,6 +443,7 @@ 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */; }; 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; + 915C0DB82AB3284300C2C3F6 /* TimelineMediaFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915C0DB72AB3284300C2C3F6 /* TimelineMediaFrame.swift */; }; 91ABC91758A6E4A5FAA2E9C4 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */; }; 92133B170A1F917685E9FF78 /* OnboardingScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D168471461717AF5689F64B /* OnboardingScreenUITests.swift */; }; 9219640F4D980CFC5FE855AD /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 536E72DCBEEC4A1FE66CFDCE /* target.yml */; }; @@ -523,6 +524,9 @@ A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; }; A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; }; A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; }; + A762491F2AAF6EA800ADB9E6 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A762491E2AAF6EA800ADB9E6 /* CustomLayoutLabelStyle.swift */; }; + A76249212AB1F56400ADB9E6 /* ThreadDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76249202AB1F56400ADB9E6 /* ThreadDecorator.swift */; }; + A76249232AB2064800ADB9E6 /* SwipeToReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76249222AB2064800ADB9E6 /* SwipeToReplyView.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; }; @@ -1273,6 +1277,7 @@ 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; 90A55430639712CFACA34F43 /* TextRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItem.swift; sourceTree = ""; }; 90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = ""; }; + 915C0DB72AB3284300C2C3F6 /* TimelineMediaFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaFrame.swift; sourceTree = ""; }; 91CF6F7D08228D16BA69B63B /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.strings"; sourceTree = ""; }; 923485F85E1D765EF9D20E88 /* UserProfileCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileCell.swift; sourceTree = ""; }; 92390F9FA98255440A6BF5F8 /* OIDCAuthenticationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCAuthenticationPresenter.swift; sourceTree = ""; }; @@ -1333,6 +1338,9 @@ A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; + A762491E2AAF6EA800ADB9E6 /* CustomLayoutLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLayoutLabelStyle.swift; sourceTree = ""; }; + A76249202AB1F56400ADB9E6 /* ThreadDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadDecorator.swift; sourceTree = ""; }; + A76249222AB2064800ADB9E6 /* SwipeToReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToReplyView.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; @@ -3182,6 +3190,8 @@ 94BCC8A9C73C1F838122C645 /* TimelineItemPlainStylerView.swift */, 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */, 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */, + A76249202AB1F56400ADB9E6 /* ThreadDecorator.swift */, + A76249222AB2064800ADB9E6 /* SwipeToReplyView.swift */, ); path = Style; sourceTree = ""; @@ -3575,6 +3585,8 @@ 565F1B2B300597C616B37888 /* FullscreenDialog.swift */, 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */, EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */, + A762491E2AAF6EA800ADB9E6 /* CustomLayoutLabelStyle.swift */, + 915C0DB72AB3284300C2C3F6 /* TimelineMediaFrame.swift */, ); path = Layout; sourceTree = ""; @@ -4525,6 +4537,7 @@ 663E198678778F7426A9B27D /* Collection.swift in Sources */, 24B7CD41342C143117ADA768 /* Comparable.swift in Sources */, 0B57C2399B9E1CE5CE0D8005 /* ComposerToolbar.swift in Sources */, + A76249212AB1F56400ADB9E6 /* ThreadDecorator.swift in Sources */, 56BAB81A0D03C2EF09B86294 /* ComposerToolbarModels.swift in Sources */, 5995C63B1C61DE1373AA2BCE /* ComposerToolbarViewModel.swift in Sources */, 9BEA56957B3AF954E7321658 /* ComposerToolbarViewModelProtocol.swift in Sources */, @@ -4718,6 +4731,7 @@ FD4C21F8DA1E273DE94FCD1A /* NotificationItemProxyProtocol.swift in Sources */, 652ACCF104A8CEF30788963C /* NotificationManager.swift in Sources */, 06D3942496E9E0E655F14D21 /* NotificationManagerProtocol.swift in Sources */, + 915C0DB82AB3284300C2C3F6 /* TimelineMediaFrame.swift in Sources */, C4C84901ABAC9B17564AB7EB /* NotificationName.swift in Sources */, C0090506A52A1991BAF4BA68 /* NotificationSettingsChatType.swift in Sources */, AA93B3F9B5DD097DEF79F981 /* NotificationSettingsEditScreen.swift in Sources */, @@ -4821,6 +4835,7 @@ 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */, 7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */, 617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */, + A76249232AB2064800ADB9E6 /* SwipeToReplyView.swift in Sources */, 6C34237AFB808E38FC8776B9 /* RoomStateEventStringBuilder.swift in Sources */, A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */, 983896D611ABF52A5C37498D /* RoomSummaryProvider.swift in Sources */, @@ -4893,6 +4908,7 @@ B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */, B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */, 1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */, + A762491F2AAF6EA800ADB9E6 /* CustomLayoutLabelStyle.swift in Sources */, DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */, 5C164551F7D26E24F09083D3 /* StaticLocationScreenViewModelProtocol.swift in Sources */, C58E305C380D3ADDF7912180 /* StickerRoomTimelineItem.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index ece7f9aa9..ba929bb2e 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -47,6 +47,7 @@ "action_react" = "React"; "action_remove" = "Remove"; "action_reply" = "Reply"; +"action_reply_in_thread" = "Reply in thread"; "action_report_bug" = "Report bug"; "action_report_content" = "Report Content"; "action_retry" = "Retry"; @@ -128,6 +129,7 @@ "common_syncing" = "Syncing"; "common_text" = "Text"; "common_third_party_notices" = "Third-party notices"; +"common_thread" = "Thread"; "common_topic" = "Topic"; "common_topic_placeholder" = "What is this room about?"; "common_unable_to_decrypt" = "Unable to decrypt"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 77937cccf..63ef9602f 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -110,6 +110,8 @@ public enum L10n { public static var actionRemove: String { return L10n.tr("Localizable", "action_remove") } /// Reply public static var actionReply: String { return L10n.tr("Localizable", "action_reply") } + /// Reply in thread + public static var actionReplyInThread: String { return L10n.tr("Localizable", "action_reply_in_thread") } /// Report bug public static var actionReportBug: String { return L10n.tr("Localizable", "action_report_bug") } /// Report Content @@ -288,6 +290,8 @@ public enum L10n { public static var commonText: String { return L10n.tr("Localizable", "common_text") } /// Third-party notices public static var commonThirdPartyNotices: String { return L10n.tr("Localizable", "common_third_party_notices") } + /// Thread + public static var commonThread: String { return L10n.tr("Localizable", "common_thread") } /// Topic public static var commonTopic: String { return L10n.tr("Localizable", "common_topic") } /// What is this room about? diff --git a/ElementX/Sources/Other/SwiftUI/Layout/CustomLayoutLabelStyle.swift b/ElementX/Sources/Other/SwiftUI/Layout/CustomLayoutLabelStyle.swift new file mode 100644 index 000000000..4ef0533b9 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Layout/CustomLayoutLabelStyle.swift @@ -0,0 +1,50 @@ +// +// 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 + +extension LabelStyle where Self == CustomLayoutLabelStyle { + /// A label style that uses an `HStack` with parameters to customise the label's layout. + static func custom(spacing: CGFloat, alignment: VerticalAlignment = .center, iconLayout: Self.IconLayout = .leading) -> Self { + CustomLayoutLabelStyle(spacing: spacing, alignment: alignment, iconLayout: iconLayout) + } +} + +struct CustomLayoutLabelStyle: LabelStyle { + let spacing: CGFloat + var alignment: VerticalAlignment + + enum IconLayout { case leading, trailing } + var iconLayout: IconLayout + + fileprivate init(spacing: CGFloat, alignment: VerticalAlignment, iconLayout: IconLayout) { + self.spacing = spacing + self.alignment = alignment + self.iconLayout = iconLayout + } + + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: alignment, spacing: spacing) { + if iconLayout == .leading { + configuration.icon + configuration.title + } else { + configuration.title + configuration.icon + } + } + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Layout/TimelineMediaFrame.swift b/ElementX/Sources/Other/SwiftUI/Layout/TimelineMediaFrame.swift new file mode 100644 index 000000000..80825a461 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Layout/TimelineMediaFrame.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 SwiftUI + +extension View { + /// Constrains the max height of a media item in the timeline, whilst preserving its aspect ratio. + func timelineMediaFrame(height contentHeight: CGFloat?, aspectRatio contentAspectRatio: CGFloat?) -> some View { + aspectRatio(contentAspectRatio, contentMode: .fit) + .frame(maxHeight: min(300, max(100, contentHeight ?? .infinity))) + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index 793dda502..e3772c3a4 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -122,8 +122,17 @@ struct ComposerToolbar: View { } } + private var placeholder: String { + switch context.viewState.composerMode { + case .reply(_, _, let isThread): + return isThread ? L10n.actionReplyInThread : L10n.richTextEditorComposerPlaceholder + default: + return L10n.richTextEditorComposerPlaceholder + } + } + private var composerView: WysiwygComposerView { - WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder, + WysiwygComposerView(placeholder: placeholder, viewModel: wysiwygViewModel, itemProviderHelper: ItemProviderHelper(), keyCommandHandler: keyCommandHandler) { provider in diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index f8c198e11..d3a0d2b93 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -86,7 +86,7 @@ struct MessageComposer: View { @ViewBuilder private var header: some View { switch mode { - case .reply(_, let replyDetails): + case .reply(_, let replyDetails, _): MessageComposerReplyHeader(replyDetails: replyDetails, action: replyCancellationAction) case .edit: MessageComposerEditHeader(action: editCancellationAction) @@ -176,6 +176,22 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle { struct MessageComposer_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock + + static let replyTypes: [TimelineItemReplyDetails] = [ + .loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, source: nil, contentType: nil))), + .loaded(sender: .init(id: "James"), contentType: .emote(.init(body: "Emote: James thinks he's the phantom lord"))), + .loaded(sender: .init(id: "Robert"), contentType: .file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil))), + .loaded(sender: .init(id: "Cliff"), contentType: .image(.init(body: "Image: Pushead", + source: .init(url: .picturesDirectory, mimeType: nil), + thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))), + .loaded(sender: .init(id: "Jason"), contentType: .notice(.init(body: "Notice: Too far gone?"))), + .loaded(sender: .init(id: "Kirk"), contentType: .text(.init(body: "Text: Where the wild things are"))), + .loaded(sender: .init(id: "Lars"), contentType: .video(.init(body: "Video: Through the never", + duration: 100, + source: nil, + thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))), + .loading(eventID: "") + ] static func messageComposer(_ content: String = "", sendingDisabled: Bool = false, @@ -210,36 +226,32 @@ struct MessageComposer_Previews: PreviewProvider { messageComposer(mode: .reply(itemID: .random, replyDetails: .loaded(sender: .init(id: "Kirk"), - contentType: .text(.init(body: "Text: Where the wild things are"))))) + contentType: .text(.init(body: "Text: Where the wild things are"))), isThread: false)) } .padding(.horizontal) ScrollView { VStack { - let replyTypes: [TimelineItemReplyDetails] = [ - .loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, source: nil, contentType: nil))), - .loaded(sender: .init(id: "James"), contentType: .emote(.init(body: "Emote: James thinks he's the phantom lord"))), - .loaded(sender: .init(id: "Robert"), contentType: .file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil))), - .loaded(sender: .init(id: "Cliff"), contentType: .image(.init(body: "Image: Pushead", - source: .init(url: .picturesDirectory, mimeType: nil), - thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))), - .loaded(sender: .init(id: "Jason"), contentType: .notice(.init(body: "Notice: Too far gone?"))), - .loaded(sender: .init(id: "Kirk"), contentType: .text(.init(body: "Text: Where the wild things are"))), - .loaded(sender: .init(id: "Lars"), contentType: .video(.init(body: "Video: Through the never", - duration: 100, - source: nil, - thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))), - .loading(eventID: "") - ] - ForEach(replyTypes, id: \.self) { replyDetails in messageComposer(mode: .reply(itemID: .random, - replyDetails: replyDetails)) + replyDetails: replyDetails, isThread: false)) } } } .padding(.horizontal) .environmentObject(viewModel.context) .previewDisplayName("Replying") + + ScrollView { + VStack { + ForEach(replyTypes, id: \.self) { replyDetails in + messageComposer(mode: .reply(itemID: .random, + replyDetails: replyDetails, isThread: true)) + } + } + } + .padding(.horizontal) + .environmentObject(viewModel.context) + .previewDisplayName("Replying in thread") } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 727220541..f056dbb69 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -38,7 +38,7 @@ enum RoomScreenViewModelAction { enum RoomScreenComposerMode: Equatable { case `default` - case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails) + case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool) case edit(originalItemId: TimelineItemIdentifier) var isEdit: Bool { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index b51926b18..f40e620ec 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -435,7 +435,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol actionsSubject.send(.composer(action: .clear)) switch mode { - case .reply(let itemId, _): + case .reply(let itemId, _, _): await timelineController.sendMessage(message, html: html, inReplyTo: itemId) case .edit(let originalItemId): await timelineController.editMessage(message, html: html, original: originalItemId) @@ -517,8 +517,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol var actions: [TimelineItemMenuAction] = [] - if item.isMessage { - actions.append(.reply) + if let messageitem = item as? EventBasedMessageTimelineItemProtocol { + actions.append(.reply(isThread: messageitem.isThreaded)) actions.append(.forward(itemID: itemID)) } @@ -601,9 +601,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } case .reply: - let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: buildReplyContent(for: eventTimelineItem)) + let replyInfo = buildReplyInfo(for: eventTimelineItem) + let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: replyInfo.type) - actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails)))) + actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails, isThread: replyInfo.isThread)))) case .forward(let itemID): actionsSubject.send(.displayMessageForwarding(itemID: itemID)) case .viewSource: @@ -681,12 +682,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } - private func buildReplyContent(for item: EventBasedTimelineItemProtocol) -> EventBasedMessageTimelineItemContentType { + private func buildReplyInfo(for item: EventBasedTimelineItemProtocol) -> ReplyInfo { guard let messageItem = item as? EventBasedMessageTimelineItemProtocol else { - return .text(.init(body: item.body)) + return .init(type: .text(.init(body: item.body)), isThread: false) } - return messageItem.contentType + return .init(type: messageItem.contentType, isThread: messageItem.isThreaded) } private func handleTappedUser(userID: String) async { @@ -863,3 +864,8 @@ extension RoomScreenViewModel { analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) } + +private struct ReplyInfo { + let type: EventBasedMessageTimelineItemContentType + let isThread: Bool +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/SwipeToReplyView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/SwipeToReplyView.swift new file mode 100644 index 000000000..f839ec083 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/SwipeToReplyView.swift @@ -0,0 +1,56 @@ +// +// 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 SwipeToReplyView: View { + let timelineItem: RoomTimelineItemProtocol + + var body: some View { + VStack(spacing: 4) { + Image(systemName: "arrowshape.turn.up.left") + .foregroundColor(.compound.iconPrimary) + if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol, + messageTimelineItem.isThreaded { + Text(L10n.actionReplyInThread) + .font(.compound.bodyXS) + .foregroundColor(.compound.textPrimary) + } + } + .accessibilityHidden(true) + } +} + +struct SwipeToReplyView_Previews: PreviewProvider { + static let timelineItem = TextRoomTimelineItem(id: .init(timelineID: ""), + timestamp: "", + isOutgoing: true, + isEditable: true, + isThreaded: false, sender: .init(id: ""), + content: .init(body: "")) + static let threadedTimelineItem = TextRoomTimelineItem(id: .init(timelineID: ""), + timestamp: "", + isOutgoing: true, + isEditable: true, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "")) + + static var previews: some View { + SwipeToReplyView(timelineItem: timelineItem) + SwipeToReplyView(timelineItem: threadedTimelineItem) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/ThreadDecorator.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/ThreadDecorator.swift new file mode 100644 index 000000000..916ef8865 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/ThreadDecorator.swift @@ -0,0 +1,40 @@ +// +// 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 + +import Compound + +struct ThreadDecorator: View { + var body: some View { + Label { + Text(L10n.commonThread) + .foregroundColor(.compound.textPrimary) + .font(.compound.bodyXS) + } icon: { + CompoundIcon(\.threads) + .font(.system(size: 16)) + .foregroundColor(.compound.iconSecondary) + } + .labelStyle(.custom(spacing: 4)) + } +} + +struct ThreadDecorator_Previews: PreviewProvider { + static var previews: some View { + ThreadDecorator() + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 25bd1d5d4..23b5589c3 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -17,6 +17,8 @@ import Foundation import SwiftUI +import Compound + struct TimelineItemBubbledStylerView: View { @EnvironmentObject private var context: RoomScreenViewModel.Context @Environment(\.timelineGroupStyle) private var timelineGroupStyle @@ -130,12 +132,12 @@ struct TimelineItemBubbledStylerView: View { context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { - Image(systemName: "arrowshape.turn.up.left") - .accessibilityHidden(true) + SwipeToReplyView(timelineItem: timelineItem) } shouldStartAction: { context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false } action: { - context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply)) + let isThread = (timelineItem as? EventBasedMessageTimelineItemProtocol)?.isThreaded ?? false + context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: isThread))) } .contextMenu { TimelineItemMacContextMenu(item: timelineItem, @@ -217,28 +219,35 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var contentWithReply: some View { TimelineBubbleLayout(spacing: 8) { - if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol, - let replyDetails = messageTimelineItem.replyDetails { - // The rendered reply bubble with a greedy width. The custom layout prevents - // the infinite width from increasing the overall width of the view. - TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) - .fixedSize(horizontal: false, vertical: true) - .padding(4.0) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.compound.bgCanvasDefault) - .cornerRadius(8) - .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) - - // Add a fixed width reply bubble that is used for layout calculations but won't be rendered. - TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) - .fixedSize(horizontal: false, vertical: true) - .padding(4.0) - .layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote) - .hidden() + if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol { + if messageTimelineItem.isThreaded { + ThreadDecorator() + .padding(.leading, 4) + .layoutPriority(TimelineBubbleLayout.Priority.regularText) + } + if let replyDetails = messageTimelineItem.replyDetails { + // The rendered reply bubble with a greedy width. The custom layout prevents + // the infinite width from increasing the overall width of the view. + TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) + .fixedSize(horizontal: false, vertical: true) + .padding(4.0) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.compound.bgCanvasDefault) + .cornerRadius(8) + .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) + + // Add a fixed width reply bubble that is used for layout calculations but won't be rendered. + TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) + .fixedSize(horizontal: false, vertical: true) + .padding(4.0) + .layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote) + .hidden() + } } content() .layoutPriority(TimelineBubbleLayout.Priority.regularText) + .cornerRadius(timelineItem.contentCornerRadius) } } @@ -312,9 +321,15 @@ private extension EventBasedTimelineItemProtocol { let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming switch self { - case is ImageRoomTimelineItem, - is VideoRoomTimelineItem, - is StickerRoomTimelineItem: + case let self as EventBasedMessageTimelineItemProtocol: + switch self { + case is ImageRoomTimelineItem, is VideoRoomTimelineItem: + // In case a reply detail or a thread decorator is present we render the color and the padding + return self.replyDetails != nil || self.isThreaded ? defaultColor : nil + default: + return defaultColor + } + case is StickerRoomTimelineItem: return nil default: return defaultColor @@ -327,14 +342,24 @@ private extension EventBasedTimelineItemProtocol { let defaultInsets: EdgeInsets = .init(around: 8) switch self { - case is ImageRoomTimelineItem, - is VideoRoomTimelineItem, - is StickerRoomTimelineItem: + case is StickerRoomTimelineItem: return .zero case is PollRoomTimelineItem: return .init(top: 12, leading: 12, bottom: 4, trailing: 12) - case let locationTimelineItem as LocationRoomTimelineItem: - return locationTimelineItem.content.geoURI == nil ? defaultInsets : .zero + case let self as EventBasedMessageTimelineItemProtocol: + switch self { + // In case a reply detail or a thread decorator is present we render the color and the padding + case is ImageRoomTimelineItem, + is VideoRoomTimelineItem: + return self.replyDetails != nil || + self.isThreaded ? defaultInsets : .zero + case let locationTimelineItem as LocationRoomTimelineItem: + return locationTimelineItem.content.geoURI == nil || + self.replyDetails != nil || + self.isThreaded ? defaultInsets : .zero + default: + return defaultInsets + } default: return defaultInsets } @@ -358,6 +383,17 @@ private extension EventBasedTimelineItemProtocol { return defaultTimestampLayout } } + + var contentCornerRadius: CGFloat { + guard let message = self as? EventBasedMessageTimelineItemProtocol else { return .zero } + + switch message { + case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is LocationRoomTimelineItem: + return message.replyDetails != nil || message.isThreaded ? 8 : .zero + default: + return .zero + } + } } struct TimelineItemBubbledStylerView_Previews: PreviewProvider { @@ -374,6 +410,80 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { .previewDisplayName("Mock Timeline RTL") replies .previewDisplayName("Replies") + threads + .previewDisplayName("Thread decorator") + } + + // These akwats include a reply + static var threads: some View { + ScrollView { + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + isThreaded: true, + sender: .init(id: "whoever"), + content: .init(body: "A long message that should be on multiple lines."), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short")))), groupStyle: .single)) + + AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + source: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "File", + source: nil, + thumbnailSource: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: true, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + LocationRoomTimelineView(timelineItem: .init(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, + longitude: 12.496366), + description: "Location description description description description description description description description"), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + LocationRoomTimelineView(timelineItem: .init(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + } + .environmentObject(viewModel.context) } static var mockTimeline: some View { @@ -396,6 +506,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { timestamp: "10:42", isOutgoing: true, isEditable: false, + isThreaded: false, sender: .init(id: "whoever"), content: .init(body: "A long message that should be on multiple lines."), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), @@ -405,6 +516,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { timestamp: "10:42", isOutgoing: true, isEditable: false, + isThreaded: false, sender: .init(id: "whoever"), content: .init(body: "Short message"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index a606ded73..a9d0d481c 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -48,17 +48,22 @@ 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(.global.melon) - .frame(width: 4.0) - TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) + if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol { + if messageTimelineItem.isThreaded { + ThreadDecorator() + } + if let replyDetails = messageTimelineItem.replyDetails { + HStack(spacing: 4.0) { + Rectangle() + .foregroundColor(.global.melon) + .frame(width: 4.0) + TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) + } } } content() + .layoutPriority(1) } .onTapGesture(count: 2) { context.send(viewAction: .displayEmojiPicker(itemID: timelineItem.id)) @@ -72,11 +77,12 @@ struct TimelineItemPlainStylerView: View { context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { - Image(systemName: "arrowshape.turn.up.left") + SwipeToReplyView(timelineItem: timelineItem) } shouldStartAction: { context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false } action: { - context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply)) + let isThread = (timelineItem as? EventBasedMessageTimelineItemProtocol)?.isThreaded ?? false + context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: isThread))) } .contextMenu { TimelineItemMacContextMenu(item: timelineItem, @@ -136,6 +142,78 @@ struct TimelineItemPlainStylerView: View { struct TimelineItemPlainStylerView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock + + // These akwats include a reply + static var threads: some View { + ScrollView { + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + isThreaded: true, + sender: .init(id: "whoever"), + content: .init(body: "A long message that should be on multiple lines."), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short")))), groupStyle: .single)) + + AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: false, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "audio.ogg", + duration: 100, + source: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "File", + source: nil, + thumbnailSource: nil, + contentType: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), + timestamp: "10:42", + isOutgoing: true, + isEditable: true, + isThreaded: true, + sender: .init(id: ""), + content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + LocationRoomTimelineView(timelineItem: .init(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, + longitude: 12.496366), + description: "Location description description description description description description description description"), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + LocationRoomTimelineView(timelineItem: .init(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), + replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + contentType: .text(.init(body: "Short"))))) + } + .environmentObject(viewModel.context) + } static var previews: some View { VStack(alignment: .leading, spacing: 0) { @@ -148,5 +226,9 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider { .environment(\.timelineStyle, .plain) .previewLayout(.sizeThatFits) .environmentObject(viewModel.context) + threads + .padding() + .environment(\.timelineStyle, .plain) + .previewDisplayName("Threads") } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift index 9dfcac87e..8550c43e4 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift @@ -38,7 +38,7 @@ struct TimelineStyler: View { struct TimelineItemStyler_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock - static let base = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + static let base = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) static let sentNonLast: TextRoomTimelineItem = { var result = base @@ -54,7 +54,7 @@ struct TimelineItemStyler_Previews: PreviewProvider { static let sendingLast: TextRoomTimelineItem = { let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString - var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) result.properties.deliveryStatus = .sending return result }() @@ -67,21 +67,21 @@ struct TimelineItemStyler_Previews: PreviewProvider { static let sentLast: TextRoomTimelineItem = { let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString - let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) return result }() - static let ltrString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "house!")) + static let ltrString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "house!")) - static let rtlString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת!")) + static let rtlString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת!")) - static let ltrStringThatContainsRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת‏! -- house!")) + static let ltrStringThatContainsRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת‏! -- house!")) - static let rtlStringThatContainsLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house! -- באמת!")) + static let rtlStringThatContainsLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house! -- באמת!")) - static let ltrStringThatFinishesInRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת!")) + static let ltrStringThatFinishesInRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת!")) - static let rtlStringThatFinishesInLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house!")) + static let rtlStringThatFinishesInLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house!")) static var testView: some View { VStack { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift index c61ddcb0d..f96f088f6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift @@ -81,7 +81,9 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider { TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, - isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"), + isEditable: false, + isThreaded: false, + sender: .init(id: UUID().uuidString), content: .init(body: "Test"), properties: .init(orderedReadReceipts: receipts)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift index 46b718406..561b58e60 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/SwipeRightAction.swift @@ -88,6 +88,8 @@ struct SwipeRightAction: ViewModifier { .overlay(alignment: .leading) { // We want the action icon to follow the view translation and gradually fade in label() + .multilineTextAlignment(.center) + .frame(maxWidth: actionThreshold) .opacity(xOffset / 50) .animation(.interactiveSpring().speed(0.5), value: xOffset) .offset(x: -actionThreshold + min(xOffset, actionThreshold), y: 0.0) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift index 1c6813bad..24d0fea41 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift @@ -50,6 +50,7 @@ struct AudioRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), 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 e12022c6b..a6d294e7f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift @@ -59,6 +59,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, isOutgoing: false, isEditable: false, + isThreaded: false, 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 ee5be34d0..793aae092 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift @@ -51,6 +51,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "document.pdf", source: nil, thumbnailSource: nil, contentType: nil))) @@ -58,6 +59,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "document.docx", source: nil, thumbnailSource: nil, contentType: nil))) @@ -65,6 +67,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "document.txt", source: nil, thumbnailSource: nil, contentType: nil))) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index c9a5683f8..df7dbd7b4 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -28,8 +28,8 @@ struct ImageRoomTimelineView: View { imageProvider: context.imageProvider) { placeholder } - .frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity))) - .aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit) + .timelineMediaFrame(height: timelineItem.content.height, + aspectRatio: timelineItem.content.height) .accessibilityElement(children: .ignore) .accessibilityLabel(L10n.commonImage) } @@ -72,6 +72,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil))) @@ -79,6 +80,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Some other image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil))) @@ -86,6 +88,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Blurhashed image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/LocationRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/LocationRoomTimelineView.swift index 15edb56a4..3c773d3f8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/LocationRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/LocationRoomTimelineView.swift @@ -86,20 +86,33 @@ struct LocationRoomTimelineView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock static var previews: some View { - body - .environmentObject(viewModel.context) - - body - .environment(\.timelineStyle, .plain) - .environmentObject(viewModel.context) + ScrollView { + VStack { + states + .padding(.horizontal) + } + } + .environmentObject(viewModel.context) + .previewDisplayName("Bubbles") + + ScrollView { + VStack { + states + .padding(.horizontal) + } + } + .environment(\.timelineStyle, .plain) + .environmentObject(viewModel.context) + .previewDisplayName("Plain") } @ViewBuilder - static var body: some View { + static var states: some View { LocationRoomTimelineView(timelineItem: .init(id: .random, timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Fallback geo uri description"))) @@ -107,8 +120,19 @@ struct LocationRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Fallback geo uri description", geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"))) + LocationRoomTimelineView(timelineItem: .init(id: .random, + timestamp: "Now", + isOutgoing: false, + isEditable: false, + isThreaded: true, + sender: .init(id: "Bob"), + content: .init(body: "Fallback geo uri description", + geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"), + replyDetails: .loaded(sender: .init(id: "Someone"), + contentType: .text(.init(body: "The thread content goes 'ere."))))) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index c86262bc6..8b152b4a7 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -70,6 +70,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, isOutgoing: false, isEditable: false, + isThreaded: false, 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 74bdd4179..60f92e992 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift @@ -47,6 +47,7 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { timestamp: "", isOutgoing: true, isEditable: false, + isThreaded: false, sender: .init(id: "1", displayName: "Bob"), content: .init(body: "This is another message"))), groupStyle: .single)) @@ -57,6 +58,7 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "", displayName: "Alice"), content: .init(body: "This is a message"))), groupStyle: .single)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift index 7a58c4bae..6e8d89a4e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift @@ -28,8 +28,8 @@ struct StickerRoomTimelineView: View { imageProvider: context.imageProvider) { placeholder } - .frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity))) - .aspectRatio(timelineItem.aspectRatio, contentMode: .fit) + .timelineMediaFrame(height: timelineItem.height, + aspectRatio: timelineItem.aspectRatio) .accessibilityElement(children: .ignore) .accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)") } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index b75fd1a5e..04d60e407 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -75,6 +75,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isOutgoing, + isThreaded: false, 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 91f097983..19371c747 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -24,8 +24,8 @@ struct VideoRoomTimelineView: View { var body: some View { TimelineStyler(timelineItem: timelineItem) { thumbnail - .frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity))) - .aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit) + .timelineMediaFrame(height: timelineItem.content.height, + aspectRatio: timelineItem.content.aspectRatio) .accessibilityElement(children: .ignore) .accessibilityLabel(L10n.commonVideo) } @@ -83,6 +83,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Some video", duration: 21, source: nil, thumbnailSource: nil))) @@ -90,6 +91,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), content: .init(body: "Some other video", duration: 22, source: nil, thumbnailSource: nil))) @@ -97,6 +99,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "Bob"), 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/Screens/RoomScreen/View/TimelineItemMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift index 104bbf3d4..19de95698 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift @@ -46,7 +46,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case edit case copyPermalink case redact - case reply + case reply(isThread: Bool) case forward(itemID: TimelineItemIdentifier) case viewSource case retryDecryption(sessionID: String) @@ -106,8 +106,8 @@ enum TimelineItemMenuAction: Identifiable, Hashable { Label(L10n.actionEdit, systemImage: "pencil.line") case .copyPermalink: Label(L10n.actionCopyLinkToMessage, systemImage: "link") - case .reply: - Label(L10n.actionReply, systemImage: "arrowshape.turn.up.left") + case .reply(let isThread): + Label(isThread ? L10n.actionReplyInThread : L10n.actionReply, systemImage: "arrowshape.turn.up.left") case .forward: Label(L10n.actionForward, systemImage: "arrowshape.turn.up.right") case .redact: @@ -292,7 +292,7 @@ struct TimelineItemMenu_Previews: PreviewProvider { static var previews: some View { VStack { if let item = RoomTimelineItemFixtures.singleMessageChunk.first as? EventBasedTimelineItemProtocol, - let actions = TimelineItemMenuActions(actions: [.copy, .edit, .reply, .redact], debugActions: [.viewSource]) { + let actions = TimelineItemMenuActions(actions: [.copy, .edit, .reply(isThread: false), .redact], debugActions: [.viewSource]) { TimelineItemMenu(item: item, actions: actions) } } diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index fbe8610b6..d5ceb91bf 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -24,6 +24,7 @@ enum RoomTimelineItemFixtures { timestamp: "10:10 AM", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "", displayName: "Jacob"), content: .init(body: "That looks so good!"), properties: RoomTimelineItemProperties(isEdited: true)), @@ -31,6 +32,7 @@ enum RoomTimelineItemFixtures { timestamp: "10:11 AM", isOutgoing: false, isEditable: false, + isThreaded: 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: [ @@ -40,6 +42,7 @@ enum RoomTimelineItemFixtures { timestamp: "10:11 AM", isOutgoing: false, isEditable: false, + isThreaded: 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: [ @@ -57,6 +60,7 @@ enum RoomTimelineItemFixtures { timestamp: "5 PM", isOutgoing: false, isEditable: false, + isThreaded: false, 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!"), properties: RoomTimelineItemProperties(orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil)])), @@ -64,12 +68,14 @@ enum RoomTimelineItemFixtures { timestamp: "5 PM", isOutgoing: true, isEditable: true, + isThreaded: false, sender: .init(id: "", displayName: "Bob"), content: .init(body: "And John's speech was amazing!")), TextRoomTimelineItem(id: .random, timestamp: "5 PM", isOutgoing: true, isEditable: true, + isThreaded: false, sender: .init(id: "", displayName: "Bob"), content: .init(body: "New home office set up!"), properties: RoomTimelineItemProperties(reactions: AggregatedReaction.mockReactions, @@ -81,6 +87,7 @@ enum RoomTimelineItemFixtures { timestamp: "5 PM", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "", displayName: "Helena"), content: .init(body: "", formattedBody: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL).fromHTML("Hol' up
New home office set up!
That's amazing! Congrats 🥳"))) @@ -230,6 +237,7 @@ private extension TextRoomTimelineItem { timestamp: "10:47 am", isOutgoing: senderDisplayName == "Alice", isEditable: false, + isThreaded: false, sender: .init(id: "", displayName: senderDisplayName), content: .init(body: text)) } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift index dea98ddde..1422f8e6b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift @@ -30,4 +30,5 @@ enum EventBasedMessageTimelineItemContentType: Hashable { protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol { var replyDetails: TimelineItemReplyDetails? { get } var contentType: EventBasedMessageTimelineItemContentType { get } + var isThreaded: 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 8eaff6d10..8ef97f8dd 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift @@ -21,6 +21,7 @@ struct AudioRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { let timestamp: String let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool let sender: TimelineItemSender let content: AudioRoomTimelineItemContent diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift index 2a3297c1e..426d27c10 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift @@ -21,6 +21,7 @@ struct EmoteRoomTimelineItem: TextBasedRoomTimelineItem, Equatable { let timestamp: String let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool let sender: TimelineItemSender diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift index 1054b6208..2b7e24919 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift @@ -23,6 +23,8 @@ struct FileRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool + let sender: TimelineItemSender let content: FileRoomTimelineItemContent diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index 1285c809b..10be9873b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -22,6 +22,7 @@ struct ImageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { let timestamp: String let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool let sender: TimelineItemSender diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift index a0cd6463c..8a9ad2031 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/LocationRoomTimelineItem.swift @@ -20,7 +20,8 @@ struct LocationRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatabl let timestamp: String let isOutgoing: Bool let isEditable: Bool - + let isThreaded: Bool + let sender: TimelineItemSender let content: LocationRoomTimelineItemContent diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift index b050df8c2..b2720cd5e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift @@ -21,6 +21,7 @@ struct NoticeRoomTimelineItem: TextBasedRoomTimelineItem, Equatable { let timestamp: String let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool let sender: TimelineItemSender diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift index 0ad3634cf..8d212dfda 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift @@ -23,6 +23,8 @@ struct TextRoomTimelineItem: TextBasedRoomTimelineItem, Equatable { let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool + let sender: TimelineItemSender let content: TextRoomTimelineItemContent diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index d232b908c..c36b5375a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -22,6 +22,7 @@ struct VideoRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable { let timestamp: String let isOutgoing: Bool let isEditable: Bool + let isThreaded: Bool let sender: TimelineItemSender diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 4bc778201..1dd99f2db 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -80,23 +80,24 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { fatalError("Invalid message timeline item: \(eventItemProxy)") } + let isThreaded = messageTimelineItem.isThreaded() switch messageTimelineItem.msgtype() { case .text(content: let content): - return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .image(content: let content): - return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .video(let content): - return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .file(let content): - return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .notice(content: let content): - return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .emote(content: let content): - return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .audio(let content): - return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .location(let content): - return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded) case .none: return nil } @@ -182,11 +183,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildTextTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: TextMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { TextRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildTextTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -199,11 +202,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildImageTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: ImageMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { ImageRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildImageTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -216,11 +221,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildVideoTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: VideoMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { VideoRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildVideoTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -233,11 +240,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildAudioTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: AudioMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { AudioRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildAudioTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -250,11 +259,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: FileMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { FileRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildFileTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -267,11 +278,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildNoticeTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: NoticeMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { NoticeRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildNoticeTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -284,11 +297,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildEmoteTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: EmoteMessageContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { EmoteRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), @@ -301,11 +316,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildLocationTimelineItem(for eventItemProxy: EventTimelineItemProxy, _ messageTimelineItem: Message, _ messageContent: LocationContent, - _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool, + _ isThreaded: Bool) -> RoomTimelineItemProtocol { LocationRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, + isThreaded: isThreaded, sender: eventItemProxy.sender, content: buildLocationTimelineItemContent(messageContent), replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()), diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index 9086a9cc7..2de801f9d 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -257,6 +257,7 @@ class LoggingTests: XCTestCase { timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "sender"), content: .init(body: "TextString", formattedBody: AttributedString(textAttributedString))) let noticeAttributedString = "NoticeAttributed" @@ -264,6 +265,7 @@ class LoggingTests: XCTestCase { timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "sender"), content: .init(body: "NoticeString", formattedBody: AttributedString(noticeAttributedString))) let emoteAttributedString = "EmoteAttributed" @@ -271,24 +273,28 @@ class LoggingTests: XCTestCase { timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "sender"), content: .init(body: "EmoteString", formattedBody: AttributedString(emoteAttributedString))) let imageMessage = ImageRoomTimelineItem(id: .init(timelineID: "myimagemessage"), timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "sender"), content: .init(body: "ImageString", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"), thumbnailSource: nil)) let videoMessage = VideoRoomTimelineItem(id: .random, timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "sender"), content: .init(body: "VideoString", duration: 0, source: nil, thumbnailSource: nil)) let fileMessage = FileRoomTimelineItem(id: .random, timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: "sender"), content: .init(body: "FileString", source: nil, thumbnailSource: nil, contentType: nil)) diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 71d0636ca..53b5fcd7b 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -511,6 +511,7 @@ private extension TextRoomTimelineItem { timestamp: "10:47 am", isOutgoing: sender == "bob", isEditable: sender == "bob", + isThreaded: false, sender: .init(id: "@\(sender):server.com", displayName: sender), content: .init(body: text), properties: RoomTimelineItemProperties(reactions: reactions)) @@ -529,6 +530,7 @@ private extension TextRoomTimelineItem { timestamp: "", isOutgoing: false, isEditable: false, + isThreaded: false, sender: .init(id: ""), content: .init(body: "Hello, World!")) } diff --git a/UnitTests/Sources/TextBasedRoomTimelineTests.swift b/UnitTests/Sources/TextBasedRoomTimelineTests.swift index 03f9763f8..02c369ebc 100644 --- a/UnitTests/Sources/TextBasedRoomTimelineTests.swift +++ b/UnitTests/Sources/TextBasedRoomTimelineTests.swift @@ -20,24 +20,24 @@ import XCTest final class TextBasedRoomTimelineTests: XCTestCase { func testTextRoomTimelineItemWhitespaceEnd() { let timestamp = "Now" - let timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + let timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + 1) } func testTextRoomTimelineItemWhitespaceEndLonger() { let timestamp = "10:00 AM" - let timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + let timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + 1) } func testTextRoomTimelineItemWhitespaceEndPlain() { - let timelineItem = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + let timelineItem = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: true, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .plain), 0) } func testTextRoomTimelineItemWhitespaceEndWithEdit() { let timestamp = "Now" - var timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + var timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) timelineItem.properties.isEdited = true let editedCount = L10n.commonEditedSuffix.count XCTAssertEqual(timelineItem.additionalWhitespaces(timelineStyle: .bubbles), timestamp.count + editedCount + 2) @@ -45,7 +45,7 @@ final class TextBasedRoomTimelineTests: XCTestCase { func testTextRoomTimelineItemWhitespaceEndWithEditAndAlert() { let timestamp = "Now" - var timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) + var timelineItem = TextRoomTimelineItem(id: .random, timestamp: timestamp, isOutgoing: true, isEditable: true, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test")) timelineItem.properties.isEdited = true timelineItem.properties.deliveryStatus = .sendingFailed let editedCount = L10n.commonEditedSuffix.count diff --git a/changelog.d/1686.feature b/changelog.d/1686.feature new file mode 100644 index 000000000..0733e691d --- /dev/null +++ b/changelog.d/1686.feature @@ -0,0 +1 @@ +Messages that are part of a thread will be marked with a thread decorator. \ No newline at end of file