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:
Stefan Ceriu
2023-05-09 16:09:38 +03:00
committed by GitHub
parent 483d261504
commit 1bd6e461ed
48 changed files with 889 additions and 733 deletions
@@ -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")))
}
}
}