element-hq/element-x-ios/issues/2636 - Expose paths for focusing replied-to timeline items by tapping on an in-reply-to message bubble

This commit is contained in:
Stefan Ceriu
2024-04-16 09:16:06 +03:00
committed by Stefan Ceriu
parent 43c132720d
commit 83c620d685
10 changed files with 252 additions and 147 deletions
@@ -199,18 +199,32 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock static let viewModel = RoomScreenViewModel.mock
static let replyTypes: [TimelineItemReplyDetails] = [ 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: "Dave"),
.loaded(sender: .init(id: "James"), eventContent: .message(.emote(.init(body: "Emote: James thinks he's the phantom lord")))), eventID: "123",
.loaded(sender: .init(id: "Robert"), eventContent: .message(.file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil)))), eventContent: .message(.audio(.init(body: "Audio: Ride the lightning", duration: 100, waveform: nil, source: nil, contentType: nil)))),
.loaded(sender: .init(id: "Cliff"), eventContent: .message(.image(.init(body: "Image: Pushead", .loaded(sender: .init(id: "James"),
source: .init(url: .picturesDirectory, mimeType: nil), eventID: "123",
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))), eventContent: .message(.emote(.init(body: "Emote: James thinks he's the phantom lord")))),
.loaded(sender: .init(id: "Jason"), eventContent: .message(.notice(.init(body: "Notice: Too far gone?")))), .loaded(sender: .init(id: "Robert"),
.loaded(sender: .init(id: "Kirk"), eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), eventID: "123",
.loaded(sender: .init(id: "Lars"), eventContent: .message(.video(.init(body: "Video: Through the never", eventContent: .message(.file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil)))),
duration: 100, .loaded(sender: .init(id: "Cliff"),
source: nil, eventID: "123",
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))), 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: "") .loading(eventID: "")
] ]
@@ -247,6 +261,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
messageComposer(mode: .reply(itemID: .random, messageComposer(mode: .reply(itemID: .random,
replyDetails: .loaded(sender: .init(id: "Kirk"), replyDetails: .loaded(sender: .init(id: "Kirk"),
eventID: "123",
eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), eventContent: .message(.text(.init(body: "Text: Where the wild things are")))),
isThread: false)) isThread: false))
} }
@@ -230,8 +230,12 @@ class RoomScreenInteractionHandler {
} }
} }
case .reply: case .reply:
guard let eventID = eventTimelineItem.id.eventID else {
return
}
let replyInfo = buildReplyInfo(for: eventTimelineItem) 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)))) actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails, isThread: replyInfo.isThread))))
case .forward(let itemID): case .forward(let itemID):
@@ -76,8 +76,10 @@ enum RoomScreenViewAudioAction {
enum RoomScreenViewAction { enum RoomScreenViewAction {
case displayRoomDetails case displayRoomDetails
case itemAppeared(itemID: TimelineItemIdentifier) case itemAppeared(itemID: TimelineItemIdentifier)
case itemDisappeared(itemID: TimelineItemIdentifier) case itemDisappeared(itemID: TimelineItemIdentifier)
case itemTapped(itemID: TimelineItemIdentifier) case itemTapped(itemID: TimelineItemIdentifier)
case toggleReaction(key: String, itemID: TimelineItemIdentifier) case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier) case sendReadReceiptIfNeeded(TimelineItemIdentifier)
@@ -104,6 +106,8 @@ enum RoomScreenViewAction {
case audio(RoomScreenViewAudioAction) case audio(RoomScreenViewAudioAction)
case presentCall case presentCall
case focusOnEventID(String)
} }
enum RoomScreenComposerAction { enum RoomScreenComposerAction {
@@ -172,6 +172,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actionsSubject.send(.displayCallScreen) actionsSubject.send(.displayCallScreen)
case .showReadReceipts(itemID: let itemID): case .showReadReceipts(itemID: let itemID):
showReadReceipts(for: itemID) showReadReceipts(for: itemID)
case .focusOnEventID(let eventID):
// TODO: .. something
break
} }
} }
@@ -29,7 +29,7 @@ struct TimelineReplyView: View {
var body: some View { var body: some View {
if let timelineItemReplyDetails { if let timelineItemReplyDetails {
switch timelineItemReplyDetails { switch timelineItemReplyDetails {
case .loaded(let sender, let content): case .loaded(let sender, _, let content):
switch content { switch content {
case .message(let content): case .message(let content):
switch content { switch content {
@@ -131,38 +131,6 @@ struct TimelineReplyView: View {
var icon: Icon? 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 { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
iconView iconView
@@ -183,7 +151,7 @@ struct TimelineReplyView: View {
.tint(.compound.textLinkExternal) .tint(.compound.textLinkExternal)
.lineLimit(2) .lineLimit(2)
} }
.padding(.leading, isTextOnly ? 8 : 0) .padding(.leading, icon == nil ? 8 : 0)
.padding(.trailing, 8) .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, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "This is a reply"))))), eventContent: .message(.text(.init(body: "This is a reply"))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.emote(.init(body: "says hello"))))), eventContent: .message(.emote(.init(body: "says hello"))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
eventID: "123",
eventContent: .message(.notice(.init(body: "Hello world"))))), eventContent: .message(.notice(.init(body: "Hello world"))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.audio(.init(body: "Some audio", eventContent: .message(.audio(.init(body: "Some audio",
duration: 0, duration: 0,
waveform: nil, waveform: nil,
@@ -264,6 +264,7 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.file(.init(body: "Some file", eventContent: .message(.file(.init(body: "Some file",
source: nil, source: nil,
thumbnailSource: nil, thumbnailSource: nil,
@@ -271,22 +272,26 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.image(.init(body: "Some image", eventContent: .message(.image(.init(body: "Some image",
source: imageSource, source: imageSource,
thumbnailSource: imageSource))))), thumbnailSource: imageSource))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.video(.init(body: "Some video", eventContent: .message(.video(.init(body: "Some video",
duration: 0, duration: 0,
source: nil, source: nil,
thumbnailSource: imageSource))))), thumbnailSource: imageSource))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.location(.init(body: ""))))), eventContent: .message(.location(.init(body: ""))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.voice(.init(body: "Some voice message", eventContent: .message(.voice(.init(body: "Some voice message",
duration: 0, duration: 0,
waveform: nil, waveform: nil,
@@ -294,16 +299,20 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
contentType: nil))))), contentType: nil))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
eventID: "123",
eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))))), eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
eventID: "123",
eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))))), eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
eventID: "123",
eventContent: .poll(question: "Do you like polls?"))), eventContent: .poll(question: "Do you like polls?"))),
TimelineReplyView(placement: .timeline, TimelineReplyView(placement: .timeline,
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"), timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
eventID: "123",
eventContent: .redacted)) eventContent: .redacted))
] ]
} }
@@ -220,9 +220,11 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.padding(.leading, 4) .padding(.leading, 4)
.layoutPriority(TimelineBubbleLayout.Priority.regularText) .layoutPriority(TimelineBubbleLayout.Priority.regularText)
} }
if let replyDetails = messageTimelineItem.replyDetails { if let replyDetails = messageTimelineItem.replyDetails {
// The rendered reply bubble with a greedy width. The custom layout prevents // The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view. // the infinite width from increasing the overall width of the view.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(4.0) .padding(4.0)
@@ -230,6 +232,9 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.background(Color.compound.bgCanvasDefault) .background(Color.compound.bgCanvasDefault)
.cornerRadius(8) .cornerRadius(8)
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote) .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. // Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
@@ -418,6 +423,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
sender: .init(id: "whoever"), sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."), content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short"))))), eventContent: .message(.text(.init(body: "Short"))))),
groupStyle: .single)) groupStyle: .single))
@@ -434,6 +440,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
source: nil, source: nil,
contentType: nil), contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))) eventContent: .message(.text(.init(body: "Short"))))))
FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
@@ -448,6 +455,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
thumbnailSource: nil, thumbnailSource: nil,
contentType: nil), contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))) eventContent: .message(.text(.init(body: "Short"))))))
ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42", timestamp: "10:42",
@@ -458,6 +466,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
sender: .init(id: ""), sender: .init(id: ""),
content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil), content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))) eventContent: .message(.text(.init(body: "Short"))))))
LocationRoomTimelineView(timelineItem: .init(id: .random, LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now", timestamp: "Now",
@@ -471,6 +480,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
longitude: 12.496366), longitude: 12.496366),
description: "Location description description description description description description description description"), description: "Location description description description description description description description description"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))) eventContent: .message(.text(.init(body: "Short"))))))
LocationRoomTimelineView(timelineItem: .init(id: .random, LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now", timestamp: "Now",
@@ -482,6 +492,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
content: .init(body: "Fallback geo uri description", content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil), geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))) eventContent: .message(.text(.init(body: "Short"))))))
VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""), VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
@@ -497,6 +508,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
source: nil, source: nil,
contentType: nil), contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short"))))), eventContent: .message(.text(.init(body: "Short"))))),
playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform)) playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform))
} }
@@ -528,6 +540,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
sender: .init(id: "whoever"), sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."), content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short"))))), eventContent: .message(.text(.init(body: "Short"))))),
groupStyle: .single)) groupStyle: .single))
@@ -540,6 +553,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
sender: .init(id: "whoever"), sender: .init(id: "whoever"),
content: .init(body: "Short message"), content: .init(body: "Short message"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), 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."))))), 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)) groupStyle: .single))
} }
@@ -52,7 +52,11 @@ struct TimelineItemPlainStylerView<Content: View>: View {
Rectangle() Rectangle()
.foregroundColor(.compound.iconTertiary) .foregroundColor(.compound.iconTertiary)
.frame(width: 4.0) .frame(width: 4.0)
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails) TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.onTapGesture {
context.send(viewAction: .focusOnEventID(replyDetails.eventID))
}
} }
} }
} }
@@ -142,101 +146,6 @@ struct TimelineItemPlainStylerView<Content: View>: View {
struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview { struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock 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 { static var previews: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
@@ -253,5 +162,142 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview {
.padding() .padding()
.environment(\.timelineStyle, .plain) .environment(\.timelineStyle, .plain)
.previewDisplayName("Threads") .previewDisplayName("Threads")
replies
.environment(\.timelineStyle, .plain)
.previewDisplayName("Replies")
}
// 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"),
eventID: "123",
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"),
eventID: "123",
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"),
eventID: "123",
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"),
eventID: "123",
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"),
eventID: "123",
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"),
eventID: "123",
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"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short"))))),
playerState: AudioPlayerState(id: .timelineItemIdentifier(.init(timelineID: "")), duration: 10, waveform: EstimatedWaveform.mockWaveform))
}
.environmentObject(viewModel.context)
}
static var replies: some View {
ScrollView {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
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"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short"))))),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
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))
}
.environmentObject(viewModel.context)
} }
} }
@@ -136,6 +136,7 @@ struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
content: .init(body: "Fallback geo uri description", 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"), geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"),
replyDetails: .loaded(sender: .init(id: "Someone"), replyDetails: .loaded(sender: .init(id: "Someone"),
eventID: "123",
eventContent: .message(.text(.init(body: "The thread content goes 'ere.")))))) eventContent: .message(.text(.init(body: "The thread content goes 'ere."))))))
} }
} }
@@ -19,8 +19,15 @@ import Foundation
enum TimelineItemReplyDetails: Hashable { enum TimelineItemReplyDetails: Hashable {
case notLoaded(eventID: String) case notLoaded(eventID: String)
case loading(eventID: String) case loading(eventID: String)
case loaded(sender: TimelineItemSender, eventContent: TimelineEventContent) case loaded(sender: TimelineItemSender, eventID: String, eventContent: TimelineEventContent)
case error(eventID: String, message: String) case error(eventID: String, message: String)
var eventID: String {
switch self {
case .notLoaded(let eventID), .loading(let eventID), .loaded(_, let eventID, _), .error(let eventID, _):
return eventID
}
}
} }
enum TimelineEventContent: Hashable { enum TimelineEventContent: Hashable {
@@ -640,7 +640,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
switch timelineItem.kind() { switch timelineItem.kind() {
case .message: case .message:
return timelineItemReplyDetails(for: timelineItem.asMessage()?.msgtype(), sender: sender) return timelineItemReplyDetails(sender: sender, eventID: details.eventId, messageType: timelineItem.asMessage()?.msgtype())
case .poll(let question, _, _, _, _, _, _): case .poll(let question, _, _, _, _, _, _):
replyContent = .poll(question: question) replyContent = .poll(question: question)
case .sticker(let body, _, _): case .sticker(let body, _, _):
@@ -651,13 +651,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent))) replyContent = .message(.text(.init(body: L10n.commonUnsupportedEvent)))
} }
return .loaded(sender: sender, eventContent: replyContent) return .loaded(sender: sender, eventID: details.eventId, eventContent: replyContent)
case let .error(message): case let .error(message):
return .error(eventID: details.eventId, message: message) return .error(eventID: details.eventId, message: message)
} }
} }
private func timelineItemReplyDetails(for messageType: MessageType?, sender: TimelineItemSender) -> TimelineItemReplyDetails { private func timelineItemReplyDetails(sender: TimelineItemSender, eventID: String, messageType: MessageType?) -> TimelineItemReplyDetails {
let replyContent: EventBasedMessageTimelineItemContentType let replyContent: EventBasedMessageTimelineItemContentType
switch messageType { switch messageType {
@@ -685,7 +685,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
replyContent = .text(.init(body: L10n.commonUnsupportedEvent)) replyContent = .text(.init(body: L10n.commonUnsupportedEvent))
} }
return .loaded(sender: sender, eventContent: .message(replyContent)) return .loaded(sender: sender,
eventID: eventID,
eventContent: .message(replyContent))
} }
} }