mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
RoomScreenViewModel is now TimelineViewModel (#3157)
Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// 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 SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// The contents of the context menu shown when right clicking an item in the timeline on a Mac
|
||||
struct TimelineItemMacContextMenu: View {
|
||||
let item: RoomTimelineItemProtocol
|
||||
let actionProvider: TimelineItemMenuActionProvider
|
||||
let send: (TimelineItemMenuAction) -> Void
|
||||
|
||||
var body: some View {
|
||||
if ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
if let menuActions = actionProvider.makeActions() {
|
||||
Section {
|
||||
if !menuActions.reactions.isEmpty {
|
||||
if #available(iOS 17.0, *) {
|
||||
let reactions = (item as? EventBasedTimelineItemProtocol)?.properties.reactions ?? []
|
||||
ControlGroup {
|
||||
ForEach(menuActions.reactions, id: \.key) {
|
||||
ReactionToggle(reaction: $0, reactions: reactions) {
|
||||
send(.toggleReaction(key: $0))
|
||||
}
|
||||
}
|
||||
|
||||
Button { send(.react) } label: {
|
||||
CompoundIcon(\.reactionAdd)
|
||||
}
|
||||
}
|
||||
.controlGroupStyle(.palette)
|
||||
} else {
|
||||
Button { send(.react) } label: {
|
||||
TimelineItemMenuAction.react.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(menuActions.actions) { action in
|
||||
Button(role: action.isDestructive ? .destructive : nil) {
|
||||
send(action)
|
||||
} label: {
|
||||
action.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(menuActions.debugActions) { action in
|
||||
Button(role: action.isDestructive ? .destructive : nil) {
|
||||
send(action)
|
||||
} label: {
|
||||
action.label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A button that acts as a toggle for reacting to a message.
|
||||
private struct ReactionToggle: View {
|
||||
let reaction: TimelineItemMenuReaction
|
||||
let reactions: [AggregatedReaction]
|
||||
let action: (String) -> Void
|
||||
|
||||
var isOn: Bool {
|
||||
reactions.contains { $0.key == reaction.key && $0.isHighlighted }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button { action(reaction.key) } label: {
|
||||
Image(systemSymbol: reaction.symbol)
|
||||
.symbolVariant(isOn ? .fill : .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//
|
||||
// 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 TimelineItemMenu: View {
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var reactionsFrame = CGRect.zero
|
||||
|
||||
let item: EventBasedTimelineItemProtocol
|
||||
let actions: TimelineItemMenuActions
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
messagePreview
|
||||
.frame(idealWidth: 300.0)
|
||||
|
||||
Divider()
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0.0) {
|
||||
if !actions.reactions.isEmpty {
|
||||
reactionsSection
|
||||
.padding(.top, 4.0)
|
||||
.padding(.bottom, 8.0)
|
||||
|
||||
Divider()
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
}
|
||||
|
||||
if !actions.actions.isEmpty {
|
||||
viewsForActions(actions.actions)
|
||||
|
||||
Divider()
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
}
|
||||
|
||||
viewsForActions(actions.debugActions)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.timelineItemActionMenu)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationBackground(Color.compound.bgCanvasDefault)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var messagePreview: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .top, spacing: 0.0) {
|
||||
LoadableAvatarImage(url: item.sender.avatarURL,
|
||||
name: item.sender.displayName,
|
||||
contentID: item.sender.id,
|
||||
avatarSize: .user(on: .timeline),
|
||||
imageProvider: context.imageProvider)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer(minLength: 8.0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.sender.displayName ?? item.sender.id)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
Text(item.timelineMenuDescription)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 16.0)
|
||||
|
||||
Text(item.timestamp)
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
if let authenticity = item.properties.encryptionAuthenticity {
|
||||
Label(authenticity.message, icon: authenticity.icon, iconSize: .small, relativeTo: .compound.bodySMSemibold)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundStyle(authenticity.foregroundStyle)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 32.0)
|
||||
.padding(.bottom, 4.0)
|
||||
}
|
||||
|
||||
private var reactionsSection: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
ForEach(actions.reactions, id: \.key) {
|
||||
reactionButton(for: $0.key)
|
||||
}
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
// Otherwise we get errors that a sheet is already presented
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
context.send(viewAction: .displayEmojiPicker(itemID: item.id))
|
||||
}
|
||||
} label: {
|
||||
CompoundIcon(\.reactionAdd, size: .medium, relativeTo: .compound.headingLG)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.padding(10)
|
||||
}
|
||||
.accessibilityLabel(L10n.actionReact)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(minWidth: reactionsFrame.width, maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||||
.readFrame($reactionsFrame)
|
||||
}
|
||||
|
||||
private func reactionButton(for emoji: String) -> some View {
|
||||
Button {
|
||||
feedbackGenerator.impactOccurred()
|
||||
dismiss()
|
||||
context.send(viewAction: .toggleReaction(key: emoji, itemID: item.id))
|
||||
} label: {
|
||||
Text(emoji)
|
||||
.font(.compound.headingLG)
|
||||
.padding(8)
|
||||
.background(Circle()
|
||||
.foregroundColor(reactionBackgroundColor(for: emoji)))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func reactionBackgroundColor(for emoji: String) -> Color {
|
||||
if let reaction = item.properties.reactions.first(where: { $0.key == emoji }),
|
||||
reaction.isHighlighted {
|
||||
return .compound.bgActionPrimaryRest
|
||||
} else {
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
|
||||
private func viewsForActions(_ actions: [TimelineItemMenuAction]) -> some View {
|
||||
ForEach(actions, id: \.self) { action in
|
||||
Button(role: action.isDestructive ? .destructive : nil) {
|
||||
send(action)
|
||||
} label: {
|
||||
action.label
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ action: TimelineItemMenuAction) {
|
||||
dismiss()
|
||||
// Otherwise we might get errors that a sheet is already presented
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension EncryptionAuthenticity {
|
||||
var foregroundStyle: SwiftUI.Color {
|
||||
switch color {
|
||||
case .red: .compound.textCriticalPrimary
|
||||
case .gray: .compound.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let (item, actions) = makeItem()
|
||||
static let (backupItem, _) = makeItem(authenticity: .notGuaranteed(color: .gray))
|
||||
static let (unsignedItem, _) = makeItem(authenticity: .unsignedDevice(color: .red))
|
||||
static let (unencryptedItem, _) = makeItem(authenticity: .sentInClear(color: .red))
|
||||
|
||||
static var previews: some View {
|
||||
TimelineItemMenu(item: item, actions: actions)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("With button shapes off")
|
||||
|
||||
TimelineItemMenu(item: item, actions: actions)
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\._accessibilityShowButtonShapes, true)
|
||||
.previewDisplayName("With button shapes on")
|
||||
|
||||
TimelineItemMenu(item: backupItem, actions: actions)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Authenticity not guaranteed")
|
||||
|
||||
TimelineItemMenu(item: unsignedItem, actions: actions)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Unsigned")
|
||||
|
||||
TimelineItemMenu(item: unencryptedItem, actions: actions)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Unencrypted")
|
||||
}
|
||||
|
||||
static func makeItem(authenticity: EncryptionAuthenticity? = nil) -> (TextRoomTimelineItem, TimelineItemMenuActions)! {
|
||||
guard var item = RoomTimelineItemFixtures.singleMessageChunk.first as? TextRoomTimelineItem,
|
||||
let actions = TimelineItemMenuActions(isReactable: true,
|
||||
actions: [.copy, .edit, .reply(isThread: false), .pin, .redact],
|
||||
debugActions: [.viewSource]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let authenticity {
|
||||
item.properties.encryptionAuthenticity = authenticity
|
||||
}
|
||||
|
||||
return (item, actions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// 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 SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineItemMenuActions {
|
||||
let reactions: [TimelineItemMenuReaction]
|
||||
let actions: [TimelineItemMenuAction]
|
||||
let debugActions: [TimelineItemMenuAction]
|
||||
|
||||
init?(isReactable: Bool, actions: [TimelineItemMenuAction], debugActions: [TimelineItemMenuAction]) {
|
||||
if !isReactable, actions.isEmpty, debugActions.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.actions = actions
|
||||
self.debugActions = debugActions
|
||||
reactions = if isReactable {
|
||||
[
|
||||
.init(key: "👍️", symbol: .handThumbsup),
|
||||
.init(key: "👎️", symbol: .handThumbsdown),
|
||||
.init(key: "🔥", symbol: .flame),
|
||||
.init(key: "❤️", symbol: .heart),
|
||||
.init(key: "👏", symbol: .handsClap)
|
||||
]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemMenuReaction {
|
||||
let key: String
|
||||
let symbol: SFSymbol
|
||||
}
|
||||
|
||||
enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case copy
|
||||
case edit
|
||||
case copyPermalink
|
||||
case redact
|
||||
case reply(isThread: Bool)
|
||||
case forward(itemID: TimelineItemIdentifier)
|
||||
case viewSource
|
||||
case retryDecryption(sessionID: String)
|
||||
case report
|
||||
case react
|
||||
case toggleReaction(key: String)
|
||||
case endPoll(pollStartID: String)
|
||||
case pin
|
||||
case unpin
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
/// Whether the item should cancel a reply/edit occurring in the composer.
|
||||
var switchToDefaultComposer: Bool {
|
||||
switch self {
|
||||
case .reply, .edit:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the action should be shown for an item that failed to send.
|
||||
var canAppearInFailedEcho: Bool {
|
||||
switch self {
|
||||
case .copy, .edit, .redact, .viewSource:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the action should be shown for a redacted item.
|
||||
var canAppearInRedacted: Bool {
|
||||
switch self {
|
||||
case .viewSource:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the action is destructive.
|
||||
var isDestructive: Bool {
|
||||
switch self {
|
||||
case .redact, .report:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// The action's label.
|
||||
@ViewBuilder
|
||||
var label: some View {
|
||||
switch self {
|
||||
case .copy:
|
||||
Label(L10n.actionCopy, icon: \.copy)
|
||||
case .edit:
|
||||
Label(L10n.actionEdit, icon: \.edit)
|
||||
case .copyPermalink:
|
||||
Label(L10n.actionCopyLinkToMessage, icon: \.link)
|
||||
case .reply(let isThread):
|
||||
Label(isThread ? L10n.actionReplyInThread : L10n.actionReply, icon: \.reply)
|
||||
case .forward:
|
||||
Label(L10n.actionForward, icon: \.forward)
|
||||
case .redact:
|
||||
Label(L10n.actionRemove, icon: \.delete)
|
||||
case .viewSource:
|
||||
Label(L10n.actionViewSource, icon: \.code)
|
||||
case .retryDecryption:
|
||||
Label(L10n.actionRetryDecryption, systemImage: "arrow.down.message")
|
||||
case .report:
|
||||
Label(L10n.actionReportContent, icon: \.chatProblem)
|
||||
case .react:
|
||||
Label(L10n.actionReact, icon: \.reactionAdd)
|
||||
case .toggleReaction:
|
||||
// Unused label - manually created in TimelineItemMacContextMenu.
|
||||
Label(L10n.actionReact, icon: \.reactionAdd)
|
||||
case .endPoll:
|
||||
Label(L10n.actionEndPoll, icon: \.pollsEnd)
|
||||
case .pin:
|
||||
Label(L10n.actionPin, icon: \.pin)
|
||||
case .unpin:
|
||||
Label(L10n.actionUnpin, icon: \.unpin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
struct TimelineItemMenuActionProvider {
|
||||
let timelineItem: RoomTimelineItemProtocol
|
||||
let canCurrentUserRedactSelf: Bool
|
||||
let canCurrentUserRedactOthers: Bool
|
||||
let canCurrentUserPin: Bool
|
||||
let pinnedEventIDs: Set<String>
|
||||
let isDM: Bool
|
||||
let isViewSourceEnabled: Bool
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func makeActions() -> TimelineItemMenuActions? {
|
||||
guard let item = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
// Don't show a context menu for non-event based items.
|
||||
return nil
|
||||
}
|
||||
|
||||
if timelineItem is StateRoomTimelineItem {
|
||||
// Don't show a context menu for state events.
|
||||
return nil
|
||||
}
|
||||
|
||||
var debugActions: [TimelineItemMenuAction] = []
|
||||
if isViewSourceEnabled {
|
||||
debugActions.append(.viewSource)
|
||||
}
|
||||
|
||||
if let encryptedItem = timelineItem as? EncryptedRoomTimelineItem {
|
||||
switch encryptedItem.encryptionType {
|
||||
case .megolmV1AesSha2(let sessionID, _):
|
||||
debugActions.append(.retryDecryption(sessionID: sessionID))
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return .init(isReactable: false, actions: [.copyPermalink], debugActions: debugActions)
|
||||
}
|
||||
|
||||
var actions: [TimelineItemMenuAction] = []
|
||||
|
||||
if item.canBeRepliedTo {
|
||||
if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
|
||||
actions.append(.reply(isThread: messageItem.isThreaded))
|
||||
} else {
|
||||
actions.append(.reply(isThread: false))
|
||||
}
|
||||
}
|
||||
|
||||
if item.isForwardable {
|
||||
actions.append(.forward(itemID: item.id))
|
||||
}
|
||||
|
||||
if canCurrentUserPin, let eventID = item.id.eventID {
|
||||
actions.append(pinnedEventIDs.contains(eventID) ? .unpin : .pin)
|
||||
}
|
||||
|
||||
if item.isEditable {
|
||||
actions.append(.edit)
|
||||
}
|
||||
|
||||
if item.isCopyable {
|
||||
actions.append(.copy)
|
||||
}
|
||||
|
||||
if item.isRemoteMessage {
|
||||
actions.append(.copyPermalink)
|
||||
}
|
||||
|
||||
if canRedactItem(item), let poll = item.pollIfAvailable, !poll.hasEnded, let eventID = item.id.eventID {
|
||||
actions.append(.endPoll(pollStartID: eventID))
|
||||
}
|
||||
|
||||
if canRedactItem(item) {
|
||||
actions.append(.redact)
|
||||
}
|
||||
|
||||
if !item.isOutgoing {
|
||||
actions.append(.report)
|
||||
}
|
||||
|
||||
if item.hasFailedToSend {
|
||||
actions = actions.filter(\.canAppearInFailedEcho)
|
||||
}
|
||||
|
||||
if item.isRedacted {
|
||||
actions = actions.filter(\.canAppearInRedacted)
|
||||
}
|
||||
|
||||
return .init(isReactable: item.isReactable, actions: actions, debugActions: debugActions)
|
||||
}
|
||||
|
||||
private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool {
|
||||
item.isOutgoing ? canCurrentUserRedactSelf : canCurrentUserRedactOthers && !isDM
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// 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 PollOptionView: View {
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
let pollOption: Poll.Option
|
||||
let showVotes: Bool
|
||||
let isFinalResult: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Image(systemSymbol: pollOption.isSelected ? .checkmarkCircleFill : .circle)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(pollOption.isSelected && isEnabled ? .compound.iconPrimary : .compound.iconTertiary)
|
||||
.accessibilityAddTraits(pollOption.isSelected ? .isSelected : [])
|
||||
|
||||
VStack(spacing: 10) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 8) {
|
||||
Text(pollOption.text)
|
||||
.font(isFinalWinningOption ? .compound.bodyLGSemibold : .compound.bodyLG)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if showVotes {
|
||||
if isFinalWinningOption {
|
||||
HStack(spacing: 4) {
|
||||
CompoundIcon(asset: Asset.Images.pollWinner)
|
||||
.foregroundColor(.compound.iconAccentTertiary)
|
||||
|
||||
Text(L10n.commonPollVotesCount(pollOption.votes))
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
}
|
||||
} else {
|
||||
Text(L10n.commonPollVotesCount(pollOption.votes))
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PollProgressView(progress: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var progress: Double {
|
||||
switch (showVotes, pollOption.allVotes, pollOption.isSelected) {
|
||||
case (true, let allVotes, _) where allVotes > 0:
|
||||
return Double(pollOption.votes) / Double(allVotes)
|
||||
case (false, _, true):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private var isFinalWinningOption: Bool {
|
||||
pollOption.isWinning && isFinalResult
|
||||
}
|
||||
}
|
||||
|
||||
private struct PollProgressView: View {
|
||||
let progress: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.foregroundColor(.compound._bgEmptyItemAlpha)
|
||||
|
||||
Capsule()
|
||||
.frame(maxWidth: progress * geometry.size.width)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct PollOptionView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 8) {
|
||||
Group {
|
||||
PollOptionView(pollOption: .init(id: "1",
|
||||
text: "Italian 🇮🇹",
|
||||
votes: 1,
|
||||
allVotes: 10,
|
||||
isSelected: true,
|
||||
isWinning: false),
|
||||
showVotes: false,
|
||||
isFinalResult: false)
|
||||
|
||||
PollOptionView(pollOption: .init(id: "2",
|
||||
text: "Chinese 🇨🇳",
|
||||
votes: 9,
|
||||
allVotes: 10,
|
||||
isSelected: false,
|
||||
isWinning: true),
|
||||
showVotes: true,
|
||||
isFinalResult: false)
|
||||
|
||||
PollOptionView(pollOption: .init(id: "2",
|
||||
text: "Chinese 🇨🇳",
|
||||
votes: 9,
|
||||
allVotes: 10,
|
||||
isSelected: false,
|
||||
isWinning: true),
|
||||
showVotes: true,
|
||||
isFinalResult: true)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
enum PollViewAction {
|
||||
case selectOption(optionID: String)
|
||||
case edit
|
||||
case end
|
||||
}
|
||||
|
||||
struct PollView: View {
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
|
||||
let poll: Poll
|
||||
let editable: Bool
|
||||
let actionHandler: (PollViewAction) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
questionView
|
||||
optionsView
|
||||
summaryView
|
||||
toolbarView
|
||||
}
|
||||
.frame(maxWidth: 450)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var questionView: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
CompoundIcon(poll.hasEnded ? \.pollsEnd : \.polls,
|
||||
size: .custom(22),
|
||||
relativeTo: .compound.bodyLGSemibold)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(poll.question)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionsView: some View {
|
||||
ForEach(poll.options, id: \.id) { option in
|
||||
Button {
|
||||
guard !option.isSelected else { return }
|
||||
actionHandler(.selectOption(optionID: option.id))
|
||||
feedbackGenerator.impactOccurred()
|
||||
} label: {
|
||||
PollOptionView(pollOption: option,
|
||||
showVotes: showVotes,
|
||||
isFinalResult: poll.hasEnded)
|
||||
.foregroundColor(progressBarColor(for: option))
|
||||
}
|
||||
.disabled(poll.hasEnded)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var summaryView: some View {
|
||||
if let summaryText = poll.summaryText {
|
||||
Text(summaryText)
|
||||
.font(.compound.bodySM)
|
||||
.scaledPadding(.leading, showVotes ? 0 : 32)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: showVotes ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbarView: some View {
|
||||
if !poll.hasEnded, poll.createdByAccountOwner {
|
||||
Button {
|
||||
toolbarAction()
|
||||
} label: {
|
||||
Text(editable ? L10n.actionEditPoll : L10n.actionEndPoll)
|
||||
.lineLimit(2, reservesSpace: false)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textOnSolidPrimary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
Capsule()
|
||||
.foregroundColor(.compound.bgActionPrimaryRest)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarAction() {
|
||||
if editable {
|
||||
actionHandler(.edit)
|
||||
} else {
|
||||
actionHandler(.end)
|
||||
}
|
||||
}
|
||||
|
||||
private func progressBarColor(for option: Poll.Option) -> Color {
|
||||
if poll.hasEnded {
|
||||
return option.isWinning ? .compound.textActionAccent : .compound.textDisabled
|
||||
} else {
|
||||
return .compound.textPrimary
|
||||
}
|
||||
}
|
||||
|
||||
private var showVotes: Bool {
|
||||
poll.hasEnded || poll.kind == .disclosed
|
||||
}
|
||||
}
|
||||
|
||||
private extension Poll {
|
||||
var summaryText: String? {
|
||||
guard !hasEnded else {
|
||||
return options.first.map {
|
||||
L10n.commonPollTotalVotes($0.allVotes)
|
||||
}
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .disclosed:
|
||||
return options.first.map {
|
||||
L10n.commonPollTotalVotes($0.allVotes)
|
||||
}
|
||||
case .undisclosed:
|
||||
return L10n.commonPollUndisclosedText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PollView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
PollView(poll: .disclosed(), editable: false) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Disclosed")
|
||||
|
||||
PollView(poll: .undisclosed(), editable: false) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Undisclosed")
|
||||
|
||||
PollView(poll: .endedDisclosed, editable: false) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Ended, Disclosed")
|
||||
|
||||
PollView(poll: .endedUndisclosed, editable: false) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Ended, Undisclosed")
|
||||
|
||||
PollView(poll: .disclosed(createdByAccountOwner: true), editable: true) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Creator, disclosed")
|
||||
|
||||
PollView(poll: .emptyDisclosed, editable: true) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Creator, no votes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// 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 ReadReceiptCell: View {
|
||||
let readReceipt: ReadReceipt
|
||||
let memberState: RoomMemberState?
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
|
||||
private var title: String {
|
||||
memberState?.displayName ?? readReceipt.userID
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
guard title != readReceipt.userID else {
|
||||
return ""
|
||||
}
|
||||
return readReceipt.userID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
LoadableAvatarImage(url: memberState?.avatarURL,
|
||||
name: memberState?.displayName,
|
||||
contentID: readReceipt.userID,
|
||||
avatarSize: .user(on: .readReceiptSheet),
|
||||
imageProvider: imageProvider)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Text(title)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let formattedTimestamp = readReceipt.formattedTimestamp {
|
||||
Text(formattedTimestamp)
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadReceiptCell_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
ReadReceiptCell(readReceipt: .init(userID: "@test:matrix.org",
|
||||
formattedTimestamp: "10:00"),
|
||||
memberState: .init(displayName: "Test",
|
||||
avatarURL: nil),
|
||||
imageProvider: MockMediaProvider())
|
||||
.previewDisplayName("No Image")
|
||||
ReadReceiptCell(readReceipt: .init(userID: "@test:matrix.org",
|
||||
formattedTimestamp: "10:00"),
|
||||
memberState: .init(displayName: "Test",
|
||||
avatarURL: URL.documentsDirectory),
|
||||
imageProvider: MockMediaProvider())
|
||||
.previewDisplayName("With Image")
|
||||
ReadReceiptCell(readReceipt: .init(userID: "@test:matrix.org",
|
||||
formattedTimestamp: "10:00"),
|
||||
memberState: nil,
|
||||
imageProvider: MockMediaProvider())
|
||||
.previewDisplayName("Loading Member")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// 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 ReadReceiptsSummaryView: View {
|
||||
let orderedReadReceipts: [ReadReceipt]
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(L10n.commonSeenBy)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(orderedReadReceipts) { receipt in
|
||||
ReadReceiptCell(readReceipt: receipt,
|
||||
memberState: context.viewState.members[receipt.userID],
|
||||
imageProvider: context.imageProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationBackground(Color.compound.bgCanvasDefault)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = {
|
||||
let members: [RoomMemberProxyMock] = [
|
||||
.mockAlice,
|
||||
.mockBob,
|
||||
.mockCharlie,
|
||||
.mockDan
|
||||
]
|
||||
let roomProxyMock = RoomProxyMock(.init(name: "Room", members: members))
|
||||
let mock = TimelineViewModel(roomProxy: roomProxyMock,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
return mock
|
||||
}()
|
||||
|
||||
static let orderedReadReceipts: [ReadReceipt] = [
|
||||
.init(userID: "@alice:matrix.org", formattedTimestamp: "10:00"),
|
||||
.init(userID: "@bob:matrix.org", formattedTimestamp: "9:30"),
|
||||
.init(userID: "@charlie:matrix.org", formattedTimestamp: "9:00"),
|
||||
.init(userID: "@dan:matrix.org", formattedTimestamp: "8:30"),
|
||||
.init(userID: "@loading:matrix.org", formattedTimestamp: "Long time ago")
|
||||
]
|
||||
|
||||
static var previews: some View {
|
||||
ReadReceiptsSummaryView(orderedReadReceipts: orderedReadReceipts)
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
enum TimelineReplyViewPlacement {
|
||||
case timeline
|
||||
case composer
|
||||
}
|
||||
|
||||
struct TimelineReplyView: View {
|
||||
let placement: TimelineReplyViewPlacement
|
||||
let timelineItemReplyDetails: TimelineItemReplyDetails?
|
||||
|
||||
var body: some View {
|
||||
if let timelineItemReplyDetails {
|
||||
switch timelineItemReplyDetails {
|
||||
case .loaded(let sender, _, let content):
|
||||
switch content {
|
||||
case .message(let content):
|
||||
switch content {
|
||||
case .audio(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .systemIcon("waveform"), cornerRadii: iconCornerRadii))
|
||||
case .emote(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: content.formattedBody)
|
||||
case .file(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .icon(\.document), cornerRadii: iconCornerRadii))
|
||||
case .image(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .mediaSource(content.thumbnailSource ?? content.source), cornerRadii: iconCornerRadii))
|
||||
case .notice(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: content.formattedBody)
|
||||
case .text(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: content.formattedBody)
|
||||
case .video(let content):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: nil,
|
||||
icon: content.thumbnailSource.map { .init(kind: .mediaSource($0), cornerRadii: iconCornerRadii) })
|
||||
case .voice:
|
||||
ReplyView(sender: sender,
|
||||
plainBody: L10n.commonVoiceMessage,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .icon(\.micOn), cornerRadii: iconCornerRadii))
|
||||
case .location:
|
||||
ReplyView(sender: sender,
|
||||
plainBody: L10n.commonSharedLocation,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .icon(\.locationPin), cornerRadii: iconCornerRadii))
|
||||
}
|
||||
case .poll(let question):
|
||||
ReplyView(sender: sender,
|
||||
plainBody: question,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .icon(\.polls), cornerRadii: iconCornerRadii))
|
||||
case .redacted:
|
||||
ReplyView(sender: sender,
|
||||
plainBody: L10n.commonMessageRemoved,
|
||||
formattedBody: nil,
|
||||
icon: .init(kind: .icon(\.delete), cornerRadii: iconCornerRadii))
|
||||
}
|
||||
default:
|
||||
LoadingReplyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var iconCornerRadii: Double {
|
||||
switch placement {
|
||||
case .composer:
|
||||
return 9.0
|
||||
case .timeline:
|
||||
return 4.0
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoadingReplyView: View {
|
||||
var body: some View {
|
||||
ReplyView(sender: .init(id: "@alice:matrix.org"), plainBody: "Hello world", formattedBody: nil)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReplyView: View {
|
||||
struct Icon {
|
||||
enum Kind {
|
||||
case mediaSource(MediaSourceProxy)
|
||||
case systemIcon(String)
|
||||
case iconAsset(ImageAsset)
|
||||
case icon(KeyPath<CompoundIcons, Image>)
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let cornerRadii: Double
|
||||
}
|
||||
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
@ScaledMetric private var imageContainerSize = 36.0
|
||||
|
||||
let sender: TimelineItemSender
|
||||
let plainBody: String
|
||||
let formattedBody: AttributedString?
|
||||
|
||||
var icon: Icon?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
iconView
|
||||
.frame(width: imageContainerSize, height: imageContainerSize)
|
||||
.foregroundColor(.compound.iconPrimary)
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
.cornerRadius(icon?.cornerRadii ?? 0.0, corners: .allCorners)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sender.displayName ?? sender.id)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.accessibilityLabel(L10n.commonInReplyTo(sender.displayName ?? sender.id))
|
||||
|
||||
Text(messagePreview)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.tint(.compound.textLinkExternal)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.leading, icon == nil ? 8 : 0)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iconView: some View {
|
||||
if let icon {
|
||||
switch icon.kind {
|
||||
case .mediaSource(let mediaSource):
|
||||
LoadableImage(mediaSource: mediaSource,
|
||||
size: .init(width: imageContainerSize,
|
||||
height: imageContainerSize),
|
||||
imageProvider: context.imageProvider) {
|
||||
Image(systemName: "photo")
|
||||
.padding(4.0)
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .systemIcon(let systemIconName):
|
||||
Image(systemName: systemIconName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(8.0)
|
||||
case .iconAsset(let asset):
|
||||
Image(asset: asset)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(8.0)
|
||||
case .icon(let keyPath):
|
||||
CompoundIcon(keyPath, size: .small, relativeTo: .body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The string shown as the message preview.
|
||||
///
|
||||
/// This converts the formatted body to a plain string to remove formatting
|
||||
/// and render with a consistent font size. This conversion is done to avoid
|
||||
/// showing markdown characters in the preview for messages with formatting.
|
||||
private var messagePreview: String {
|
||||
guard let formattedBody,
|
||||
let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else {
|
||||
return plainBody
|
||||
}
|
||||
|
||||
let range = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: range) { attributes, range, _ in
|
||||
if let userID = attributes[.MatrixUserID] as? String {
|
||||
if let displayName = context.viewState.members[userID]?.displayName {
|
||||
attributedString.replaceCharacters(in: range, with: "@\(displayName)")
|
||||
} else {
|
||||
attributedString.replaceCharacters(in: range, with: userID)
|
||||
}
|
||||
}
|
||||
|
||||
if attributes[.MatrixAllUsersMention] as? Bool == true {
|
||||
attributedString.replaceCharacters(in: range, with: PillConstants.atRoom)
|
||||
}
|
||||
}
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static let attributedStringWithMention = {
|
||||
var attributedString = AttributedString("To be replaced")
|
||||
attributedString.userID = "@alice:matrix.org"
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static let attributedStringWithAtRoomMention = {
|
||||
var attributedString = AttributedString("to be replaced")
|
||||
attributedString.allUsersMention = true
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static var previewItems: [TimelineReplyView] {
|
||||
let imageSource = MediaSourceProxy(url: "https://mock.com", mimeType: "image/png")
|
||||
|
||||
return [
|
||||
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .notLoaded(eventID: "")),
|
||||
|
||||
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: .loading(eventID: "")),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "This is a reply"))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.emote(.init(body: "says hello"))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.notice(.init(body: "Hello world"))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.audio(.init(body: "Some audio",
|
||||
duration: 0,
|
||||
waveform: nil,
|
||||
source: nil,
|
||||
contentType: nil))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.file(.init(body: "Some file",
|
||||
source: nil,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.image(.init(body: "Some image",
|
||||
source: imageSource,
|
||||
thumbnailSource: imageSource))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.video(.init(body: "Some video",
|
||||
duration: 0,
|
||||
source: nil,
|
||||
thumbnailSource: imageSource))))),
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.location(.init(body: ""))))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.voice(.init(body: "Some voice message",
|
||||
duration: 0,
|
||||
waveform: nil,
|
||||
source: nil,
|
||||
contentType: nil))))),
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))))),
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))))),
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
|
||||
eventID: "123",
|
||||
eventContent: .poll(question: "Do you like polls?"))),
|
||||
|
||||
TimelineReplyView(placement: .timeline,
|
||||
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Bob"),
|
||||
eventID: "123",
|
||||
eventContent: .redacted))
|
||||
]
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
ForEach(0..<previewItems.count, id: \.self) { index in
|
||||
previewItems[index]
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.environmentObject(viewModel.context)
|
||||
// Allow member names to load. Reduce precission as the `imageSource` randomly renders slightly differently
|
||||
.snapshot(delay: 0.2, precision: 0.98)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// 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 LongPressWithFeedback: ViewModifier {
|
||||
let action: () -> Void
|
||||
|
||||
@State private var triggerTask: Task<Void, Never>?
|
||||
@State private var isLongPressing = false
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.compositingGroup() // Apply the shadow to the view as a whole.
|
||||
.shadow(color: .black.opacity(isLongPressing ? 0.2 : 0.0), radius: isLongPressing ? 12 : 0)
|
||||
.shadow(color: .black.opacity(isLongPressing ? 0.1 : 0.0), radius: isLongPressing ? 3 : 0)
|
||||
.scaleEffect(x: isLongPressing ? 1.05 : 1,
|
||||
y: isLongPressing ? 1.05 : 1)
|
||||
.animation(.spring(response: 0.7).delay(isLongPressing ? 0.1 : 0).disabledDuringTests(),
|
||||
value: isLongPressing)
|
||||
// The minimum duration here doesn't actually invoke the perform block when elapsed (thus
|
||||
// the implementation below) but it does cancel other system gestures e.g. swipe to reply
|
||||
.onLongPressGesture(minimumDuration: 0.25) { } onPressingChanged: { isPressing in
|
||||
isLongPressing = isPressing
|
||||
|
||||
guard isPressing else {
|
||||
triggerTask?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
feedbackGenerator.prepare()
|
||||
|
||||
triggerTask = Task {
|
||||
// The wait time needs to be at least 0.5 seconds or the long press gesture will take precedence over long pressing links.
|
||||
try? await Task.sleep(for: .seconds(0.5))
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
action()
|
||||
feedbackGenerator.impactOccurred()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func longPressWithFeedback(action: @escaping () -> Void) -> some View {
|
||||
modifier(LongPressWithFeedback(action: action))
|
||||
}
|
||||
}
|
||||
|
||||
struct LongPressWithFeedback_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View { Preview() }
|
||||
|
||||
struct Preview: View {
|
||||
private let viewModel = TimelineViewModel.mock
|
||||
@State private var isPresentingSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
mockBubble("This is a message from somebody with a couple of lines of text.")
|
||||
.longPressWithFeedback { isPresentingSheet = true }
|
||||
|
||||
mockBubble("Short message")
|
||||
.longPressWithFeedback { isPresentingSheet = true }
|
||||
|
||||
mockBubble("How are you today? The sun is shining here and its very hot ☀️☀️☀️")
|
||||
.longPressWithFeedback { isPresentingSheet = true }
|
||||
|
||||
mockBubble("I'm a fake!")
|
||||
.contextMenu {
|
||||
Button("Copy") { }
|
||||
Button("Reply") { }
|
||||
Button("Remove") { }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Work chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.sheet(isPresented: $isPresentingSheet) {
|
||||
Text("Long pressed!")
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
func mockBubble(_ body: String) -> some View {
|
||||
Text(body)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.compound._bgBubbleOutgoing, in: RoundedRectangle(cornerRadius: 12))
|
||||
.contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: 12))
|
||||
.onTapGesture { /* Fix long press gesture blocking the scroll view */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// 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 SwipeToReplyView: View {
|
||||
let timelineItem: RoomTimelineItemProtocol
|
||||
|
||||
var body: some View {
|
||||
CompoundIcon(\.reply)
|
||||
.foregroundColor(.compound.iconPrimary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct SwipeToReplyView_Previews: PreviewProvider, TestablePreview {
|
||||
static let timelineItem = TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "",
|
||||
isOutgoing: true,
|
||||
isEditable: true,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: ""),
|
||||
content: .init(body: ""))
|
||||
|
||||
static var previews: some View {
|
||||
SwipeToReplyView(timelineItem: timelineItem)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// 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, size: .xSmall, relativeTo: .compound.bodyXS)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
}
|
||||
.labelStyle(.custom(spacing: 4))
|
||||
}
|
||||
}
|
||||
|
||||
struct ThreadDecorator_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
ThreadDecorator()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// A custom layout used for quotes and content when using the bubbles timeline style.
|
||||
///
|
||||
/// A custom layout is required as the embedded quote bubbles should fill the entire width of
|
||||
/// the message bubble, without causing the width of the bubble to fill all of the available space.
|
||||
struct TimelineBubbleLayout: Layout {
|
||||
struct Cache {
|
||||
var sizes = [Int: [ProposedViewSize: CGSize]]()
|
||||
}
|
||||
|
||||
/// The spacing between the components in the bubble.
|
||||
let spacing: CGFloat
|
||||
|
||||
/// Layout priority constants for the bubble content. These priorities are abused within
|
||||
/// `TimelineBubbleLayout` to create the layout we would like. They aren't
|
||||
/// used in the expected way that SwiftUI would normally use layout priorities.
|
||||
enum Priority {
|
||||
/// The priority of hidden quote bubbles that are only used for layout calculations.
|
||||
static let hiddenQuote: Double = -1
|
||||
/// The priority of visible quote bubbles that are placed in the view with a full width.
|
||||
static let visibleQuote: Double = 0
|
||||
/// The priority of regular text that is used for layout calculations and placed in the view.
|
||||
static let regularText: Double = 1
|
||||
}
|
||||
|
||||
func makeCache(subviews: Subviews) -> Cache {
|
||||
Cache()
|
||||
}
|
||||
|
||||
func updateCache(_ cache: inout Cache, subviews: Subviews) {
|
||||
// A subview changed, reset everything
|
||||
cache = Cache()
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
|
||||
guard !subviews.isEmpty else { return .zero }
|
||||
|
||||
// Calculate the natural size using the regular text and non-greedy quote bubbles.
|
||||
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleQuote }
|
||||
|
||||
let subviewSizes = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache) }
|
||||
|
||||
let maxWidth = subviewSizes.map(\.width).reduce(0, max)
|
||||
let totalHeight = subviewSizes.map(\.height).reduce(0, +)
|
||||
let totalSpacing = CGFloat(layoutSubviews.count - 1) * spacing
|
||||
|
||||
return CGSize(width: maxWidth, height: totalHeight + totalSpacing)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
|
||||
guard !subviews.isEmpty else { return }
|
||||
|
||||
// Calculate the width using the regular text and the non-greedy quote bubbles.
|
||||
let layoutSubviews = subviews.filter { $0.priority != Priority.visibleQuote }
|
||||
let maxWidth = layoutSubviews.map { size(for: $0, subviews: subviews, proposedSize: proposal, cache: &cache).width }.reduce(0, max)
|
||||
|
||||
// Place the regular text and greedy quote bubbles using the calculated width.
|
||||
let visibleSubviews = subviews.filter { $0.priority != Priority.hiddenQuote }
|
||||
|
||||
let subviewSizes = visibleSubviews.map { size(for: $0, subviews: subviews, proposedSize: ProposedViewSize(width: maxWidth, height: proposal.height), cache: &cache) }
|
||||
|
||||
var y = bounds.minY
|
||||
for index in visibleSubviews.indices {
|
||||
let height = subviewSizes[index].height
|
||||
visibleSubviews[index].place(at: CGPoint(x: bounds.minX, y: y),
|
||||
anchor: .topLeading,
|
||||
proposal: ProposedViewSize(width: maxWidth, height: height))
|
||||
y += height + spacing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func size(for subview: LayoutSubview, subviews: LayoutSubviews, proposedSize: ProposedViewSize, cache: inout Cache) -> CGSize {
|
||||
guard let index = subviews.firstIndex(of: subview) else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
if cache.sizes[index] == nil {
|
||||
cache.sizes[index] = [:]
|
||||
}
|
||||
|
||||
if let cachedSize = cache.sizes[index]?[proposedSize] {
|
||||
return cachedSize
|
||||
}
|
||||
|
||||
let size = subview.sizeThatFits(proposedSize)
|
||||
|
||||
cache.sizes[index]?[proposedSize] = size
|
||||
return size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
private struct TimelineItemAccessibilityModifier: ViewModifier {
|
||||
let timelineItem: RoomTimelineItemProtocol
|
||||
let action: () -> Void
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
switch timelineItem {
|
||||
case is PollRoomTimelineItem, is VoiceMessageRoomTimelineItem:
|
||||
content
|
||||
.accessibilityActions {
|
||||
Button(L10n.commonMessageActions) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
case let timelineItem as EventBasedTimelineItemProtocol:
|
||||
content
|
||||
.accessibilityRepresentation {
|
||||
VStack(spacing: 8) {
|
||||
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
|
||||
content
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityActions {
|
||||
Button(L10n.commonMessageActions) {
|
||||
action()
|
||||
}
|
||||
}
|
||||
default:
|
||||
content
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func timelineItemAccessibility(_ timelineItem: RoomTimelineItemProtocol, action: @escaping () -> Void) -> some View {
|
||||
modifier(TimelineItemAccessibilityModifier(timelineItem: timelineItem, action: action))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//
|
||||
// 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 TimelineItemBubbledStylerView<Content: View>: View {
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
|
||||
@Environment(\.focussedEventID) private var focussedEventID
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom }
|
||||
private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID }
|
||||
|
||||
/// The base padding applied to bubbles on either side.
|
||||
///
|
||||
/// **Note:** This is on top of the insets applied to the cells by the table view.
|
||||
private let bubbleHorizontalPadding: CGFloat = 8
|
||||
/// Additional padding applied to outgoing bubbles when the avatar is shown
|
||||
private var bubbleAvatarPadding: CGFloat {
|
||||
guard !timelineItem.isOutgoing, !isEncryptedOneToOneRoom else { return 0 }
|
||||
return 8
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailingFirstTextBaseline) {
|
||||
VStack(alignment: alignment, spacing: -12) {
|
||||
if !timelineItem.isOutgoing, !isEncryptedOneToOneRoom {
|
||||
header
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
VStack(alignment: alignment, spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
if timelineItem.isOutgoing {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
messageBubbleWithReactions
|
||||
}
|
||||
.padding(timelineItem.isOutgoing ? .leading : .trailing, 48) // Additional padding to differentiate alignment.
|
||||
|
||||
HStack(spacing: 0) {
|
||||
if !timelineItem.isOutgoing {
|
||||
Spacer()
|
||||
}
|
||||
TimelineItemStatusView(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus)
|
||||
.environmentObject(context)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, bubbleHorizontalPadding)
|
||||
.padding(.leading, bubbleAvatarPadding)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8))
|
||||
.highlightedTimelineItem(isFocussed)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if shouldShowSenderDetails {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
TimelineSenderAvatarView(timelineItem: timelineItem)
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.decorativeColor(for: timelineItem.sender.id).text)
|
||||
|
||||
if timelineItem.sender.displayName != nil, timelineItem.sender.isDisplayNameAmbiguous {
|
||||
Text(timelineItem.sender.id)
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
.scaledPadding(.vertical, 3)
|
||||
}
|
||||
// sender info are read inside the `TimelineAccessibilityModifier`
|
||||
.accessibilityHidden(true)
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .displayRoomMemberDetails(userID: timelineItem.sender.id))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var messageBubbleWithReactions: some View {
|
||||
// Figma overlaps reactions by 3
|
||||
VStack(alignment: alignment, spacing: -3) {
|
||||
messageBubbleWithActions
|
||||
.timelineItemAccessibility(timelineItem) {
|
||||
context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id))
|
||||
}
|
||||
|
||||
if !timelineItem.properties.reactions.isEmpty {
|
||||
TimelineReactionsView(context: context,
|
||||
itemID: timelineItem.id,
|
||||
reactions: timelineItem.properties.reactions,
|
||||
isLayoutRTL: timelineItem.isOutgoing)
|
||||
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
||||
.onTapGesture { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var messageBubbleWithActions: some View {
|
||||
messageBubble
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .itemTapped(itemID: timelineItem.id))
|
||||
}
|
||||
// We need a tap gesture before this long one so that it doesn't
|
||||
// steal away the gestures from the scroll view
|
||||
.longPressWithFeedback {
|
||||
context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id))
|
||||
}
|
||||
.swipeRightAction {
|
||||
SwipeToReplyView(timelineItem: timelineItem)
|
||||
} shouldStartAction: {
|
||||
timelineItem.canBeRepliedTo
|
||||
} action: {
|
||||
let isThread = (timelineItem as? EventBasedMessageTimelineItemProtocol)?.isThreaded ?? false
|
||||
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: isThread)))
|
||||
}
|
||||
.contextMenu {
|
||||
let provider = TimelineItemMenuActionProvider(timelineItem: timelineItem,
|
||||
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventIDs,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled)
|
||||
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
|
||||
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
|
||||
}
|
||||
}
|
||||
.padding(.top, messageBubbleTopPadding)
|
||||
}
|
||||
|
||||
var messageBubble: some View {
|
||||
contentWithReply
|
||||
.timelineItemSendInfo(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus, context: context)
|
||||
.bubbleStyle(insets: timelineItem.bubbleInsets,
|
||||
color: timelineItem.bubbleBackgroundColor,
|
||||
corners: roundedCorners)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentWithReply: some View {
|
||||
TimelineBubbleLayout(spacing: 8) {
|
||||
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)
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .focusOnEventID(replyDetails.eventID))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
private var messageBubbleTopPadding: CGFloat {
|
||||
guard timelineItem.isOutgoing || isEncryptedOneToOneRoom else { return 0 }
|
||||
return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0
|
||||
}
|
||||
|
||||
private var alignment: HorizontalAlignment {
|
||||
timelineItem.isOutgoing ? .trailing : .leading
|
||||
}
|
||||
|
||||
private var roundedCorners: UIRectCorner {
|
||||
switch timelineGroupStyle {
|
||||
case .single:
|
||||
return .allCorners
|
||||
case .first:
|
||||
if timelineItem.isOutgoing {
|
||||
return [.topLeft, .topRight, .bottomLeft]
|
||||
} else {
|
||||
return [.topLeft, .topRight, .bottomRight]
|
||||
}
|
||||
case .middle:
|
||||
return timelineItem.isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight]
|
||||
case .last:
|
||||
if timelineItem.isOutgoing {
|
||||
return [.topLeft, .bottomLeft, .bottomRight]
|
||||
} else {
|
||||
return [.topRight, .bottomLeft, .bottomRight]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowSenderDetails: Bool {
|
||||
timelineGroupStyle.shouldShowSenderDetails
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func bubbleStyle(insets: EdgeInsets, color: Color? = nil, cornerRadius: CGFloat = 12, corners: UIRectCorner) -> some View {
|
||||
padding(insets)
|
||||
.background(color)
|
||||
.cornerRadius(cornerRadius, corners: corners)
|
||||
}
|
||||
}
|
||||
|
||||
private extension EventBasedTimelineItemProtocol {
|
||||
var bubbleBackgroundColor: Color? {
|
||||
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
|
||||
|
||||
switch self {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// The insets for the full bubble content.
|
||||
/// Padding affecting just the "send info" should be added inside `TimelineItemSendInfoView`
|
||||
var bubbleInsets: EdgeInsets {
|
||||
let defaultInsets: EdgeInsets = .init(around: 8)
|
||||
|
||||
switch self {
|
||||
case is StickerRoomTimelineItem:
|
||||
return .zero
|
||||
case is PollRoomTimelineItem:
|
||||
return .init(top: 12, leading: 12, bottom: 4, trailing: 12)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension EdgeInsets {
|
||||
init(around: CGFloat) {
|
||||
self.init(top: around, leading: around, bottom: around, trailing: around)
|
||||
}
|
||||
|
||||
static var zero: Self = .init(around: 0)
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static var previews: some View {
|
||||
mockTimeline
|
||||
.previewDisplayName("Mock Timeline")
|
||||
mockTimeline
|
||||
.environment(\.layoutDirection, .rightToLeft)
|
||||
.previewDisplayName("Mock Timeline RTL")
|
||||
replies
|
||||
.previewDisplayName("Replies")
|
||||
threads
|
||||
.previewDisplayName("Thread decorator")
|
||||
encryptionAuthenticity
|
||||
.previewDisplayName("Encryption Indicators")
|
||||
}
|
||||
|
||||
// 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,
|
||||
canBeRepliedTo: true,
|
||||
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"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))),
|
||||
groupStyle: .single))
|
||||
|
||||
AudioRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: true,
|
||||
sender: .init(id: ""),
|
||||
content: .init(body: "audio.ogg",
|
||||
duration: 100,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
source: nil,
|
||||
contentType: nil),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))))
|
||||
|
||||
FileRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: true,
|
||||
sender: .init(id: ""),
|
||||
content: .init(body: "File",
|
||||
source: nil,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))))
|
||||
ImageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: true,
|
||||
canBeRepliedTo: 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"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))))
|
||||
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: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))))
|
||||
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: nil),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))))
|
||||
|
||||
VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: true,
|
||||
sender: .init(id: ""),
|
||||
content: .init(body: "audio.ogg",
|
||||
duration: 100,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
source: nil,
|
||||
contentType: nil),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))),
|
||||
playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
static var mockTimeline: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in
|
||||
RoomTimelineItemView(viewState: viewState)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
static var replies: some View {
|
||||
VStack(spacing: 0) {
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
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"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short"))))),
|
||||
groupStyle: .single))
|
||||
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "Short message"),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout."))))),
|
||||
groupStyle: .single))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
static var encryptionAuthenticity: some View {
|
||||
VStack(spacing: 0) {
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "A long message that should be on multiple lines."),
|
||||
properties: RoomTimelineItemProperties(encryptionAuthenticity: .unsignedDevice(color: .red))),
|
||||
groupStyle: .single))
|
||||
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "A long message that should be on multiple lines."),
|
||||
properties: RoomTimelineItemProperties(isEdited: true,
|
||||
encryptionAuthenticity: .unsignedDevice(color: .red))),
|
||||
groupStyle: .single))
|
||||
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "Short message"),
|
||||
properties: RoomTimelineItemProperties(encryptionAuthenticity: .unknownDevice(color: .red))),
|
||||
groupStyle: .first))
|
||||
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "Message goes Here"),
|
||||
properties: RoomTimelineItemProperties(encryptionAuthenticity: .notGuaranteed(color: .gray))),
|
||||
groupStyle: .last))
|
||||
|
||||
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),
|
||||
|
||||
properties: RoomTimelineItemProperties(encryptionAuthenticity: .notGuaranteed(color: .gray))))
|
||||
|
||||
VoiceMessageRoomTimelineView(timelineItem: .init(id: .init(timelineID: ""),
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: true,
|
||||
sender: .init(id: ""),
|
||||
content: .init(body: "audio.ogg",
|
||||
duration: 100,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
source: nil,
|
||||
contentType: nil),
|
||||
properties: RoomTimelineItemProperties(encryptionAuthenticity: .notGuaranteed(color: .gray))),
|
||||
playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// 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 {
|
||||
/// Adds the send info (timestamp along indicators for edits and delivery/encryption issues) for the given timeline item to this view.
|
||||
func timelineItemSendInfo(timelineItem: EventBasedTimelineItemProtocol,
|
||||
adjustedDeliveryStatus: TimelineItemDeliveryStatus?,
|
||||
context: TimelineViewModel.Context) -> some View {
|
||||
modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem,
|
||||
adjustedDeliveryStatus: adjustedDeliveryStatus),
|
||||
context: context))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the send info to a view with the correct layout.
|
||||
private struct TimelineItemSendInfoModifier: ViewModifier {
|
||||
let sendInfo: TimelineItemSendInfo
|
||||
let context: TimelineViewModel.Context
|
||||
|
||||
var layout: AnyLayout {
|
||||
switch sendInfo.layoutType {
|
||||
case .horizontal(let spacing):
|
||||
AnyLayout(HStackLayout(alignment: .bottom, spacing: spacing))
|
||||
case .vertical(let spacing):
|
||||
AnyLayout(GridLayout(alignment: .leading, verticalSpacing: spacing))
|
||||
case .overlay:
|
||||
AnyLayout(ZStackLayout(alignment: .bottomTrailing))
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
layout {
|
||||
content
|
||||
|
||||
TimelineItemSendInfoLabel(sendInfo: sendInfo)
|
||||
.contentShape(.rect)
|
||||
// Tap gesture to avoid the message being detected as a button by VoiceOver
|
||||
// (and the action shows a description that is already read to the user).
|
||||
.onTapGesture {
|
||||
guard sendInfo.status != nil else { return }
|
||||
context.send(viewAction: .itemSendInfoTapped(itemID: sendInfo.itemID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The label shown for a timeline item with info about it's timestamp and various other indicators.
|
||||
private struct TimelineItemSendInfoLabel: View {
|
||||
let sendInfo: TimelineItemSendInfo
|
||||
|
||||
var statusIcon: KeyPath<CompoundIcons, Image>? {
|
||||
switch sendInfo.status {
|
||||
case .sendingFailed: \.error
|
||||
case .encryptionAuthenticity(let authenticity): authenticity.icon
|
||||
case .none: nil
|
||||
}
|
||||
}
|
||||
|
||||
var statusIconAccessibilityLabel: String? {
|
||||
switch sendInfo.status {
|
||||
case .sendingFailed: L10n.commonSendingFailed
|
||||
case .encryptionAuthenticity(let authenticity): authenticity.message
|
||||
case .none: nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch sendInfo.layoutType {
|
||||
case .overlay(capsuleStyle: true):
|
||||
content
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.compound.bgSubtleSecondary)
|
||||
.cornerRadius(10)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.bottom, 4)
|
||||
case .horizontal, .overlay(capsuleStyle: false):
|
||||
content
|
||||
.padding(.bottom, -4)
|
||||
case .vertical:
|
||||
GridRow {
|
||||
content
|
||||
.gridColumnAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var content: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(sendInfo.localizedString)
|
||||
|
||||
if let statusIcon {
|
||||
CompoundIcon(statusIcon, size: .xSmall, relativeTo: .compound.bodyXS)
|
||||
.accessibilityLabel(statusIconAccessibilityLabel ?? "")
|
||||
.accessibilityHidden(statusIconAccessibilityLabel == nil)
|
||||
}
|
||||
}
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundStyle(sendInfo.foregroundStyle)
|
||||
}
|
||||
}
|
||||
|
||||
/// All the data needed to render a timeline item's send info label.
|
||||
private struct TimelineItemSendInfo {
|
||||
enum Status { case sendingFailed, encryptionAuthenticity(EncryptionAuthenticity) }
|
||||
|
||||
/// Describes how the content and the send info should be arranged inside a bubble
|
||||
enum LayoutType {
|
||||
case horizontal(spacing: CGFloat = 4)
|
||||
case vertical(spacing: CGFloat = 4)
|
||||
case overlay(capsuleStyle: Bool)
|
||||
}
|
||||
|
||||
let itemID: TimelineItemIdentifier
|
||||
let localizedString: String
|
||||
var status: Status?
|
||||
let layoutType: LayoutType
|
||||
|
||||
var foregroundStyle: Color {
|
||||
switch status {
|
||||
case .sendingFailed:
|
||||
.compound.textCriticalPrimary
|
||||
case .encryptionAuthenticity(let authenticity):
|
||||
authenticity.foregroundStyle
|
||||
case .none:
|
||||
.compound.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension TimelineItemSendInfo {
|
||||
init(timelineItem: EventBasedTimelineItemProtocol, adjustedDeliveryStatus: TimelineItemDeliveryStatus?) {
|
||||
itemID = timelineItem.id
|
||||
localizedString = timelineItem.localizedSendInfo
|
||||
|
||||
status = if adjustedDeliveryStatus == .sendingFailed {
|
||||
.sendingFailed
|
||||
} else if let authenticity = timelineItem.properties.encryptionAuthenticity {
|
||||
.encryptionAuthenticity(authenticity)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
layoutType = switch timelineItem {
|
||||
case is TextBasedRoomTimelineItem:
|
||||
.overlay(capsuleStyle: false)
|
||||
case is ImageRoomTimelineItem,
|
||||
is VideoRoomTimelineItem,
|
||||
is StickerRoomTimelineItem:
|
||||
.overlay(capsuleStyle: true)
|
||||
case let locationTimelineItem as LocationRoomTimelineItem:
|
||||
.overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil)
|
||||
case is PollRoomTimelineItem:
|
||||
.vertical(spacing: 16)
|
||||
default:
|
||||
.horizontal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension EncryptionAuthenticity {
|
||||
var foregroundStyle: SwiftUI.Color {
|
||||
switch color {
|
||||
case .red: .compound.textCriticalPrimary
|
||||
case .gray: .compound.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineItemSendInfoLabel_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 16) {
|
||||
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
|
||||
localizedString: "09:47 AM",
|
||||
layoutType: .horizontal()))
|
||||
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
|
||||
localizedString: "09:47 AM",
|
||||
status: .sendingFailed,
|
||||
layoutType: .horizontal()))
|
||||
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
|
||||
localizedString: "09:47 AM",
|
||||
status: .encryptionAuthenticity(.unsignedDevice(color: .red)),
|
||||
layoutType: .horizontal()))
|
||||
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
|
||||
localizedString: "09:47 AM",
|
||||
status: .encryptionAuthenticity(.notGuaranteed(color: .gray)),
|
||||
layoutType: .horizontal()))
|
||||
TimelineItemSendInfoLabel(sendInfo: .init(itemID: .random,
|
||||
localizedString: "09:47 AM",
|
||||
status: .encryptionAuthenticity(.sentInClear(color: .red)),
|
||||
layoutType: .horizontal()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
enum TimelineGroupStyle: Hashable {
|
||||
case single
|
||||
case first
|
||||
case middle
|
||||
case last
|
||||
|
||||
var shouldShowSenderDetails: Bool {
|
||||
switch self {
|
||||
case .single, .first:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
private struct TimelineGroupStyleKey: EnvironmentKey {
|
||||
static let defaultValue = TimelineGroupStyle.single
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var timelineGroupStyle: TimelineGroupStyle {
|
||||
get { self[TimelineGroupStyleKey.self] }
|
||||
set { self[TimelineGroupStyleKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
// MARK: - TimelineStyler
|
||||
|
||||
struct TimelineStyler<Content: View>: View {
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
@State private var adjustedDeliveryStatus: TimelineItemDeliveryStatus?
|
||||
@State private var task: Task<Void, Never>?
|
||||
|
||||
init(timelineItem: EventBasedTimelineItemProtocol, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.timelineItem = timelineItem
|
||||
self.content = content
|
||||
_adjustedDeliveryStatus = State(initialValue: timelineItem.properties.deliveryStatus)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
.onChange(of: timelineItem.properties.deliveryStatus) { newStatus in
|
||||
if newStatus == .sendingFailed {
|
||||
guard task == nil else {
|
||||
return
|
||||
}
|
||||
task = Task {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
if !Task.isCancelled {
|
||||
adjustedDeliveryStatus = newStatus
|
||||
}
|
||||
task = nil
|
||||
}
|
||||
} else {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
adjustedDeliveryStatus = newStatus
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: adjustedDeliveryStatus)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var mainContent: some View {
|
||||
TimelineItemBubbledStylerView(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus, content: content)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static let base = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "Test"))
|
||||
|
||||
static let sentNonLast: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
result.properties.deliveryStatus = .sent
|
||||
return result
|
||||
}()
|
||||
|
||||
static let sendingNonLast: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
result.properties.deliveryStatus = .sending
|
||||
return result
|
||||
}()
|
||||
|
||||
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,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "Test"))
|
||||
result.properties.deliveryStatus = .sending
|
||||
return result
|
||||
}()
|
||||
|
||||
static let failed: TextRoomTimelineItem = {
|
||||
var result = base
|
||||
result.properties.deliveryStatus = .sendingFailed
|
||||
return result
|
||||
}()
|
||||
|
||||
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,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "Test"))
|
||||
return result
|
||||
}()
|
||||
|
||||
static let ltrString = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test, content: .init(body: "house!"))
|
||||
|
||||
static let rtlString = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test, content: .init(body: "באמת!"))
|
||||
|
||||
static let ltrStringThatContainsRtl = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "house! -- באמת! -- house!"))
|
||||
|
||||
static let rtlStringThatContainsLtr = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "באמת! -- house! -- באמת!"))
|
||||
|
||||
static let ltrStringThatFinishesInRtl = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "house! -- באמת!"))
|
||||
|
||||
static let rtlStringThatFinishesInLtr = TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .test,
|
||||
content: .init(body: "באמת! -- house!"))
|
||||
|
||||
static var testView: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextRoomTimelineView(timelineItem: base)
|
||||
TextRoomTimelineView(timelineItem: sentNonLast)
|
||||
TextRoomTimelineView(timelineItem: sentLast)
|
||||
TextRoomTimelineView(timelineItem: sendingNonLast)
|
||||
TextRoomTimelineView(timelineItem: sendingLast)
|
||||
TextRoomTimelineView(timelineItem: failed)
|
||||
}
|
||||
}
|
||||
|
||||
static var languagesTestView: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextRoomTimelineView(timelineItem: ltrString)
|
||||
TextRoomTimelineView(timelineItem: rtlString)
|
||||
TextRoomTimelineView(timelineItem: ltrStringThatContainsRtl)
|
||||
TextRoomTimelineView(timelineItem: rtlStringThatContainsLtr)
|
||||
TextRoomTimelineView(timelineItem: ltrStringThatFinishesInRtl)
|
||||
TextRoomTimelineView(timelineItem: rtlStringThatFinishesInLtr)
|
||||
}
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
testView
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Bubbles")
|
||||
|
||||
languagesTestView
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Bubbles LTR with different layout languages")
|
||||
|
||||
languagesTestView
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.layoutDirection, .rightToLeft)
|
||||
.previewDisplayName("Bubbles RTL with different layout languages")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// 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 ReactionsSummaryView: View {
|
||||
let reactions: [AggregatedReaction]
|
||||
let members: [String: RoomMemberState]
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
|
||||
@State var selectedReactionKey: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
reactionButtons
|
||||
sendersList
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.presentationBackground(Color.compound.bgCanvasDefault)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var reactionButtons: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
ScrollViewReader { scrollView in
|
||||
HStack(spacing: 8) {
|
||||
ForEach(reactions) { reaction in
|
||||
ReactionSummaryButton(reaction: reaction, highlighted: selectedReactionKey == reaction.key) { key in
|
||||
selectedReactionKey = key
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
scrollView.scrollTo(selectedReactionKey)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedReactionKey) { _ in
|
||||
scrollView.scrollTo(selectedReactionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private var sendersList: some View {
|
||||
TabView(selection: $selectedReactionKey) {
|
||||
ForEach(reactions) { reaction in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(reaction.senders) { sender in
|
||||
ReactionSummarySenderView(sender: sender, member: members[sender.id], imageProvider: imageProvider)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.tag(reaction.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReactionSummaryButton: View {
|
||||
let reaction: AggregatedReaction
|
||||
let highlighted: Bool
|
||||
let action: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button { action(reaction.key) } label: { label }
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(reaction.displayKey)
|
||||
.font(.compound.headingSM)
|
||||
.foregroundColor(textColor)
|
||||
if reaction.count > 1 {
|
||||
Text(String(reaction.count))
|
||||
.font(.compound.headingSM)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(highlighted ? Color.compound.bgActionPrimaryRest : .clear, in: Capsule())
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
highlighted ? Color.compound.textOnSolidPrimary : Color.compound.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReactionSummarySenderView: View {
|
||||
var sender: ReactionSender
|
||||
var member: RoomMemberState?
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
|
||||
var displayName: String {
|
||||
member?.displayName ?? sender.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
LoadableAvatarImage(url: member?.avatarURL,
|
||||
name: displayName,
|
||||
contentID: sender.id,
|
||||
avatarSize: .user(on: .timeline),
|
||||
imageProvider: imageProvider)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Text(displayName)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(sender.timestamp.formattedMinimal())
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
Text(sender.id)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReactionsSummaryView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
ReactionsSummaryView(reactions: AggregatedReaction.mockReactions,
|
||||
members: [:],
|
||||
imageProvider: MockMediaProvider(),
|
||||
selectedReactionKey: AggregatedReaction.mockReactions[0].key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// 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 TimelineDeliveryStatusView: View {
|
||||
enum Status {
|
||||
case sending
|
||||
case sent
|
||||
}
|
||||
|
||||
let deliveryStatus: Status
|
||||
|
||||
private var icon: CompoundIcon {
|
||||
switch deliveryStatus {
|
||||
case .sending:
|
||||
return CompoundIcon(\.circle, size: .xSmall, relativeTo: .compound.bodyMD)
|
||||
case .sent:
|
||||
return CompoundIcon(\.checkCircle, size: .xSmall, relativeTo: .compound.bodyMD)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
icon
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
switch deliveryStatus {
|
||||
case .sending:
|
||||
return L10n.commonSending
|
||||
case .sent:
|
||||
return L10n.commonSent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineDeliveryStatusView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 8) {
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sending)
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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 TimelineItemStatusView: View {
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
timelineItem.isOutgoing && context.viewState.timelineViewState.timelineIDs.last == timelineItem.id.timelineID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty {
|
||||
readReceipts
|
||||
} else {
|
||||
deliveryStatusBadge
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryStatusBadge: some View {
|
||||
switch adjustedDeliveryStatus {
|
||||
case .sending:
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sending)
|
||||
case .sent, .none:
|
||||
if isLastOutgoingMessage {
|
||||
// We only display the sent icon for the latest outgoing message
|
||||
TimelineDeliveryStatusView(deliveryStatus: .sent)
|
||||
}
|
||||
case .sendingFailed:
|
||||
// Bubbles handle the case internally
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
var readReceipts: some View {
|
||||
TimelineReadReceiptsView(timelineItem: timelineItem)
|
||||
.environmentObject(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
struct TimelineReactionsView: View {
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
|
||||
|
||||
let context: TimelineViewModel.Context
|
||||
let itemID: TimelineItemIdentifier
|
||||
let reactions: [AggregatedReaction]
|
||||
let isLayoutRTL: Bool
|
||||
|
||||
private var collapsed: Binding<Bool>
|
||||
|
||||
init(context: TimelineViewModel.Context,
|
||||
itemID: TimelineItemIdentifier,
|
||||
reactions: [AggregatedReaction],
|
||||
isLayoutRTL: Bool = false) {
|
||||
self.context = context
|
||||
self.itemID = itemID
|
||||
self.reactions = reactions
|
||||
self.isLayoutRTL = isLayoutRTL
|
||||
|
||||
collapsed = Binding(get: {
|
||||
context.reactionsCollapsed[itemID] ?? true
|
||||
}, set: {
|
||||
context.reactionsCollapsed[itemID] = $0
|
||||
})
|
||||
}
|
||||
|
||||
var reactionsLayoutDirection: LayoutDirection {
|
||||
guard isLayoutRTL else { return layoutDirection }
|
||||
return layoutDirection == .leftToRight ? .rightToLeft : .leftToRight
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
layout {
|
||||
ForEach(reactions) { reaction in
|
||||
TimelineReactionButton(reaction: reaction) { key in
|
||||
feedbackGenerator.impactOccurred()
|
||||
context.send(viewAction: .toggleReaction(key: key, itemID: itemID))
|
||||
} showReactionSummary: { key in
|
||||
context.send(viewAction: .displayReactionSummary(itemID: itemID, key: key))
|
||||
}
|
||||
.reactionLayoutItem(.reaction)
|
||||
.environment(\.layoutDirection, layoutDirection)
|
||||
}
|
||||
|
||||
if isCollapsible {
|
||||
Button {
|
||||
collapsed.wrappedValue.toggle()
|
||||
} label: {
|
||||
TimelineCollapseButtonLabel(collapsed: collapsed.wrappedValue)
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
.reactionLayoutItem(.expandCollapse)
|
||||
.environment(\.layoutDirection, layoutDirection)
|
||||
}
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .displayEmojiPicker(itemID: itemID))
|
||||
} label: {
|
||||
TimelineReactionAddMoreButtonLabel()
|
||||
}
|
||||
.reactionLayoutItem(.addMore)
|
||||
}
|
||||
.environment(\.layoutDirection, reactionsLayoutDirection)
|
||||
.animation(.easeInOut(duration: 0.1).disabledDuringTests(), value: reactions)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var isCollapsible: Bool {
|
||||
reactions.count > 5
|
||||
}
|
||||
|
||||
private var layout: AnyLayout {
|
||||
if isCollapsible {
|
||||
return AnyLayout(CollapsibleReactionLayout(itemSpacing: 4,
|
||||
rowSpacing: 4,
|
||||
collapsed: collapsed.wrappedValue,
|
||||
rowsBeforeCollapsible: 2))
|
||||
}
|
||||
|
||||
return AnyLayout(HStackLayout(spacing: 4.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// The pill shape for the label that surrounds both the reaction and collapse buttons.
|
||||
struct TimelineReactionButtonLabel<Content: View>: View {
|
||||
var isHighlighted = false
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor))
|
||||
.overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor))
|
||||
.overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
var backgroundShape: some InsettableShape {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
}
|
||||
|
||||
var overlayBackgroundColor: Color {
|
||||
isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary
|
||||
}
|
||||
|
||||
var overlayBorderColor: Color {
|
||||
isHighlighted ? Color.compound.borderInteractivePrimary : .clear
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineCollapseButtonLabel: View {
|
||||
var collapsed: Bool
|
||||
@ScaledMetric(relativeTo: .subheadline) private var lineHeight = 20
|
||||
|
||||
var body: some View {
|
||||
TimelineReactionButtonLabel {
|
||||
Text(collapsed ? L10n.screenRoomTimelineReactionsShowMore : L10n.screenRoomTimelineReactionsShowLess)
|
||||
.frame(height: lineHeight, alignment: .center)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionButton: View {
|
||||
let reaction: AggregatedReaction
|
||||
let toggleReaction: (String) -> Void
|
||||
let showReactionSummary: (String) -> Void
|
||||
@ScaledMetric(relativeTo: .subheadline) private var lineHeight = 20
|
||||
|
||||
var body: some View {
|
||||
label
|
||||
.onTapGesture {
|
||||
toggleReaction(reaction.key)
|
||||
}
|
||||
.longPressWithFeedback {
|
||||
showReactionSummary(reaction.key)
|
||||
}
|
||||
.accessibilityHint(L10n.commonReaction)
|
||||
.accessibilityAddTraits(reaction.isHighlighted ? .isSelected : [])
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
TimelineReactionButtonLabel(isHighlighted: reaction.isHighlighted) {
|
||||
HStack(spacing: 4) {
|
||||
// Designs have bodyMD for the key but practically this makes
|
||||
// emojis too big. bodySM gives a more appropriate size when compared
|
||||
// to the count text and the lineHeight/padding in the designs.
|
||||
Text(reaction.displayKey)
|
||||
.font(.compound.bodySM)
|
||||
if reaction.count > 1 {
|
||||
Text(String(reaction.count))
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
.frame(height: lineHeight, alignment: .center)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
reaction.isHighlighted ? Color.compound.textPrimary : .compound.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionAddMoreButtonLabel: View {
|
||||
var body: some View {
|
||||
TimelineReactionButtonLabel {
|
||||
CompoundIcon(\.reactionAdd, size: .xSmall, relativeTo: .compound.bodySM)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.accessibilityLabel(L10n.actionReact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionViewPreviewsContainer: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
TimelineReactionsView(context: TimelineViewModel.mock.context,
|
||||
itemID: .init(timelineID: "1"),
|
||||
reactions: [AggregatedReaction.mockReactionWithLongText,
|
||||
AggregatedReaction.mockReactionWithLongTextRTL])
|
||||
Divider()
|
||||
TimelineReactionsView(context: TimelineViewModel.mock.context,
|
||||
itemID: .init(timelineID: "2"),
|
||||
reactions: Array(AggregatedReaction.mockReactions.prefix(3)))
|
||||
Divider()
|
||||
TimelineReactionsView(context: TimelineViewModel.mock.context,
|
||||
itemID: .init(timelineID: "3"),
|
||||
reactions: AggregatedReaction.mockReactions)
|
||||
Divider()
|
||||
TimelineReactionsView(context: TimelineViewModel.mock.context,
|
||||
itemID: .init(timelineID: "4"),
|
||||
reactions: AggregatedReaction.mockReactions,
|
||||
isLayoutRTL: true)
|
||||
}
|
||||
.background(Color.red)
|
||||
.frame(maxWidth: 250, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReactionView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
TimelineReactionViewPreviewsContainer()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//
|
||||
// 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 TimelineReadReceiptsView: View {
|
||||
let displayNumber = 3
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
HStack(spacing: -4) {
|
||||
let receiptsToDisplay = timelineItem.properties.orderedReadReceipts.prefix(displayNumber)
|
||||
ForEach(0..<receiptsToDisplay.count, id: \.self) { index in
|
||||
let receipt = receiptsToDisplay[index]
|
||||
LoadableAvatarImage(url: context.viewState.members[receipt.userID]?.avatarURL,
|
||||
name: context.viewState.members[receipt.userID]?.displayName,
|
||||
contentID: receipt.userID,
|
||||
avatarSize: .user(on: .readReceipt),
|
||||
imageProvider: context.imageProvider)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: .infinity)
|
||||
.stroke(Color.compound.bgCanvasDefault, lineWidth: 1)
|
||||
}
|
||||
.zIndex(Double(displayNumber - index))
|
||||
}
|
||||
}
|
||||
if timelineItem.properties.orderedReadReceipts.count > displayNumber {
|
||||
Text("+\(remaining)")
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .displayReadReceipts(itemID: timelineItem.id))
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityHint(L10n.a11yReadReceiptsTapToShowAll)
|
||||
}
|
||||
|
||||
private var remaining: Int {
|
||||
timelineItem.properties.orderedReadReceipts.count - displayNumber
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
if timelineItem.properties.orderedReadReceipts.count == 1 {
|
||||
return L10n.a11yReadReceiptsSingle(displayName(at: 0))
|
||||
} else if timelineItem.properties.orderedReadReceipts.count <= displayNumber {
|
||||
let limit = timelineItem.properties.orderedReadReceipts.count - 1
|
||||
let list = (0..<limit).map { displayName(at: $0) }.formatted(.list(type: .and, width: .narrow))
|
||||
let last = displayName(at: limit)
|
||||
return L10n.a11yReadReceiptsMultiple(list, last)
|
||||
} else if timelineItem.properties.orderedReadReceipts.count > displayNumber {
|
||||
let list = (0..<displayNumber).map { displayName(at: $0) }.formatted(.list(type: .and, width: .narrow))
|
||||
|
||||
// Plurals with string arguments aren't generated correctly so we need to use this
|
||||
// https://github.com/SwiftGen/SwiftGen/issues/1089
|
||||
return L10n.tr("Localizable", "a11y_read_receipts_multiple_with_others", list, remaining)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func displayName(at index: Int) -> String {
|
||||
let userID = timelineItem.properties.orderedReadReceipts[index].userID
|
||||
return context.viewState.members[userID]?.displayName ?? userID
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
|
||||
static let members: [RoomMemberProxyMock] = [
|
||||
.mockAlice,
|
||||
.mockBob,
|
||||
.mockCharlie,
|
||||
.mockDan,
|
||||
.mockMe
|
||||
]
|
||||
|
||||
static let viewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Test", members: members)),
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
|
||||
static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
|
||||
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
|
||||
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before")]
|
||||
static let tripleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
|
||||
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before"),
|
||||
ReadReceipt(userID: RoomMemberProxyMock.mockCharlie.userID, formattedTimestamp: "Way before")]
|
||||
static let quadrupleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
|
||||
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before"),
|
||||
ReadReceipt(userID: RoomMemberProxyMock.mockCharlie.userID, formattedTimestamp: "Way before"),
|
||||
ReadReceipt(userID: RoomMemberProxyMock.mockDan.userID, formattedTimestamp: "Way, way before")]
|
||||
|
||||
static func mockTimelineItem(with receipts: [ReadReceipt]) -> TextRoomTimelineItem {
|
||||
TextRoomTimelineItem(id: .random,
|
||||
timestamp: "Now",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: UUID().uuidString), content: .init(body: "Test"),
|
||||
properties: .init(orderedReadReceipts: receipts))
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 8) {
|
||||
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: singleReceipt))
|
||||
.environmentObject(viewModel.context)
|
||||
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: doubleReceipt))
|
||||
.environmentObject(viewModel.context)
|
||||
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: tripleReceipt))
|
||||
.environmentObject(viewModel.context)
|
||||
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: quadrupleReceipt))
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 SwiftUI
|
||||
|
||||
struct TimelineItemDebugView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let info: TimelineItemDebugInfo
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
TimelineItemInfoDisclosureGroup(title: "Model", text: info.model)
|
||||
|
||||
if let originalJSONInfo = info.originalJSON {
|
||||
TimelineItemInfoDisclosureGroup(title: "Original JSON", text: originalJSONInfo)
|
||||
}
|
||||
|
||||
if let latestEditJSONInfo = info.latestEditJSON {
|
||||
TimelineItemInfoDisclosureGroup(title: "Latest edit JSON", text: latestEditJSONInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("Timeline item")
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(L10n.actionCopy) {
|
||||
UIPasteboard.general.string = info.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private struct TimelineItemInfoDisclosureGroup: View {
|
||||
@State private var isExpanded = true
|
||||
|
||||
let title: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0.0) {
|
||||
DisclosureGroup(title, isExpanded: $isExpanded) {
|
||||
disclosureGroupContent
|
||||
}
|
||||
.font(.compound.bodyMD)
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var disclosureGroupContent: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
|
||||
TextField("", text: .constant(text), axis: .vertical)
|
||||
.font(.compound.bodyXS.monospaced())
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemDebugView_Previews: PreviewProvider, TestablePreview {
|
||||
static let smallContent = """
|
||||
{
|
||||
SomeItem(
|
||||
event_id: "$1234546634535",
|
||||
sender: "@user:server.com",
|
||||
timestamp: 42354534534
|
||||
content: Message(
|
||||
Message {
|
||||
…
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
"""
|
||||
|
||||
static var previews: some View {
|
||||
TimelineItemDebugView(info: .init(model: smallContent,
|
||||
originalJSON: "{\"Hi\": \"Alice\"}",
|
||||
latestEditJSON: "{\"Hi\": \"Bob\"}"))
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
+48
@@ -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")))
|
||||
}
|
||||
}
|
||||
+78
@@ -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")))
|
||||
}
|
||||
}
|
||||
+71
@@ -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))
|
||||
}
|
||||
}
|
||||
+109
@@ -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())
|
||||
}
|
||||
}
|
||||
+117
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -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))
|
||||
}
|
||||
}
|
||||
+34
@@ -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")
|
||||
}
|
||||
}
|
||||
+69
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
@@ -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))
|
||||
}
|
||||
}
|
||||
+38
@@ -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: ""))
|
||||
}
|
||||
+89
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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))
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+74
@@ -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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// 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 TimelineSenderAvatarView: View {
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
|
||||
var body: some View {
|
||||
LoadableAvatarImage(url: timelineItem.sender.avatarURL,
|
||||
name: timelineItem.sender.displayName,
|
||||
contentID: timelineItem.sender.id,
|
||||
avatarSize: .user(on: .timeline),
|
||||
imageProvider: context.imageProvider)
|
||||
.overlay {
|
||||
Circle().stroke(Color.compound.bgCanvasDefault, lineWidth: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
/// A table view wrapper that displays the timeline of a room.
|
||||
struct TimelineView: UIViewControllerRepresentable {
|
||||
@EnvironmentObject private var viewModelContext: TimelineViewModel.Context
|
||||
|
||||
func makeUIViewController(context: Context) -> TimelineTableViewController {
|
||||
let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
|
||||
isScrolledToBottom: $viewModelContext.isScrolledToBottom,
|
||||
scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.scrollToBottomPublisher)
|
||||
return tableViewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) {
|
||||
context.coordinator.update(tableViewController: uiViewController)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModelContext: viewModelContext)
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
@MainActor
|
||||
class Coordinator {
|
||||
let context: TimelineViewModel.Context
|
||||
|
||||
init(viewModelContext: TimelineViewModel.Context) {
|
||||
context = viewModelContext
|
||||
}
|
||||
|
||||
/// Updates the specified table view's properties from the current view state.
|
||||
func update(tableViewController: TimelineTableViewController) {
|
||||
if tableViewController.isSwitchingTimelines != context.viewState.timelineViewState.isSwitchingTimelines {
|
||||
// Must come before timelineItemsDictionary in order to disable animations.
|
||||
tableViewController.isSwitchingTimelines = context.viewState.timelineViewState.isSwitchingTimelines
|
||||
}
|
||||
if tableViewController.timelineItemsDictionary != context.viewState.timelineViewState.itemsDictionary {
|
||||
tableViewController.timelineItemsDictionary = context.viewState.timelineViewState.itemsDictionary
|
||||
}
|
||||
if tableViewController.paginationState != context.viewState.timelineViewState.paginationState {
|
||||
tableViewController.paginationState = context.viewState.timelineViewState.paginationState
|
||||
}
|
||||
if tableViewController.isLive != context.viewState.timelineViewState.isLive {
|
||||
tableViewController.isLive = context.viewState.timelineViewState.isLive
|
||||
}
|
||||
if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent {
|
||||
tableViewController.focussedEvent = context.viewState.timelineViewState.focussedEvent
|
||||
}
|
||||
|
||||
if tableViewController.typingMembers.members != context.viewState.typingMembers {
|
||||
tableViewController.setTypingMembers(context.viewState.typingMembers)
|
||||
}
|
||||
}
|
||||
|
||||
func send(viewAction: TimelineViewAction) {
|
||||
context.send(viewAction: viewAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let roomViewModel = RoomScreenViewModel.mock()
|
||||
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
|
||||
name: "Preview room")),
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// 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 SwiftUI
|
||||
|
||||
struct TypingIndicatorView: View {
|
||||
@ObservedObject var typingMembers: TypingMembersObservableObject
|
||||
@State private var didShowTextOnce = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textPlaceholder)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal, 4)
|
||||
.animation(.elementDefault, value: typingMembers.members)
|
||||
.onChange(of: typingMembers.members) { newValue in
|
||||
if !newValue.isEmpty {
|
||||
didShowTextOnce = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var content: some View {
|
||||
// Plurals with string arguments aren't generated correctly by so we need to work around that
|
||||
// https://github.com/SwiftGen/SwiftGen/issues/1089
|
||||
|
||||
switch typingMembers.members.count {
|
||||
case 1:
|
||||
let firstMember = typingMembers.members[0]
|
||||
|
||||
Text(firstMember).bold() +
|
||||
Text(L10n.screenRoomTypingNotificationSingularIos)
|
||||
case 2:
|
||||
let firstMember = typingMembers.members[0]
|
||||
let lastMember = typingMembers.members[1]
|
||||
|
||||
Text(L10n.screenRoomTypingTwoMembers(firstMember, lastMember)).bold() +
|
||||
Text(L10n.screenRoomTypingNotificationPluralIos)
|
||||
case 3...:
|
||||
let firstMember = typingMembers.members[0]
|
||||
let lastMember = typingMembers.members[1]
|
||||
|
||||
Text(L10n.tr("Localizable", "screen_room_typing_many_members_first_component_ios", firstMember, lastMember)).bold() +
|
||||
Text(L10n.tr("Localizable", "screen_room_typing_many_members_second_component_ios", typingMembers.members.count - 2)).bold() +
|
||||
Text(L10n.screenRoomTypingNotificationPluralIos)
|
||||
default:
|
||||
if didShowTextOnce {
|
||||
Text(L10n.screenRoomTypingNotificationSingularIos)
|
||||
.opacity(0)
|
||||
} else {
|
||||
// Together with the margins and minSize on the contentConfiguration forces the tableView cell to be small
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.opacity(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TypingIndicatorView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading, spacing: 16.0) {
|
||||
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice"]))
|
||||
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob"]))
|
||||
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob", "Charlie"]))
|
||||
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob", "Charlie", "Dan"]))
|
||||
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice", "Bob", "Charlie", "Dan", "Frannie"]))
|
||||
TypingIndicatorView(typingMembers: TypingMembersObservableObject(members: ["Alice with her very long display name", "Bob", "Charlie", "Dan", "Frannie"]))
|
||||
}
|
||||
.frame(width: 300)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user