mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Reply rendering (#861)
* Cleanup how we use message timeline items in preparation for rich reply rendering * Expose reply details up to the UI level * Render replies in the timeline (textual for now). Remove now unused AttributedStringBuilderComponent isReply flag. * Move reply detail loading to when the item appears instead of when it's created. * Fix unit tests * Fetch event details also when the reply details are in the error state * Tweaks following code review * Localise emote bodies * Fix replies not loading sometimes, use the current event identifier instead of the one being replied to
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineReplyView: View {
|
||||
let timelineItemReplyDetails: TimelineItemReplyDetails?
|
||||
|
||||
var body: some View {
|
||||
if let timelineItemReplyDetails {
|
||||
switch timelineItemReplyDetails {
|
||||
case .loaded(let sender, let content):
|
||||
switch content {
|
||||
case .audio(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil))
|
||||
case .emote(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody))
|
||||
case .file(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil))
|
||||
case .image(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil))
|
||||
case .notice(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody))
|
||||
case .text(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: content.formattedBody))
|
||||
case .video(let content):
|
||||
TimelineTextReplyView(attributedText: attributedString(for: sender, body: content.body, formattedBody: nil))
|
||||
}
|
||||
default:
|
||||
Text("Missing in-reply-to details")
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func attributedString(for sender: TimelineItemSender, body: String, formattedBody: AttributedString?) -> AttributedString {
|
||||
var attributedHeading = AttributedString("\(sender.displayName ?? sender.id)\n")
|
||||
attributedHeading.font = .compound.bodyMD.bold()
|
||||
attributedHeading.foregroundColor = .element.primaryContent
|
||||
|
||||
var formattedBody = formattedBody ?? AttributedString(body)
|
||||
formattedBody.font = .compound.bodyMD
|
||||
formattedBody.foregroundColor = .element.secondaryContent
|
||||
|
||||
attributedHeading += formattedBody
|
||||
|
||||
return attributedHeading
|
||||
}
|
||||
|
||||
private struct TimelineTextReplyView: View {
|
||||
let attributedText: AttributedString
|
||||
|
||||
var body: some View {
|
||||
FormattedBodyText(attributedString: attributedText)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,13 +102,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
@ViewBuilder
|
||||
var styledContent: some View {
|
||||
if shouldAvoidBubbling {
|
||||
content()
|
||||
contentWithReply
|
||||
.bubbleStyle(inset: false,
|
||||
cornerRadius: cornerRadius,
|
||||
corners: roundedCorners)
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
content()
|
||||
contentWithReply
|
||||
|
||||
if timelineItem.properties.isEdited {
|
||||
Text(L10n.commonEditedSuffix)
|
||||
@@ -123,6 +123,20 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentWithReply: some View {
|
||||
VStack(alignment: .leading, spacing: 4.0) {
|
||||
if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol,
|
||||
let replyDetails = messageTimelineItem.replyDetails {
|
||||
TimelineReplyView(timelineItemReplyDetails: replyDetails)
|
||||
.background(Color.element.background)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private var messageBubbleTopPadding: CGFloat {
|
||||
guard timelineItem.isOutgoing else { return 0 }
|
||||
return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0
|
||||
|
||||
@@ -30,7 +30,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
content()
|
||||
contentWithReply
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -42,7 +42,24 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
var contentWithReply: some View {
|
||||
VStack(alignment: .leading) {
|
||||
if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol,
|
||||
let replyDetails = messageTimelineItem.replyDetails {
|
||||
HStack(spacing: 4.0) {
|
||||
Rectangle()
|
||||
.foregroundColor(.element.accent)
|
||||
.frame(width: 4.0)
|
||||
TimelineReplyView(timelineItemReplyDetails: replyDetails)
|
||||
}
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if shouldShowSenderDetails {
|
||||
|
||||
@@ -25,7 +25,7 @@ struct AudioRoomTimelineView: View {
|
||||
HStack {
|
||||
Image(systemName: "waveform")
|
||||
.foregroundColor(.element.primaryContent)
|
||||
FormattedBodyText(text: timelineItem.body)
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 6)
|
||||
@@ -35,20 +35,18 @@ struct AudioRoomTimelineView: View {
|
||||
|
||||
struct AudioRoomTimelineView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
|
||||
static var previews: some View {
|
||||
body.environmentObject(viewModel.context)
|
||||
body.timelineStyle(.plain).environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
|
||||
static var body: some View {
|
||||
AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "audio.ogg",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
duration: 300,
|
||||
source: nil))
|
||||
content: .init(body: "audio.ogg", duration: 300, source: nil, contentType: nil)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ struct EmoteRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedString = timelineItem.formattedBody {
|
||||
if let attributedString = timelineItem.content.formattedBody {
|
||||
FormattedBodyText(attributedString: attributedString)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.body)
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
|
||||
|
||||
private static func itemWith(text: String, timestamp: String, senderId: String) -> EmoteRoomTimelineItem {
|
||||
EmoteRoomTimelineItem(id: UUID().uuidString,
|
||||
body: text,
|
||||
timestamp: timestamp,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: senderId))
|
||||
sender: .init(id: senderId),
|
||||
content: .init(body: text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ struct FileRoomTimelineView: View {
|
||||
HStack {
|
||||
Image(systemName: "doc.text.fill")
|
||||
.foregroundColor(.element.primaryContent)
|
||||
FormattedBodyText(text: timelineItem.body)
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 6)
|
||||
@@ -44,31 +44,25 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
|
||||
static var body: some View {
|
||||
VStack(spacing: 20.0) {
|
||||
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "document.pdf",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
source: nil,
|
||||
thumbnailSource: nil))
|
||||
content: .init(body: "document.pdf", source: nil, thumbnailSource: nil, contentType: nil)))
|
||||
|
||||
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "document.docx",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
source: nil,
|
||||
thumbnailSource: nil))
|
||||
content: .init(body: "document.docx", source: nil, thumbnailSource: nil, contentType: nil)))
|
||||
|
||||
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "document.txt",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
source: nil,
|
||||
thumbnailSource: nil))
|
||||
content: .init(body: "document.txt", source: nil, thumbnailSource: nil, contentType: nil)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ struct FormattedBodyText: View {
|
||||
// The rendered blockquote with a greedy width. The custom layout prevents the
|
||||
// infinite width from increasing the overall width of the view.
|
||||
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||
.blockquoteFormatting(isReply: component.isReply)
|
||||
.blockquoteFormatting()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.element.background)
|
||||
.cornerRadius(8)
|
||||
@@ -119,7 +119,7 @@ struct FormattedBodyText: View {
|
||||
ForEach(attributedComponents, id: \.self) { component in
|
||||
if component.isBlockquote {
|
||||
Text(component.attributedString.mergingAttributes(blockquoteAttributes))
|
||||
.blockquoteFormatting(isReply: component.isReply)
|
||||
.blockquoteFormatting()
|
||||
.layoutPriority(LayoutPriority.hiddenBlockquote)
|
||||
.hidden()
|
||||
}
|
||||
@@ -164,9 +164,8 @@ extension FormattedBodyText {
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func blockquoteFormatting(isReply: Bool) -> some View {
|
||||
lineLimit(isReply ? 3 : nil)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
func blockquoteFormatting() -> some View {
|
||||
foregroundColor(.element.tertiaryContent)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
|
||||
}
|
||||
|
||||
@@ -23,16 +23,24 @@ struct ImageRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
LoadableImage(mediaSource: timelineItem.source,
|
||||
blurhash: timelineItem.blurhash,
|
||||
LoadableImage(mediaSource: source,
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
imageProvider: context.imageProvider) {
|
||||
placeholder
|
||||
}
|
||||
.frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity)))
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
.frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity)))
|
||||
.aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
|
||||
var source: MediaSourceProxy {
|
||||
guard timelineItem.content.contentType != .gif, let thumbnailSource = timelineItem.content.thumbnailSource else {
|
||||
return timelineItem.content.source
|
||||
}
|
||||
|
||||
return thumbnailSource
|
||||
}
|
||||
|
||||
var placeholder: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
@@ -56,31 +64,30 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||
static var body: some View {
|
||||
VStack(spacing: 20.0) {
|
||||
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Some image",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png")))
|
||||
|
||||
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Some other image",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png")))
|
||||
content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil)))
|
||||
|
||||
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Blurhashed image",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"),
|
||||
aspectRatio: 0.7,
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
|
||||
contentType: .gif))
|
||||
content: .init(body: "Some other image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil)))
|
||||
|
||||
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Blurhashed image",
|
||||
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"),
|
||||
thumbnailSource: nil,
|
||||
aspectRatio: 0.7,
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
|
||||
contentType: .gif)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ struct NoticeRoomTimelineView: View {
|
||||
Image(systemName: "info.bubble").padding(.top, 2.0)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
|
||||
if let attributedString = timelineItem.formattedBody {
|
||||
if let attributedString = timelineItem.content.formattedBody {
|
||||
FormattedBodyText(attributedString: attributedString)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.body)
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText
|
||||
@@ -64,10 +64,10 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
|
||||
|
||||
private static func itemWith(text: String, timestamp: String, senderId: String) -> NoticeRoomTimelineItem {
|
||||
NoticeRoomTimelineItem(id: UUID().uuidString,
|
||||
body: text,
|
||||
timestamp: timestamp,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: senderId))
|
||||
sender: .init(id: senderId),
|
||||
content: .init(body: text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,21 +43,21 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
RoomTimelineViewProvider.separator(.init(text: "Today"), .single)
|
||||
RoomTimelineViewProvider.text(.init(id: "",
|
||||
body: "This is another message",
|
||||
timestamp: "",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "1", displayName: "Bob")), .single)
|
||||
sender: .init(id: "1", displayName: "Bob"),
|
||||
content: .init(body: "This is another message")), .single)
|
||||
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
|
||||
RoomTimelineViewProvider.separator(.init(text: "Today"), .single)
|
||||
RoomTimelineViewProvider.text(.init(id: "",
|
||||
body: "This is a message",
|
||||
timestamp: "",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Alice")), .single)
|
||||
sender: .init(id: "", displayName: "Alice"),
|
||||
content: .init(body: "This is a message")), .single)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.environmentObject(viewModel.context)
|
||||
|
||||
@@ -22,10 +22,10 @@ struct TextRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedString = timelineItem.formattedBody {
|
||||
if let attributedString = timelineItem.content.formattedBody {
|
||||
FormattedBodyText(attributedString: attributedString)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.body)
|
||||
FormattedBodyText(text: timelineItem.content.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,10 +65,10 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
|
||||
|
||||
private static func itemWith(text: String, timestamp: String, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem {
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: text,
|
||||
timestamp: timestamp,
|
||||
isOutgoing: isOutgoing,
|
||||
isEditable: isOutgoing,
|
||||
sender: .init(id: senderId))
|
||||
sender: .init(id: senderId),
|
||||
content: .init(body: text))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,16 @@ struct VideoRoomTimelineView: View {
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
thumbnail
|
||||
.frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity)))
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
.frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity)))
|
||||
.aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var thumbnail: some View {
|
||||
if let thumbnailSource = timelineItem.thumbnailSource {
|
||||
if let thumbnailSource = timelineItem.content.thumbnailSource {
|
||||
LoadableImage(mediaSource: thumbnailSource,
|
||||
blurhash: timelineItem.blurhash,
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
imageProvider: context.imageProvider) { imageView in
|
||||
imageView
|
||||
.overlay { playIcon }
|
||||
@@ -76,36 +76,25 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
|
||||
static var body: some View {
|
||||
VStack(spacing: 20.0) {
|
||||
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Some video",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
duration: 21,
|
||||
source: nil,
|
||||
thumbnailSource: nil))
|
||||
content: .init(body: "Some video", duration: 21, source: nil, thumbnailSource: nil)))
|
||||
|
||||
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Some other video",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
duration: 22,
|
||||
source: nil,
|
||||
thumbnailSource: nil))
|
||||
content: .init(body: "Some other video", duration: 22, source: nil, thumbnailSource: nil)))
|
||||
|
||||
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Blurhashed video",
|
||||
timestamp: "Now",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "Bob"),
|
||||
duration: 23,
|
||||
source: nil,
|
||||
thumbnailSource: nil,
|
||||
aspectRatio: 0.7,
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
|
||||
content: .init(body: "Blurhashed video", duration: 23, source: nil, thumbnailSource: nil, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user