diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift index ddaf6c7bc..652773e84 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/MessageComposer.swift @@ -199,18 +199,32 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel.mock static let replyTypes: [TimelineItemReplyDetails] = [ - .loaded(sender: .init(id: "Dave"), eventContent: .message(.audio(.init(body: "Audio: Ride the lightning", duration: 100, waveform: nil, source: nil, contentType: nil)))), - .loaded(sender: .init(id: "James"), eventContent: .message(.emote(.init(body: "Emote: James thinks he's the phantom lord")))), - .loaded(sender: .init(id: "Robert"), eventContent: .message(.file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil)))), - .loaded(sender: .init(id: "Cliff"), eventContent: .message(.image(.init(body: "Image: Pushead", - source: .init(url: .picturesDirectory, mimeType: nil), - thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))), - .loaded(sender: .init(id: "Jason"), eventContent: .message(.notice(.init(body: "Notice: Too far gone?")))), - .loaded(sender: .init(id: "Kirk"), eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), - .loaded(sender: .init(id: "Lars"), eventContent: .message(.video(.init(body: "Video: Through the never", - duration: 100, - source: nil, - thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))), + .loaded(sender: .init(id: "Dave"), + eventID: "123", + eventContent: .message(.audio(.init(body: "Audio: Ride the lightning", duration: 100, waveform: nil, source: nil, contentType: nil)))), + .loaded(sender: .init(id: "James"), + eventID: "123", + eventContent: .message(.emote(.init(body: "Emote: James thinks he's the phantom lord")))), + .loaded(sender: .init(id: "Robert"), + eventID: "123", + eventContent: .message(.file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil)))), + .loaded(sender: .init(id: "Cliff"), + eventID: "123", + eventContent: .message(.image(.init(body: "Image: Pushead", + source: .init(url: .picturesDirectory, mimeType: nil), + thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))), + .loaded(sender: .init(id: "Jason"), + eventID: "123", + eventContent: .message(.notice(.init(body: "Notice: Too far gone?")))), + .loaded(sender: .init(id: "Kirk"), + eventID: "123", + eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), + .loaded(sender: .init(id: "Lars"), + eventID: "123", + eventContent: .message(.video(.init(body: "Video: Through the never", + duration: 100, + source: nil, + thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))), .loading(eventID: "") ] @@ -247,6 +261,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview { messageComposer(mode: .reply(itemID: .random, replyDetails: .loaded(sender: .init(id: "Kirk"), + eventID: "123", eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), isThread: false)) } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift index 20d2136cc..a991975a6 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift @@ -230,8 +230,12 @@ class RoomScreenInteractionHandler { } } case .reply: + guard let eventID = eventTimelineItem.id.eventID else { + return + } + let replyInfo = buildReplyInfo(for: eventTimelineItem) - let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, eventContent: replyInfo.type) + let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, eventID: eventID, eventContent: replyInfo.type) actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails, isThread: replyInfo.isThread)))) case .forward(let itemID): diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 6d489f50e..a066f5481 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -76,8 +76,10 @@ enum RoomScreenViewAudioAction { enum RoomScreenViewAction { case displayRoomDetails + case itemAppeared(itemID: TimelineItemIdentifier) case itemDisappeared(itemID: TimelineItemIdentifier) + case itemTapped(itemID: TimelineItemIdentifier) case toggleReaction(key: String, itemID: TimelineItemIdentifier) case sendReadReceiptIfNeeded(TimelineItemIdentifier) @@ -104,6 +106,8 @@ enum RoomScreenViewAction { case audio(RoomScreenViewAudioAction) case presentCall + + case focusOnEventID(String) } enum RoomScreenComposerAction { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 82466e2d4..a58231762 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -172,6 +172,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol actionsSubject.send(.displayCallScreen) case .showReadReceipts(itemID: let itemID): showReadReceipts(for: itemID) + case .focusOnEventID(let eventID): + // TODO: .. something + break } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift index 44c66515f..7b670b280 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Replies/TimelineReplyView.swift @@ -29,7 +29,7 @@ struct TimelineReplyView: View { var body: some View { if let timelineItemReplyDetails { switch timelineItemReplyDetails { - case .loaded(let sender, let content): + case .loaded(let sender, _, let content): switch content { case .message(let content): switch content { @@ -131,38 +131,6 @@ struct TimelineReplyView: View { var icon: Icon? - var isTextOnly: Bool { - icon == nil - } - - /// The string shown as the message preview. - /// - /// This converts the formatted body to a plain string to remove formatting - /// and render with a consistent font size. This conversion is done to avoid - /// showing markdown characters in the preview for messages with formatting. - var messagePreview: String { - guard let formattedBody, - let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else { - return plainBody - } - - let range = NSRange(location: 0, length: attributedString.length) - attributedString.enumerateAttributes(in: range) { attributes, range, _ in - if let userID = attributes[.MatrixUserID] as? String { - if let displayName = context.viewState.members[userID]?.displayName { - attributedString.replaceCharacters(in: range, with: "@\(displayName)") - } else { - attributedString.replaceCharacters(in: range, with: userID) - } - } - - if attributes[.MatrixAllUsersMention] as? Bool == true { - attributedString.replaceCharacters(in: range, with: PillConstants.atRoom) - } - } - return attributedString.string - } - var body: some View { HStack(spacing: 8) { iconView @@ -183,7 +151,7 @@ struct TimelineReplyView: View { .tint(.compound.textLinkExternal) .lineLimit(2) } - .padding(.leading, isTextOnly ? 8 : 0) + .padding(.leading, icon == nil ? 8 : 0) .padding(.trailing, 8) } } @@ -216,6 +184,34 @@ struct TimelineReplyView: View { } } } + + /// The string shown as the message preview. + /// + /// This converts the formatted body to a plain string to remove formatting + /// and render with a consistent font size. This conversion is done to avoid + /// showing markdown characters in the preview for messages with formatting. + private var messagePreview: String { + guard let formattedBody, + let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else { + return plainBody + } + + let range = NSRange(location: 0, length: attributedString.length) + attributedString.enumerateAttributes(in: range) { attributes, range, _ in + if let userID = attributes[.MatrixUserID] as? String { + if let displayName = context.viewState.members[userID]?.displayName { + attributedString.replaceCharacters(in: range, with: "@\(displayName)") + } else { + attributedString.replaceCharacters(in: range, with: userID) + } + } + + if attributes[.MatrixAllUsersMention] as? Bool == true { + attributedString.replaceCharacters(in: range, with: PillConstants.atRoom) + } + } + return attributedString.string + } } } @@ -244,18 +240,22 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview { TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "This is a reply"))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.emote(.init(body: "says hello"))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), + eventID: "123", eventContent: .message(.notice(.init(body: "Hello world"))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.audio(.init(body: "Some audio", duration: 0, waveform: nil, @@ -264,6 +264,7 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview { TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.file(.init(body: "Some file", source: nil, thumbnailSource: nil, @@ -271,22 +272,26 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview { TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.image(.init(body: "Some image", source: imageSource, thumbnailSource: imageSource))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.video(.init(body: "Some video", duration: 0, source: nil, thumbnailSource: imageSource))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.location(.init(body: ""))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.voice(.init(body: "Some voice message", duration: 0, waveform: nil, @@ -294,16 +299,20 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview { contentType: nil))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), + eventID: "123", eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), + eventID: "123", eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), + eventID: "123", eventContent: .poll(question: "Do you like polls?"))), TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), + eventID: "123", eventContent: .redacted)) ] } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 7c6ae76f4..b1845348d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -220,9 +220,11 @@ struct TimelineItemBubbledStylerView: View { .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) @@ -230,6 +232,9 @@ struct TimelineItemBubbledStylerView: View { .background(Color.compound.bgCanvasDefault) .cornerRadius(8) .layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) + .onTapGesture { + context.send(viewAction: .focusOnEventID(replyDetails.eventID)) + } // Add a fixed width reply bubble that is used for layout calculations but won't be rendered. TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) @@ -418,6 +423,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview sender: .init(id: "whoever"), content: .init(body: "A long message that should be on multiple lines."), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short"))))), groupStyle: .single)) @@ -434,6 +440,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview source: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short")))))) FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), @@ -448,6 +455,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview thumbnailSource: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short")))))) ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), timestamp: "10:42", @@ -458,6 +466,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview sender: .init(id: ""), content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short")))))) LocationRoomTimelineView(timelineItem: .init(id: .random, timestamp: "Now", @@ -471,6 +480,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview longitude: 12.496366), description: "Location description description description description description description description description"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short")))))) LocationRoomTimelineView(timelineItem: .init(id: .random, timestamp: "Now", @@ -482,6 +492,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview content: .init(body: "Fallback geo uri description", geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short")))))) VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), @@ -497,6 +508,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview source: nil, contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short"))))), playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform)) } @@ -528,6 +540,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview sender: .init(id: "whoever"), content: .init(body: "A long message that should be on multiple lines."), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "Short"))))), groupStyle: .single)) @@ -540,6 +553,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview sender: .init(id: "whoever"), content: .init(body: "Short message"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), + eventID: "123", eventContent: .message(.text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout."))))), groupStyle: .single)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index b37f8bcaf..fdd427144 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -52,7 +52,11 @@ struct TimelineItemPlainStylerView: View { Rectangle() .foregroundColor(.compound.iconTertiary) .frame(width: 4.0) + TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) + .onTapGesture { + context.send(viewAction: .focusOnEventID(replyDetails.eventID)) + } } } } @@ -142,101 +146,6 @@ struct TimelineItemPlainStylerView: View { struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview { 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, - canBeRepliedTo: true, - 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"), - eventContent: .message(.text(.init(body: "Short"))))), - groupStyle: .single)) - - AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), - timestamp: "10:42", - isOutgoing: true, - isEditable: false, - canBeRepliedTo: true, - isThreaded: true, - sender: .init(id: ""), - content: .init(body: "audio.ogg", - duration: 100, - waveform: nil, - source: nil, - contentType: nil), - replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), - eventContent: .message(.text(.init(body: "Short")))))) - FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), - timestamp: "10:42", - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - isThreaded: true, - sender: .init(id: ""), - content: .init(body: "File", - source: nil, - thumbnailSource: nil, - contentType: nil), - replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), - eventContent: .message(.text(.init(body: "Short")))))) - ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), - timestamp: "10:42", - isOutgoing: true, - isEditable: true, - canBeRepliedTo: 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"), - eventContent: .message(.text(.init(body: "Short")))))) - LocationRoomTimelineView(timelineItem: .init(id: .random, - timestamp: "Now", - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - 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"), - eventContent: .message(.text(.init(body: "Short")))))) - LocationRoomTimelineView(timelineItem: .init(id: .random, - timestamp: "Now", - isOutgoing: false, - isEditable: false, - canBeRepliedTo: true, - 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"), - eventContent: .message(.text(.init(body: "Short")))))) - VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), - timestamp: "10:42", - isOutgoing: true, - isEditable: false, - canBeRepliedTo: true, - isThreaded: true, - sender: .init(id: ""), - content: .init(body: "audio.ogg", - duration: 100, - waveform: EstimatedWaveform.mockWaveform, - source: nil, - contentType: nil), - replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), - eventContent: .message(.text(.init(body: "Short"))))), - playerState: AudioPlayerState(id: .timelineItemIdentifier(.init(timelineID: "")), duration: 10, waveform: EstimatedWaveform.mockWaveform)) - } - .environmentObject(viewModel.context) - } - static var previews: some View { VStack(alignment: .leading, spacing: 0) { ForEach(1.. TimelineItemReplyDetails { + private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails { let replyContent: EventBasedMessageTimelineItemContentType switch messageType { @@ -685,7 +685,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { replyContent = .text(.init(body: L10n.commonUnsupportedEvent)) } - return .loaded(sender: sender, eventContent: .message(replyContent)) + return .loaded(sender: sender, + eventID: eventID, + eventContent: .message(replyContent)) } }