Thread Decorator (#1686)

* just added the isThreaded property to message based events

* done but I should add a bunch of tests for images and videos and locations

* handling for image and videos, but locations might need to be double checked

* code improvement

* better tests and removed code that is not used, we need to fix first the appearence of replies

* bubble style is solved, we now need plain style

* also added plain style

* better handling for bubble style

* rounded corner only for bubbles in location

* fix for wrong aspect ratio in replies

* all changes done

* better testing

* swift format

* changelog

* PR comments and rebase.

* Simplify corner radius on location timeline view.

---------

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Mauro
2023-09-15 12:58:30 +02:00
committed by GitHub
parent 64d5ba7a69
commit 7642508135
42 changed files with 618 additions and 116 deletions
+4
View File
@@ -110,6 +110,8 @@ public enum L10n {
public static var actionRemove: String { return L10n.tr("Localizable", "action_remove") }
/// Reply
public static var actionReply: String { return L10n.tr("Localizable", "action_reply") }
/// Reply in thread
public static var actionReplyInThread: String { return L10n.tr("Localizable", "action_reply_in_thread") }
/// Report bug
public static var actionReportBug: String { return L10n.tr("Localizable", "action_report_bug") }
/// Report Content
@@ -288,6 +290,8 @@ public enum L10n {
public static var commonText: String { return L10n.tr("Localizable", "common_text") }
/// Third-party notices
public static var commonThirdPartyNotices: String { return L10n.tr("Localizable", "common_third_party_notices") }
/// Thread
public static var commonThread: String { return L10n.tr("Localizable", "common_thread") }
/// Topic
public static var commonTopic: String { return L10n.tr("Localizable", "common_topic") }
/// What is this room about?
@@ -0,0 +1,50 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
extension LabelStyle where Self == CustomLayoutLabelStyle {
/// A label style that uses an `HStack` with parameters to customise the label's layout.
static func custom(spacing: CGFloat, alignment: VerticalAlignment = .center, iconLayout: Self.IconLayout = .leading) -> Self {
CustomLayoutLabelStyle(spacing: spacing, alignment: alignment, iconLayout: iconLayout)
}
}
struct CustomLayoutLabelStyle: LabelStyle {
let spacing: CGFloat
var alignment: VerticalAlignment
enum IconLayout { case leading, trailing }
var iconLayout: IconLayout
fileprivate init(spacing: CGFloat, alignment: VerticalAlignment, iconLayout: IconLayout) {
self.spacing = spacing
self.alignment = alignment
self.iconLayout = iconLayout
}
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: alignment, spacing: spacing) {
if iconLayout == .leading {
configuration.icon
configuration.title
} else {
configuration.title
configuration.icon
}
}
}
}
@@ -0,0 +1,25 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
extension View {
/// Constrains the max height of a media item in the timeline, whilst preserving its aspect ratio.
func timelineMediaFrame(height contentHeight: CGFloat?, aspectRatio contentAspectRatio: CGFloat?) -> some View {
aspectRatio(contentAspectRatio, contentMode: .fit)
.frame(maxHeight: min(300, max(100, contentHeight ?? .infinity)))
}
}
@@ -122,8 +122,17 @@ struct ComposerToolbar: View {
}
}
private var placeholder: String {
switch context.viewState.composerMode {
case .reply(_, _, let isThread):
return isThread ? L10n.actionReplyInThread : L10n.richTextEditorComposerPlaceholder
default:
return L10n.richTextEditorComposerPlaceholder
}
}
private var composerView: WysiwygComposerView {
WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder,
WysiwygComposerView(placeholder: placeholder,
viewModel: wysiwygViewModel,
itemProviderHelper: ItemProviderHelper(),
keyCommandHandler: keyCommandHandler) { provider in
@@ -86,7 +86,7 @@ struct MessageComposer: View {
@ViewBuilder
private var header: some View {
switch mode {
case .reply(_, let replyDetails):
case .reply(_, let replyDetails, _):
MessageComposerReplyHeader(replyDetails: replyDetails, action: replyCancellationAction)
case .edit:
MessageComposerEditHeader(action: editCancellationAction)
@@ -176,6 +176,22 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
struct MessageComposer_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static let replyTypes: [TimelineItemReplyDetails] = [
.loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, source: nil, contentType: nil))),
.loaded(sender: .init(id: "James"), contentType: .emote(.init(body: "Emote: James thinks he's the phantom lord"))),
.loaded(sender: .init(id: "Robert"), contentType: .file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil))),
.loaded(sender: .init(id: "Cliff"), contentType: .image(.init(body: "Image: Pushead",
source: .init(url: .picturesDirectory, mimeType: nil),
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))),
.loaded(sender: .init(id: "Jason"), contentType: .notice(.init(body: "Notice: Too far gone?"))),
.loaded(sender: .init(id: "Kirk"), contentType: .text(.init(body: "Text: Where the wild things are"))),
.loaded(sender: .init(id: "Lars"), contentType: .video(.init(body: "Video: Through the never",
duration: 100,
source: nil,
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))),
.loading(eventID: "")
]
static func messageComposer(_ content: String = "",
sendingDisabled: Bool = false,
@@ -210,36 +226,32 @@ struct MessageComposer_Previews: PreviewProvider {
messageComposer(mode: .reply(itemID: .random,
replyDetails: .loaded(sender: .init(id: "Kirk"),
contentType: .text(.init(body: "Text: Where the wild things are")))))
contentType: .text(.init(body: "Text: Where the wild things are"))), isThread: false))
}
.padding(.horizontal)
ScrollView {
VStack {
let replyTypes: [TimelineItemReplyDetails] = [
.loaded(sender: .init(id: "Dave"), contentType: .audio(.init(body: "Audio: Ride the lightning", duration: 100, source: nil, contentType: nil))),
.loaded(sender: .init(id: "James"), contentType: .emote(.init(body: "Emote: James thinks he's the phantom lord"))),
.loaded(sender: .init(id: "Robert"), contentType: .file(.init(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil))),
.loaded(sender: .init(id: "Cliff"), contentType: .image(.init(body: "Image: Pushead",
source: .init(url: .picturesDirectory, mimeType: nil),
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))),
.loaded(sender: .init(id: "Jason"), contentType: .notice(.init(body: "Notice: Too far gone?"))),
.loaded(sender: .init(id: "Kirk"), contentType: .text(.init(body: "Text: Where the wild things are"))),
.loaded(sender: .init(id: "Lars"), contentType: .video(.init(body: "Video: Through the never",
duration: 100,
source: nil,
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil)))),
.loading(eventID: "")
]
ForEach(replyTypes, id: \.self) { replyDetails in
messageComposer(mode: .reply(itemID: .random,
replyDetails: replyDetails))
replyDetails: replyDetails, isThread: false))
}
}
}
.padding(.horizontal)
.environmentObject(viewModel.context)
.previewDisplayName("Replying")
ScrollView {
VStack {
ForEach(replyTypes, id: \.self) { replyDetails in
messageComposer(mode: .reply(itemID: .random,
replyDetails: replyDetails, isThread: true))
}
}
}
.padding(.horizontal)
.environmentObject(viewModel.context)
.previewDisplayName("Replying in thread")
}
}
@@ -38,7 +38,7 @@ enum RoomScreenViewModelAction {
enum RoomScreenComposerMode: Equatable {
case `default`
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails)
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
case edit(originalItemId: TimelineItemIdentifier)
var isEdit: Bool {
@@ -435,7 +435,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actionsSubject.send(.composer(action: .clear))
switch mode {
case .reply(let itemId, _):
case .reply(let itemId, _, _):
await timelineController.sendMessage(message, html: html, inReplyTo: itemId)
case .edit(let originalItemId):
await timelineController.editMessage(message, html: html, original: originalItemId)
@@ -517,8 +517,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
var actions: [TimelineItemMenuAction] = []
if item.isMessage {
actions.append(.reply)
if let messageitem = item as? EventBasedMessageTimelineItemProtocol {
actions.append(.reply(isThread: messageitem.isThreaded))
actions.append(.forward(itemID: itemID))
}
@@ -601,9 +601,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
case .reply:
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: buildReplyContent(for: eventTimelineItem))
let replyInfo = buildReplyInfo(for: eventTimelineItem)
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, contentType: replyInfo.type)
actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails))))
actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails, isThread: replyInfo.isThread))))
case .forward(let itemID):
actionsSubject.send(.displayMessageForwarding(itemID: itemID))
case .viewSource:
@@ -681,12 +682,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
private func buildReplyContent(for item: EventBasedTimelineItemProtocol) -> EventBasedMessageTimelineItemContentType {
private func buildReplyInfo(for item: EventBasedTimelineItemProtocol) -> ReplyInfo {
guard let messageItem = item as? EventBasedMessageTimelineItemProtocol else {
return .text(.init(body: item.body))
return .init(type: .text(.init(body: item.body)), isThread: false)
}
return messageItem.contentType
return .init(type: messageItem.contentType, isThread: messageItem.isThreaded)
}
private func handleTappedUser(userID: String) async {
@@ -863,3 +864,8 @@ extension RoomScreenViewModel {
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
private struct ReplyInfo {
let type: EventBasedMessageTimelineItemContentType
let isThread: Bool
}
@@ -0,0 +1,56 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct SwipeToReplyView: View {
let timelineItem: RoomTimelineItemProtocol
var body: some View {
VStack(spacing: 4) {
Image(systemName: "arrowshape.turn.up.left")
.foregroundColor(.compound.iconPrimary)
if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol,
messageTimelineItem.isThreaded {
Text(L10n.actionReplyInThread)
.font(.compound.bodyXS)
.foregroundColor(.compound.textPrimary)
}
}
.accessibilityHidden(true)
}
}
struct SwipeToReplyView_Previews: PreviewProvider {
static let timelineItem = TextRoomTimelineItem(id: .init(timelineID: ""),
timestamp: "",
isOutgoing: true,
isEditable: true,
isThreaded: false, sender: .init(id: ""),
content: .init(body: ""))
static let threadedTimelineItem = TextRoomTimelineItem(id: .init(timelineID: ""),
timestamp: "",
isOutgoing: true,
isEditable: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: ""))
static var previews: some View {
SwipeToReplyView(timelineItem: timelineItem)
SwipeToReplyView(timelineItem: threadedTimelineItem)
}
}
@@ -0,0 +1,40 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import Compound
struct ThreadDecorator: View {
var body: some View {
Label {
Text(L10n.commonThread)
.foregroundColor(.compound.textPrimary)
.font(.compound.bodyXS)
} icon: {
CompoundIcon(\.threads)
.font(.system(size: 16))
.foregroundColor(.compound.iconSecondary)
}
.labelStyle(.custom(spacing: 4))
}
}
struct ThreadDecorator_Previews: PreviewProvider {
static var previews: some View {
ThreadDecorator()
}
}
@@ -17,6 +17,8 @@
import Foundation
import SwiftUI
import Compound
struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
@@ -130,12 +132,12 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id))
}
.swipeRightAction {
Image(systemName: "arrowshape.turn.up.left")
.accessibilityHidden(true)
SwipeToReplyView(timelineItem: timelineItem)
} shouldStartAction: {
context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false
} action: {
context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply))
let isThread = (timelineItem as? EventBasedMessageTimelineItemProtocol)?.isThreaded ?? false
context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: isThread)))
}
.contextMenu {
TimelineItemMacContextMenu(item: timelineItem,
@@ -217,28 +219,35 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
var contentWithReply: some View {
TimelineBubbleLayout(spacing: 8) {
if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol,
let replyDetails = messageTimelineItem.replyDetails {
// The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.compound.bgCanvasDefault)
.cornerRadius(8)
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
// Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
.hidden()
if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol {
if messageTimelineItem.isThreaded {
ThreadDecorator()
.padding(.leading, 4)
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
}
if let replyDetails = messageTimelineItem.replyDetails {
// The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.compound.bgCanvasDefault)
.cornerRadius(8)
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
// Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.fixedSize(horizontal: false, vertical: true)
.padding(4.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
.hidden()
}
}
content()
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
.cornerRadius(timelineItem.contentCornerRadius)
}
}
@@ -312,9 +321,15 @@ private extension EventBasedTimelineItemProtocol {
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
switch self {
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
case let self as EventBasedMessageTimelineItemProtocol:
switch self {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
// In case a reply detail or a thread decorator is present we render the color and the padding
return self.replyDetails != nil || self.isThreaded ? defaultColor : nil
default:
return defaultColor
}
case is StickerRoomTimelineItem:
return nil
default:
return defaultColor
@@ -327,14 +342,24 @@ private extension EventBasedTimelineItemProtocol {
let defaultInsets: EdgeInsets = .init(around: 8)
switch self {
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem,
is StickerRoomTimelineItem:
case is StickerRoomTimelineItem:
return .zero
case is PollRoomTimelineItem:
return .init(top: 12, leading: 12, bottom: 4, trailing: 12)
case let locationTimelineItem as LocationRoomTimelineItem:
return locationTimelineItem.content.geoURI == nil ? defaultInsets : .zero
case let self as EventBasedMessageTimelineItemProtocol:
switch self {
// In case a reply detail or a thread decorator is present we render the color and the padding
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem:
return self.replyDetails != nil ||
self.isThreaded ? defaultInsets : .zero
case let locationTimelineItem as LocationRoomTimelineItem:
return locationTimelineItem.content.geoURI == nil ||
self.replyDetails != nil ||
self.isThreaded ? defaultInsets : .zero
default:
return defaultInsets
}
default:
return defaultInsets
}
@@ -358,6 +383,17 @@ private extension EventBasedTimelineItemProtocol {
return defaultTimestampLayout
}
}
var contentCornerRadius: CGFloat {
guard let message = self as? EventBasedMessageTimelineItemProtocol else { return .zero }
switch message {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is LocationRoomTimelineItem:
return message.replyDetails != nil || message.isThreaded ? 8 : .zero
default:
return .zero
}
}
}
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
@@ -374,6 +410,80 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
.previewDisplayName("Mock Timeline RTL")
replies
.previewDisplayName("Replies")
threads
.previewDisplayName("Thread decorator")
}
// These akwats include a reply
static var threads: some View {
ScrollView {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
isThreaded: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))), groupStyle: .single))
AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "audio.ogg",
duration: 100,
source: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "File",
source: nil,
thumbnailSource: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782,
longitude: 12.496366),
description: "Location description description description description description description description description"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
}
.environmentObject(viewModel.context)
}
static var mockTimeline: some View {
@@ -396,6 +506,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
isThreaded: false,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
@@ -405,6 +516,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
isThreaded: false,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
@@ -48,17 +48,22 @@ 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(.global.melon)
.frame(width: 4.0)
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
if let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol {
if messageTimelineItem.isThreaded {
ThreadDecorator()
}
if let replyDetails = messageTimelineItem.replyDetails {
HStack(spacing: 4.0) {
Rectangle()
.foregroundColor(.global.melon)
.frame(width: 4.0)
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
}
}
}
content()
.layoutPriority(1)
}
.onTapGesture(count: 2) {
context.send(viewAction: .displayEmojiPicker(itemID: timelineItem.id))
@@ -72,11 +77,12 @@ struct TimelineItemPlainStylerView<Content: View>: View {
context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id))
}
.swipeRightAction {
Image(systemName: "arrowshape.turn.up.left")
SwipeToReplyView(timelineItem: timelineItem)
} shouldStartAction: {
context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false
} action: {
context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply))
let isThread = (timelineItem as? EventBasedMessageTimelineItemProtocol)?.isThreaded ?? false
context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: isThread)))
}
.contextMenu {
TimelineItemMacContextMenu(item: timelineItem,
@@ -136,6 +142,78 @@ struct TimelineItemPlainStylerView<Content: View>: View {
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
// These akwats include a reply
static var threads: some View {
ScrollView {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
isThreaded: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))), groupStyle: .single))
AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: false,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "audio.ogg",
duration: 100,
source: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "File",
source: nil,
thumbnailSource: nil,
contentType: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
timestamp: "10:42",
isOutgoing: true,
isEditable: true,
isThreaded: true,
sender: .init(id: ""),
content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782,
longitude: 12.496366),
description: "Location description description description description description description description description"),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
contentType: .text(.init(body: "Short")))))
}
.environmentObject(viewModel.context)
}
static var previews: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -148,5 +226,9 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider {
.environment(\.timelineStyle, .plain)
.previewLayout(.sizeThatFits)
.environmentObject(viewModel.context)
threads
.padding()
.environment(\.timelineStyle, .plain)
.previewDisplayName("Threads")
}
}
@@ -38,7 +38,7 @@ struct TimelineStyler<Content: View>: View {
struct TimelineItemStyler_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static let base = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
static let base = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
static let sentNonLast: TextRoomTimelineItem = {
var result = base
@@ -54,7 +54,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
static let sendingLast: TextRoomTimelineItem = {
let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString
var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
result.properties.deliveryStatus = .sending
return result
}()
@@ -67,21 +67,21 @@ struct TimelineItemStyler_Previews: PreviewProvider {
static let sentLast: TextRoomTimelineItem = {
let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString
let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
return result
}()
static let ltrString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "house!"))
static let ltrString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "house!"))
static let rtlString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת!"))
static let rtlString = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת!"))
static let ltrStringThatContainsRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת‏! -- house!"))
static let ltrStringThatContainsRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת‏! -- house!"))
static let rtlStringThatContainsLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house! -- באמת!"))
static let rtlStringThatContainsLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house! -- באמת!"))
static let ltrStringThatFinishesInRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת!"))
static let ltrStringThatFinishesInRtl = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "house! -- באמת!"))
static let rtlStringThatFinishesInLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house!"))
static let rtlStringThatFinishesInLtr = TextRoomTimelineItem(id: .random, timestamp: "Now", isOutgoing: true, isEditable: false, isThreaded: false, sender: .init(id: UUID().uuidString), content: .init(body: "באמת‏! -- house!"))
static var testView: some View {
VStack {
@@ -81,7 +81,9 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider {
TextRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: true,
isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"),
isEditable: false,
isThreaded: false,
sender: .init(id: UUID().uuidString), content: .init(body: "Test"),
properties: .init(orderedReadReceipts: receipts))
}
@@ -88,6 +88,8 @@ struct SwipeRightAction<Label: View>: ViewModifier {
.overlay(alignment: .leading) {
// We want the action icon to follow the view translation and gradually fade in
label()
.multilineTextAlignment(.center)
.frame(maxWidth: actionThreshold)
.opacity(xOffset / 50)
.animation(.interactiveSpring().speed(0.5), value: xOffset)
.offset(x: -actionThreshold + min(xOffset, actionThreshold), y: 0.0)
@@ -50,6 +50,7 @@ struct AudioRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "audio.ogg", duration: 300, source: nil, contentType: nil)))
}
@@ -59,6 +59,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: text))
}
@@ -51,6 +51,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "document.pdf", source: nil, thumbnailSource: nil, contentType: nil)))
@@ -58,6 +59,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "document.docx", source: nil, thumbnailSource: nil, contentType: nil)))
@@ -65,6 +67,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "document.txt", source: nil, thumbnailSource: nil, contentType: nil)))
}
@@ -28,8 +28,8 @@ struct ImageRoomTimelineView: View {
imageProvider: context.imageProvider) {
placeholder
}
.frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity)))
.aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit)
.timelineMediaFrame(height: timelineItem.content.height,
aspectRatio: timelineItem.content.height)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
}
@@ -72,6 +72,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil)))
@@ -79,6 +80,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some other image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil)))
@@ -86,6 +88,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Blurhashed image",
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"),
@@ -86,20 +86,33 @@ struct LocationRoomTimelineView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View {
body
.environmentObject(viewModel.context)
body
.environment(\.timelineStyle, .plain)
.environmentObject(viewModel.context)
ScrollView {
VStack {
states
.padding(.horizontal)
}
}
.environmentObject(viewModel.context)
.previewDisplayName("Bubbles")
ScrollView {
VStack {
states
.padding(.horizontal)
}
}
.environment(\.timelineStyle, .plain)
.environmentObject(viewModel.context)
.previewDisplayName("Plain")
}
@ViewBuilder
static var body: some View {
static var states: some View {
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description")))
@@ -107,8 +120,19 @@ struct LocationRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description")))
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"),
replyDetails: .loaded(sender: .init(id: "Someone"),
contentType: .text(.init(body: "The thread content goes 'ere.")))))
}
}
@@ -70,6 +70,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: text))
}
@@ -47,6 +47,7 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
timestamp: "",
isOutgoing: true,
isEditable: false,
isThreaded: false,
sender: .init(id: "1", displayName: "Bob"),
content: .init(body: "This is another message"))), groupStyle: .single))
@@ -57,6 +58,7 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
timestamp: "",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: "Alice"),
content: .init(body: "This is a message"))), groupStyle: .single))
}
@@ -28,8 +28,8 @@ struct StickerRoomTimelineView: View {
imageProvider: context.imageProvider) {
placeholder
}
.frame(maxHeight: min(300, max(100, timelineItem.height ?? .infinity)))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
.timelineMediaFrame(height: timelineItem.height,
aspectRatio: timelineItem.aspectRatio)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)")
}
@@ -75,6 +75,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
isOutgoing: isOutgoing,
isEditable: isOutgoing,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: text))
}
@@ -24,8 +24,8 @@ struct VideoRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
thumbnail
.frame(maxHeight: min(300, max(100, timelineItem.content.height ?? .infinity)))
.aspectRatio(timelineItem.content.aspectRatio, contentMode: .fit)
.timelineMediaFrame(height: timelineItem.content.height,
aspectRatio: timelineItem.content.aspectRatio)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonVideo)
}
@@ -83,6 +83,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some video", duration: 21, source: nil, thumbnailSource: nil)))
@@ -90,6 +91,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some other video", duration: 22, source: nil, thumbnailSource: nil)))
@@ -97,6 +99,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Blurhashed video", duration: 23, source: nil, thumbnailSource: nil, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW")))
}
@@ -46,7 +46,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
case edit
case copyPermalink
case redact
case reply
case reply(isThread: Bool)
case forward(itemID: TimelineItemIdentifier)
case viewSource
case retryDecryption(sessionID: String)
@@ -106,8 +106,8 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
Label(L10n.actionEdit, systemImage: "pencil.line")
case .copyPermalink:
Label(L10n.actionCopyLinkToMessage, systemImage: "link")
case .reply:
Label(L10n.actionReply, systemImage: "arrowshape.turn.up.left")
case .reply(let isThread):
Label(isThread ? L10n.actionReplyInThread : L10n.actionReply, systemImage: "arrowshape.turn.up.left")
case .forward:
Label(L10n.actionForward, systemImage: "arrowshape.turn.up.right")
case .redact:
@@ -292,7 +292,7 @@ struct TimelineItemMenu_Previews: PreviewProvider {
static var previews: some View {
VStack {
if let item = RoomTimelineItemFixtures.singleMessageChunk.first as? EventBasedTimelineItemProtocol,
let actions = TimelineItemMenuActions(actions: [.copy, .edit, .reply, .redact], debugActions: [.viewSource]) {
let actions = TimelineItemMenuActions(actions: [.copy, .edit, .reply(isThread: false), .redact], debugActions: [.viewSource]) {
TimelineItemMenu(item: item, actions: actions)
}
}
@@ -24,6 +24,7 @@ enum RoomTimelineItemFixtures {
timestamp: "10:10 AM",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: "Jacob"),
content: .init(body: "That looks so good!"),
properties: RoomTimelineItemProperties(isEdited: true)),
@@ -31,6 +32,7 @@ enum RoomTimelineItemFixtures {
timestamp: "10:11 AM",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: "Helena"),
content: .init(body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗"),
properties: RoomTimelineItemProperties(reactions: [
@@ -40,6 +42,7 @@ enum RoomTimelineItemFixtures {
timestamp: "10:11 AM",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: "Helena"),
content: .init(body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/"),
properties: RoomTimelineItemProperties(reactions: [
@@ -57,6 +60,7 @@ enum RoomTimelineItemFixtures {
timestamp: "5 PM",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: "Helena"),
content: .init(body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!"),
properties: RoomTimelineItemProperties(orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil)])),
@@ -64,12 +68,14 @@ enum RoomTimelineItemFixtures {
timestamp: "5 PM",
isOutgoing: true,
isEditable: true,
isThreaded: false,
sender: .init(id: "", displayName: "Bob"),
content: .init(body: "And John's speech was amazing!")),
TextRoomTimelineItem(id: .random,
timestamp: "5 PM",
isOutgoing: true,
isEditable: true,
isThreaded: false,
sender: .init(id: "", displayName: "Bob"),
content: .init(body: "New home office set up!"),
properties: RoomTimelineItemProperties(reactions: AggregatedReaction.mockReactions,
@@ -81,6 +87,7 @@ enum RoomTimelineItemFixtures {
timestamp: "5 PM",
isOutgoing: false,
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: "Helena"),
content: .init(body: "",
formattedBody: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL).fromHTML("Hol' up <blockquote>New home office set up!</blockquote>That's amazing! Congrats 🥳")))
@@ -230,6 +237,7 @@ private extension TextRoomTimelineItem {
timestamp: "10:47 am",
isOutgoing: senderDisplayName == "Alice",
isEditable: false,
isThreaded: false,
sender: .init(id: "", displayName: senderDisplayName),
content: .init(body: text))
}
@@ -30,4 +30,5 @@ enum EventBasedMessageTimelineItemContentType: Hashable {
protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
var replyDetails: TimelineItemReplyDetails? { get }
var contentType: EventBasedMessageTimelineItemContentType { get }
var isThreaded: Bool { get }
}
@@ -21,6 +21,7 @@ struct AudioRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
let content: AudioRoomTimelineItemContent
@@ -21,6 +21,7 @@ struct EmoteRoomTimelineItem: TextBasedRoomTimelineItem, Equatable {
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
@@ -23,6 +23,8 @@ struct FileRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
let content: FileRoomTimelineItemContent
@@ -22,6 +22,7 @@ struct ImageRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
@@ -20,7 +20,8 @@ struct LocationRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatabl
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
let content: LocationRoomTimelineItemContent
@@ -21,6 +21,7 @@ struct NoticeRoomTimelineItem: TextBasedRoomTimelineItem, Equatable {
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
@@ -23,6 +23,8 @@ struct TextRoomTimelineItem: TextBasedRoomTimelineItem, Equatable {
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
let content: TextRoomTimelineItemContent
@@ -22,6 +22,7 @@ struct VideoRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
let timestamp: String
let isOutgoing: Bool
let isEditable: Bool
let isThreaded: Bool
let sender: TimelineItemSender
@@ -80,23 +80,24 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
fatalError("Invalid message timeline item: \(eventItemProxy)")
}
let isThreaded = messageTimelineItem.isThreaded()
switch messageTimelineItem.msgtype() {
case .text(content: let content):
return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .image(content: let content):
return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .video(let content):
return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .file(let content):
return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .notice(content: let content):
return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .emote(content: let content):
return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .audio(let content):
return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .location(let content):
return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing)
return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing, isThreaded)
case .none:
return nil
}
@@ -182,11 +183,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildTextTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: TextMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
TextRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildTextTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -199,11 +202,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildImageTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: ImageMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
ImageRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildImageTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -216,11 +221,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildVideoTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: VideoMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
VideoRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildVideoTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -233,11 +240,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildAudioTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: AudioMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
AudioRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildAudioTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -250,11 +259,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: FileMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
FileRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildFileTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -267,11 +278,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildNoticeTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: NoticeMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
NoticeRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildNoticeTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -284,11 +297,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEmoteTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: EmoteMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
EmoteRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildEmoteTimelineItemContent(senderDisplayName: eventItemProxy.sender.displayName, senderID: eventItemProxy.sender.id, messageContent: messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),
@@ -301,11 +316,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildLocationTimelineItem(for eventItemProxy: EventTimelineItemProxy,
_ messageTimelineItem: Message,
_ messageContent: LocationContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool,
_ isThreaded: Bool) -> RoomTimelineItemProtocol {
LocationRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
isThreaded: isThreaded,
sender: eventItemProxy.sender,
content: buildLocationTimelineItemContent(messageContent),
replyDetails: buildReplyToDetailsFrom(details: messageTimelineItem.inReplyTo()),