mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
+141
-29
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user