RoomScreenViewModel is now TimelineViewModel (#3157)

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
Mauro
2024-08-13 13:36:40 +02:00
committed by GitHub
parent 4519275559
commit 1ad361a6e8
88 changed files with 2266 additions and 1973 deletions
@@ -0,0 +1,55 @@
//
// 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 Foundation
import SwiftUI
struct AudioRoomTimelineView: View {
let timelineItem: AudioRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Label(title: { Text(timelineItem.body) },
icon: { Image(systemName: "waveform")
.foregroundColor(.compound.iconPrimary)
})
.labelStyle(RoomTimelineViewLabelStyle())
.font(.compound.bodyLG)
.padding(.vertical, 12)
.padding(.horizontal, 6)
.accessibilityLabel(L10n.commonAudio)
}
}
}
struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "audio.ogg", duration: 300, waveform: nil, source: nil, contentType: nil)))
}
}
@@ -0,0 +1,48 @@
//
// Copyright 2024 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 Compound
import Foundation
import SwiftUI
struct CallInviteRoomTimelineView: View {
let timelineItem: CallInviteRoomTimelineItem
var body: some View {
Label(title: { Text(L10n.commonCallInvite) },
icon: { CompoundIcon(\.voiceCall, size: .medium, relativeTo: .compound.bodyMD) })
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
}
}
struct CallInviteRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
CallInviteRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isEditable: false,
canBeRepliedTo: false,
sender: .init(id: "Bob")))
}
}
@@ -0,0 +1,78 @@
//
// Copyright 2024 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 Compound
import Foundation
import SwiftUI
struct CallNotificationRoomTimelineView: View {
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: CallNotificationRoomTimelineItem
var body: some View {
HStack(spacing: 12) {
LoadableAvatarImage(url: timelineItem.sender.avatarURL,
name: timelineItem.sender.displayName ?? timelineItem.sender.id,
contentID: timelineItem.sender.id,
avatarSize: .user(on: .timeline),
imageProvider: context.imageProvider)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 0) {
Text(timelineItem.sender.disambiguatedDisplayName ?? timelineItem.sender.id)
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Label(title: { Text(L10n.commonCallStarted) },
icon: { CompoundIcon(\.videoCallSolid, size: .medium, relativeTo: .compound.bodyMD) })
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
.labelStyle(.custom(spacing: 4))
}
Spacer()
Text(timelineItem.timestamp)
.font(.compound.bodyXS)
.foregroundColor(.compound.textSecondary)
}
.padding(12)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.compound.borderInteractiveSecondary, lineWidth: 1)
)
.padding(16)
}
}
struct CallNotificationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
CallNotificationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isEditable: false,
canBeRepliedTo: false,
sender: .init(id: "Bob")))
}
}
@@ -0,0 +1,71 @@
//
// 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 CollapsibleRoomTimelineView: View {
private let timelineItem: CollapsibleTimelineItem
private let groupedViewStates: [RoomTimelineItemViewState]
@State private var isExpanded = false
init(timelineItem: CollapsibleTimelineItem) {
self.timelineItem = timelineItem
groupedViewStates = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
}
var body: some View {
VStack(spacing: 0.0) {
Button {
withElementAnimation {
isExpanded.toggle()
}
} label: {
HStack(alignment: .center, spacing: 8) {
Text(L10n.screenRoomTimelineStateChanges(timelineItem.items.count))
Text(Image(systemName: "chevron.forward"))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.animation(.elementDefault, value: isExpanded)
}
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.padding(.horizontal, 36.0)
.padding(.vertical, 12.0)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity)
.padding(.top, 8.0)
if isExpanded {
ForEach(groupedViewStates) { viewState in
RoomTimelineItemView(viewState: viewState)
}
}
}
}
}
struct CollapsibleRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let item = CollapsibleTimelineItem(items: [
SeparatorRoomTimelineItem(id: .init(timelineID: "First separator"), text: "This is a separator"),
SeparatorRoomTimelineItem(id: .init(timelineID: "Second separator"), text: "This is another separator")
])
static var previews: some View {
CollapsibleRoomTimelineView(timelineItem: item)
}
}
@@ -0,0 +1,63 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct EmoteRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
let timelineItem: EmoteRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
} else {
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
}
}
}
}
struct EmoteRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
EmoteRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
senderId: "Bob"))
EmoteRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
senderId: "Anne"))
}
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> EmoteRoomTimelineItem {
EmoteRoomTimelineItem(id: .random,
timestamp: timestamp,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: text))
}
}
@@ -0,0 +1,109 @@
//
// Copyright 2022 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 Compound
import SwiftUI
struct EncryptedRoomTimelineView: View {
let timelineItem: EncryptedRoomTimelineItem
var icon: KeyPath<CompoundIcons, Image> {
switch timelineItem.encryptionType {
case .megolmV1AesSha2(_, let cause):
switch cause {
case .unknown:
return \.time
case .membership:
return \.block
}
default:
return \.time
}
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Label(timelineItem.body, icon: icon, iconSize: .small, relativeTo: .compound.bodyLG)
.labelStyle(RoomTimelineViewLabelStyle())
.font(.compound.bodyLG)
}
}
}
struct RoomTimelineViewLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .center, spacing: 8) {
configuration.icon
.foregroundColor(.compound.iconSecondary)
configuration.title
.foregroundColor(.compound.textPrimary)
}
.padding(.horizontal, 4)
}
}
struct EncryptedRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
EncryptedRoomTimelineView(timelineItem: itemWith(text: L10n.commonWaitingForDecryptionKey,
timestamp: "Now",
isOutgoing: false,
senderId: "Bob"))
EncryptedRoomTimelineView(timelineItem: itemWith(text: L10n.commonWaitingForDecryptionKey,
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
EncryptedRoomTimelineView(timelineItem: itemWith(text: "Some other text that is very long and will wrap onto multiple lines.",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
EncryptedRoomTimelineView(timelineItem: expectedItemWith(timestamp: "Now",
isOutgoing: false,
senderId: "Bob"))
}
}
private static func itemWith(text: String, timestamp: String, isOutgoing: Bool, senderId: String) -> EncryptedRoomTimelineItem {
EncryptedRoomTimelineItem(id: .random,
body: text,
encryptionType: .unknown,
timestamp: timestamp,
isOutgoing: isOutgoing,
isEditable: false,
canBeRepliedTo: false,
sender: .init(id: senderId))
}
private static func expectedItemWith(timestamp: String, isOutgoing: Bool, senderId: String) -> EncryptedRoomTimelineItem {
EncryptedRoomTimelineItem(id: .random,
body: L10n.commonUnableToDecryptNoAccess,
encryptionType: .megolmV1AesSha2(sessionID: "foo", cause: .membership),
timestamp: timestamp,
isOutgoing: isOutgoing,
isEditable: false,
canBeRepliedTo: false,
sender: .init(id: senderId))
}
}
@@ -0,0 +1,75 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct FileRoomTimelineView: View {
let timelineItem: FileRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Label(title: { Text(timelineItem.body) },
icon: { Image(systemName: "doc.text.fill")
.foregroundColor(.compound.iconPrimary)
})
.labelStyle(RoomTimelineViewLabelStyle())
.font(.compound.bodyLG)
.padding(.vertical, 12)
.padding(.horizontal, 6)
.accessibilityLabel(L10n.commonFile)
}
}
}
struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(spacing: 20.0) {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "document.pdf", source: nil, thumbnailSource: nil, contentType: nil)))
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "document.docx", source: nil, thumbnailSource: nil, contentType: nil)))
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "document.txt", source: nil, thumbnailSource: nil, contentType: nil)))
}
}
}
@@ -0,0 +1,235 @@
//
// Copyright 2022 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 FormattedBodyText: View {
@Environment(\.layoutDirection) private var layoutDirection
private let attributedString: AttributedString
private let additionalWhitespacesCount: Int
private let boostEmojiSize: Bool
private let defaultAttributesContainer: AttributeContainer = {
var container = AttributeContainer()
// Equivalent to compound's bodyLG
container.font = UIFont.preferredFont(forTextStyle: .body)
container.foregroundColor = UIColor.compound.textPrimary
return container
}()
private var attributedComponents: [AttributedStringBuilderComponent] {
var adjustedAttributedString = attributedString + AttributedString(additionalWhitespacesSuffix)
// If this is not a list, force the writing direction by adding the correct unicode character.
if !String(attributedString.characters).starts(with: "\t") {
adjustedAttributedString = AttributedString(layoutDirection.isolateLayoutUnicodeString) + adjustedAttributedString
}
// Required to allow the underlying TextView to use body font when no font is specifie in the AttributedString.
adjustedAttributedString.mergeAttributes(defaultAttributesContainer, mergePolicy: .keepCurrent)
let string = String(attributedString.characters)
if boostEmojiSize,
string.containsOnlyEmoji,
let range = adjustedAttributedString.range(of: string) {
adjustedAttributedString[range].font = UIFont.systemFont(ofSize: 48.0)
}
return adjustedAttributedString.formattedComponents
}
init(attributedString: AttributedString,
additionalWhitespacesCount: Int = 0,
boostEmojiSize: Bool = false) {
self.attributedString = attributedString
self.additionalWhitespacesCount = additionalWhitespacesCount
self.boostEmojiSize = boostEmojiSize
}
init(text: String, additionalWhitespacesCount: Int = 0, boostEmojiSize: Bool = false) {
self.init(attributedString: AttributedString(text),
additionalWhitespacesCount: additionalWhitespacesCount,
boostEmojiSize: boostEmojiSize)
}
// These is needed to create the slightly off inlined timestamp effect
private var additionalWhitespacesSuffix: String {
.generateBreakableWhitespaceEnd(whitespaceCount: additionalWhitespacesCount, layoutDirection: layoutDirection)
}
var body: some View {
mainContent
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(attributedString))
}
@ViewBuilder
var mainContent: some View {
layout
.tint(.compound.textLinkExternal)
}
/// The attributed components laid out for the bubbles timeline style.
var layout: some View {
TimelineBubbleLayout(spacing: 8) {
ForEach(attributedComponents) { component in
// Ignore if the string contains only the layout correction
if String(component.attributedString.characters) == layoutDirection.isolateLayoutUnicodeString {
EmptyView()
} else if component.isBlockquote {
// The rendered blockquote with a greedy width. The custom layout prevents the
// infinite width from increasing the overall width of the view.
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 12.0)
.overlay(alignment: .leading) {
// User an overlay here so that the rectangle's infinite height doesn't take priority
Capsule()
.frame(width: 2.0)
.padding(.leading, 5.0)
.foregroundColor(.compound.textSecondary)
.padding(.vertical, 2)
}
.layoutPriority(TimelineBubbleLayout.Priority.visibleQuote)
} else {
MessageText(attributedString: component.attributedString)
.padding(.horizontal, 4)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(TimelineBubbleLayout.Priority.regularText)
}
}
// Make a second iteration through the components adding fixed width blockquotes
// which are used for layout calculations but won't be rendered.
ForEach(attributedComponents) { component in
if component.isBlockquote {
MessageText(attributedString: component.attributedString.mergingAttributes(blockquoteAttributes))
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 12.0)
.layoutPriority(TimelineBubbleLayout.Priority.hiddenQuote)
.hidden()
}
}
}
}
private var blockquoteAttributes: AttributeContainer {
// The paragraph style removes the block style paragraph that the parser adds by default
// Set directly in the constructor to avoid `Conformance to 'Sendable'` warnings
var container = AttributeContainer([.paragraphStyle: NSParagraphStyle.default])
// Sadly setting SwiftUI fonts do not work so we would need UIFont equivalents for compound, this one is bodyMD
container.font = UIFont.preferredFont(forTextStyle: .subheadline)
container.foregroundColor = UIColor.compound.textSecondary
return container
}
}
// MARK: - Previews
struct FormattedBodyText_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
body
}
@ViewBuilder
static var body: some View {
let htmlStrings = [
"""
Plain text\n
@bob:matrix.org\n
#room:matrix.org\n
!room:matrix.org\n
https://www.matrix.org\n
www.matrix.org\n
matrix.org
""",
"""
Text before blockquote
<blockquote>
<b>bold</b> <i>italic</i>
</blockquote>Text after blockquote
""",
"""
<blockquote>First blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
<blockquote>Second blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
<blockquote>Third blockquote with a <a href=\"https://www.matrix.org/\">link</a> in it</blockquote>
""",
"""
<blockquote>A blockquote that is long and goes onto multiple lines as the first item in the message</blockquote>
<p>Then another line of text here to reply to the blockquote, which is also a multiline component.</p>
<blockquote>Short line here.</blockquote>
<p>And a simple reply here.</p>
""",
"""
<code>Hello world</code>
<p>Text</p>
<code><b>Hello</b> <i>world</i></code>
<p>Text</p>
<code>Hello world</code>
<p>Text</p>
<code><a href="https://www.matrix.org">matrix.org</a> https://www.matrix.org</code>
""",
"<p>This is a list</p>\n<ul>\n<li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n",
"<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>",
"<p>test</p>\n<p>test</p>"
]
let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder())
ScrollView {
VStack(alignment: .leading, spacing: 24.0) {
ForEach(htmlStrings, id: \.self) { htmlString in
if let attributedString = attributedStringBuilder.fromHTML(htmlString) {
FormattedBodyText(attributedString: attributedString)
.previewBubble()
}
}
FormattedBodyText(attributedString: AttributedString("Some plain text wrapped in an AttributedString."))
.previewBubble()
FormattedBodyText(text: "Some plain text that's not an attributed component.")
.previewBubble()
FormattedBodyText(text: "Some plain text that's not an attributed component. This one is really long.")
.previewBubble()
FormattedBodyText(text: "❤️", boostEmojiSize: true)
.previewBubble()
}
.padding()
}
.previewLayout(.sizeThatFits)
.snapshot(delay: 0.25)
}
}
private struct PreviewBubbleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding(8)
.background(Color.compound._bgBubbleOutgoing)
.cornerRadius(12)
.environmentObject(TimelineViewModel.mock.context)
}
}
private extension View {
func previewBubble() -> some View {
modifier(PreviewBubbleModifier())
}
}
@@ -0,0 +1,117 @@
//
// Copyright 2024 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 Compound
import SwiftUI
extension View {
func highlightedTimelineItem(_ isHighlighted: Bool) -> some View {
modifier(HighlightedTimelineItemModifier(isHighlighted: isHighlighted))
}
}
private struct HighlightedTimelineItemModifier: ViewModifier {
let isHighlighted: Bool
func body(content: Content) -> some View {
content
.padding(.top, isHighlighted ? 1 : 0)
.background {
if isHighlighted {
VStack(spacing: 0) {
Color.compound._bgBubbleHighlighted
LinearGradient(colors: [.compound._bgBubbleHighlighted, .clear],
startPoint: .top,
endPoint: .bottom)
.frame(maxHeight: 200)
.layoutPriority(1)
}
.overlay(alignment: .top) {
Color.compound.bgAccentRest
.frame(height: 1)
}
}
}
}
}
// MARK: - Previews
// swiftlint:disable line_length blanket_disable_command
struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
ScrollView {
VStack(spacing: 16) {
Bubble(text: "Hello 👋")
.highlightedTimelineItem(true)
Bubble(text: "Not highlighted")
.highlightedTimelineItem(false)
// swiftlint:disable line_length
Bubble(text: """
Bacon ipsum dolor amet brisket bacon hamburger filet mignon ham hock, capicola meatloaf corned beef tongue. Ribeye filet mignon shoulder drumstick doner shank. Landjaeger shankle chislic brisket short loin pig. Frankfurter sirloin jerky bresaola tri-tip cow buffalo. Beef tongue shankle venison, sirloin boudin biltong ham hock corned beef. Sirloin shankle pork belly, strip steak pancetta brisket flank ribeye cow chislic. Pork ham landjaeger, pastrami beef sausage capicola meatball.
Cow brisket bresaola, burgdoggen cupim turducken sirloin andouille shankle sausage jerky chicken pig. Tail capicola landjaeger frankfurter. Kevin pancetta brisket spare ribs, sausage chuck tail pork. Ground round boudin chuck tri-tip corned beef. Pork belly ham bresaola tail, pork chop meatloaf biltong filet mignon strip steak ribeye boudin shoulder frankfurter.
""",
isOutgoing: true)
.highlightedTimelineItem(true)
// swiftlint:enable line_length
}
}
.previewDisplayName("Layout")
}
struct Bubble: View {
let text: String
var isOutgoing = false
var body: some View {
Text(text)
.padding(10)
.background(isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming,
in: RoundedRectangle(cornerRadius: 12))
.padding(isOutgoing ? .leading : .trailing, 40)
.frame(maxWidth: .infinity, alignment: isOutgoing ? .trailing : .leading)
.padding(12)
}
}
}
/// A preview that allows quick testing of the highlight appearance across various timeline scenarios.
struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static let roomViewModel = RoomScreenViewModel.mock()
static let focussedEventID = "RoomTimelineItemFixtures.default.5"
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
focussedEventID: focussedEventID,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static var previews: some View {
NavigationStack {
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Timeline")
}
}
@@ -0,0 +1,101 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct ImageRoomTimelineView: View {
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: ImageRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
LoadableImage(mediaSource: source,
blurhash: timelineItem.content.blurhash,
imageProvider: context.imageProvider) {
placeholder
}
.timelineMediaFrame(height: timelineItem.content.height,
aspectRatio: timelineItem.content.aspectRatio)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
}
}
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()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
}
}
}
struct ImageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body
.environmentObject(viewModel.context)
}
static var body: some View {
VStack(spacing: 20.0) {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil)))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some other image", source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png"), thumbnailSource: nil)))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: 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)))
}
}
}
@@ -0,0 +1,129 @@
//
// 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 LocationRoomTimelineView: View {
let timelineItem: LocationRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
mainContent
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
}
@ViewBuilder
private var mainContent: some View {
if let geoURI = timelineItem.content.geoURI {
VStack(alignment: .leading, spacing: 0) {
descriptionView
.frame(maxWidth: mapAspectRatio * mapMaxHeight, alignment: .leading)
MapLibreStaticMapView(geoURI: geoURI, mapSize: .init(width: mapAspectRatio * mapMaxHeight, height: mapMaxHeight)) {
LocationMarkerView()
}
.frame(maxHeight: mapMaxHeight)
.aspectRatio(mapAspectRatio, contentMode: .fit)
.clipped()
}
} else {
FormattedBodyText(text: timelineItem.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
}
}
// MARK: - Private
private var accessibilityLabel: String {
if let description = timelineItem.content.description {
return "\(L10n.commonSharedLocation), \(description)"
}
return L10n.commonSharedLocation
}
@ViewBuilder
private var descriptionView: some View {
if let description = timelineItem.content.description, !description.isEmpty {
FormattedBodyText(text: description)
.padding(8)
}
}
private let mapAspectRatio: Double = 3 / 2
private let mapMaxHeight: Double = 300
}
private extension MapLibreStaticMapView {
init(geoURI: GeoURI, mapSize: CGSize, @ViewBuilder pinAnnotationView: () -> PinAnnotation) {
self.init(coordinates: .init(latitude: geoURI.latitude, longitude: geoURI.longitude),
zoomLevel: 15,
attributionPlacement: .bottomLeft,
mapTilerStatic: MapTilerStaticMap(baseURL: ServiceLocator.shared.settings.mapTilerBaseURL,
key: ServiceLocator.shared.settings.mapTilerApiKey),
mapSize: mapSize,
pinAnnotationView: pinAnnotationView)
}
}
struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
ScrollView {
VStack(spacing: 8) {
states
}
}
.environmentObject(viewModel.context)
.previewDisplayName("Bubbles")
}
@ViewBuilder
static var states: some View {
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description")))
LocationRoomTimelineView(timelineItem: .init(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
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,
canBeRepliedTo: true,
isThreaded: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: "Location description description description description description description description description"),
replyDetails: .loaded(sender: .init(id: "Someone"),
eventID: "123",
eventContent: .message(.text(.init(body: "The thread content goes 'ere."))))))
}
}
@@ -0,0 +1,75 @@
//
// Copyright 2022 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 Compound
import SwiftUI
struct NoticeRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
let timelineItem: NoticeRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
// Don't use RoomTimelineViewLabelStyle with FormattedBodyText as the formatted text
// adds additional padding so the spacing between the icon and text is inconsistent.
// Spacing: 6 = label spacing - formatted text padding
Label {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
} else {
FormattedBodyText(text: timelineItem.content.body, additionalWhitespacesCount: timelineItem.additionalWhitespaces())
}
} icon: {
CompoundIcon(\.info, size: .small, relativeTo: .compound.bodyLG)
.foregroundColor(.compound.iconSecondary)
}
.labelStyle(.custom(spacing: 6.0, alignment: .top))
.padding(.leading, 4) // Trailing padding is provided by FormattedBodyText
}
}
}
struct NoticeRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
NoticeRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
senderId: "Bob"))
NoticeRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
senderId: "Anne"))
}
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> NoticeRoomTimelineItem {
NoticeRoomTimelineItem(id: .random,
timestamp: timestamp,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: text))
}
}
@@ -0,0 +1,34 @@
//
// Copyright 2022 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 PaginationIndicatorRoomTimelineView: View {
let timelineItem: PaginationIndicatorRoomTimelineItem
var body: some View {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 12) // Bottom spacing comes from the next item (date separator).
}
}
struct PaginationIndicatorRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let item = PaginationIndicatorRoomTimelineItem(position: .start)
PaginationIndicatorRoomTimelineView(timelineItem: item)
}
}
@@ -0,0 +1,80 @@
//
// 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 PollRoomTimelineView: View {
let timelineItem: PollRoomTimelineItem
@EnvironmentObject private var context: TimelineViewModel.Context
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
PollView(poll: poll, editable: timelineItem.isEditable) { action in
switch action {
case .selectOption(let optionID):
guard let eventID, let option = poll.options.first(where: { $0.id == optionID }), !option.isSelected else { return }
context.send(viewAction: .handlePollAction(.selectOption(pollStartID: eventID, optionID: option.id)))
case .edit:
guard let eventID else { return }
context.send(viewAction: .handlePollAction(.edit(pollStartID: eventID, poll: poll)))
case .end:
guard let eventID else { return }
context.send(viewAction: .handlePollAction(.end(pollStartID: eventID)))
}
}
}
}
// MARK: - Private
private var poll: Poll {
timelineItem.poll
}
private var eventID: String? {
timelineItem.id.eventID
}
}
struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false))
.environmentObject(viewModel.context)
.previewDisplayName("Disclosed, Bubble")
PollRoomTimelineView(timelineItem: .mock(poll: .undisclosed(), isOutgoing: false))
.environmentObject(viewModel.context)
.previewDisplayName("Undisclosed, Bubble")
PollRoomTimelineView(timelineItem: .mock(poll: .endedDisclosed))
.environmentObject(viewModel.context)
.previewDisplayName("Ended, Disclosed, Bubble")
PollRoomTimelineView(timelineItem: .mock(poll: .endedUndisclosed))
.environmentObject(viewModel.context)
.previewDisplayName("Ended, Undisclosed, Bubble")
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(createdByAccountOwner: true)))
.environmentObject(viewModel.context)
.previewDisplayName("Creator, disclosed, Bubble")
PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true))
.environmentObject(viewModel.context)
.previewDisplayName("Creator, no votes, Bubble")
}
}
@@ -0,0 +1,69 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct ReadMarkerRoomTimelineView: View {
let timelineItem: ReadMarkerRoomTimelineItem
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
Text(L10n.screenRoomTimelineReadMarkerTitle)
.textCase(.uppercase)
.font(.compound.bodyXSSemibold)
.foregroundColor(.compound.textSecondary)
Rectangle()
.frame(height: 0.5)
.foregroundColor(.compound.borderInteractivePrimary)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
}
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static let item = ReadMarkerRoomTimelineItem(id: .init(timelineID: .init(UUID().uuidString)))
static var previews: some View {
VStack(alignment: .leading, spacing: 0) {
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""),
timestamp: "",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "1", displayName: "Bob"),
content: .init(body: "This is another message"))), groupStyle: .single))
ReadMarkerRoomTimelineView(timelineItem: item)
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""),
timestamp: "",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Alice"),
content: .init(body: "This is a message"))), groupStyle: .single))
}
.environmentObject(viewModel.context)
}
}
@@ -0,0 +1,53 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct RedactedRoomTimelineView: View {
let timelineItem: RedactedRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Label(timelineItem.body, icon: \.delete, iconSize: .small, relativeTo: .compound.bodyLG)
.labelStyle(RoomTimelineViewLabelStyle())
.imageScale(.small) // Smaller icon so that the bubble remains rounded on the outside.
}
}
}
struct RedactedRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
VStack(alignment: .leading, spacing: 20.0) {
RedactedRoomTimelineView(timelineItem: itemWith(text: L10n.commonMessageRemoved,
timestamp: "Later",
senderId: "Anne"))
}
.environmentObject(viewModel.context)
}
private static func itemWith(text: String, timestamp: String, senderId: String) -> RedactedRoomTimelineItem {
RedactedRoomTimelineItem(id: .random,
body: text,
timestamp: timestamp,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: false,
sender: .init(id: senderId))
}
}
@@ -0,0 +1,38 @@
//
// Copyright 2022 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 SeparatorRoomTimelineView: View {
let timelineItem: SeparatorRoomTimelineItem
var body: some View {
Text(timelineItem.text)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.horizontal, 36.0)
.padding(.vertical, 8.0)
}
}
struct SeparatorRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let item = SeparatorRoomTimelineItem(id: .init(timelineID: "Separator"), text: "This is a separator")
SeparatorRoomTimelineView(timelineItem: item)
}
}
@@ -0,0 +1,49 @@
//
// 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 StateRoomTimelineView: View {
let timelineItem: StateRoomTimelineItem
var body: some View {
Text(timelineItem.body)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 36.0)
.padding(.vertical, 8.0)
}
}
struct StateRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
body
}
static var body: some View {
StateRoomTimelineView(timelineItem: item)
}
static let item = StateRoomTimelineItem(id: .random,
body: "Alice joined",
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: ""))
}
@@ -0,0 +1,89 @@
//
// 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 Foundation
import SwiftUI
struct StickerRoomTimelineView: View {
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: StickerRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
LoadableImage(url: timelineItem.imageURL,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) {
placeholder
}
.timelineMediaFrame(height: timelineItem.height,
aspectRatio: timelineItem.aspectRatio)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)")
}
}
private var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
}
}
}
struct StickerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(spacing: 20.0) {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .random,
body: "Some image",
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
imageURL: URL.picturesDirectory))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .random,
body: "Some other image",
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
imageURL: URL.picturesDirectory))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .random,
body: "Blurhashed image",
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
imageURL: URL.picturesDirectory,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
}
}
@@ -0,0 +1,22 @@
//
// 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.
//
// periphery: ignore - markdown protocol
protocol TextBasedRoomTimelineViewProtocol {
associatedtype TimelineItemType: TextBasedRoomTimelineItem
var timelineItem: TimelineItemType { get }
}
@@ -0,0 +1,115 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
let timelineItem: TextRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(),
boostEmojiSize: true)
} else {
FormattedBodyText(text: timelineItem.body,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(),
boostEmojiSize: true)
}
}
}
}
struct TextRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
.previewDisplayName("Bubble")
body
.environmentObject(viewModel.context)
.environment(\.layoutDirection, .rightToLeft)
.previewDisplayName("Bubble RTL")
}
static var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20.0) {
TextRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
isOutgoing: false,
senderId: "Bob"))
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
TextRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
isOutgoing: false,
senderId: "Bob"))
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
TextRoomTimelineView(timelineItem: itemWith(text: "טקסט אחר",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
TextRoomTimelineView(timelineItem: itemWith(html: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
TextRoomTimelineView(timelineItem: itemWith(html: "<ol><li>פריט ראשון</li><li>הפריט השני</li><li>פריט שלישי</li></ol>",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
}
}
}
private static func itemWith(text: String, timestamp: String, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem {
TextRoomTimelineItem(id: .random,
timestamp: timestamp,
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: text))
}
private static func itemWith(html: String, timestamp: String, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem {
let builder = AttributedStringBuilder(cacheKey: "preview", mentionBuilder: MentionBuilder())
let attributedString = builder.fromHTML(html)
return TextRoomTimelineItem(id: .random,
timestamp: timestamp,
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: senderId),
content: .init(body: "", formattedBody: attributedString))
}
}
@@ -0,0 +1,44 @@
//
// 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 TimelineStartRoomTimelineView: View {
let timelineItem: TimelineStartRoomTimelineItem
var body: some View {
Text(title)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
}
var title: String {
var text = L10n.screenRoomTimelineBeginningOfRoomNoName
if let name = timelineItem.name {
text = L10n.screenRoomTimelineBeginningOfRoom(name)
}
return text
}
}
struct TimelineStartRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let item = TimelineStartRoomTimelineItem(name: "Alice and Bob")
TimelineStartRoomTimelineView(timelineItem: item)
}
}
@@ -0,0 +1,74 @@
//
// 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 Compound
import SwiftUI
struct UnsupportedRoomTimelineView: View {
let timelineItem: UnsupportedRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
Label {
VStack(alignment: .leading, spacing: 0) {
Text("\(timelineItem.body): \(timelineItem.eventType)")
.fixedSize(horizontal: false, vertical: true)
Text(timelineItem.error)
.fixedSize(horizontal: false, vertical: true)
.font(.compound.bodySM)
}
} icon: {
CompoundIcon(\.warning, size: .small, relativeTo: .compound.bodyLG)
}
.labelStyle(RoomTimelineViewLabelStyle())
}
}
}
struct UnsupportedRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(alignment: .leading, spacing: 20.0) {
UnsupportedRoomTimelineView(timelineItem: itemWith(text: "Text",
timestamp: "Now",
isOutgoing: false,
senderId: "Bob"))
UnsupportedRoomTimelineView(timelineItem: itemWith(text: "Some other text",
timestamp: "Later",
isOutgoing: true,
senderId: "Anne"))
}
}
private static func itemWith(text: String, timestamp: String, isOutgoing: Bool, senderId: String) -> UnsupportedRoomTimelineItem {
UnsupportedRoomTimelineItem(id: .random,
body: text,
eventType: "Some Event Type",
error: "Something went wrong",
timestamp: timestamp,
isOutgoing: isOutgoing,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: senderId))
}
}
@@ -0,0 +1,107 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
struct VideoRoomTimelineView: View {
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: VideoRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
thumbnail
.timelineMediaFrame(height: timelineItem.content.height,
aspectRatio: timelineItem.content.aspectRatio)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonVideo)
}
}
@ViewBuilder
var thumbnail: some View {
if let thumbnailSource = timelineItem.content.thumbnailSource {
LoadableImage(mediaSource: thumbnailSource,
blurhash: timelineItem.content.blurhash,
imageProvider: context.imageProvider) { imageView in
imageView
.overlay { playIcon }
} placeholder: {
placeholder
}
} else {
playIcon
}
}
var playIcon: some View {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
}
var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(timelineItem.isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming)
.opacity(0.3)
ProgressView(L10n.commonLoading)
.frame(maxWidth: .infinity)
}
}
}
struct VideoRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)
}
static var body: some View {
VStack(spacing: 20.0) {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some video", duration: 21, source: nil, thumbnailSource: nil)))
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(body: "Some other video", duration: 22, source: nil, thumbnailSource: nil)))
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
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")))
}
}
}