mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
RoomScreenViewModel is now TimelineViewModel (#3157)
Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
@@ -31,13 +31,13 @@ enum ComposerToolbarVoiceMessageAction {
|
||||
}
|
||||
|
||||
enum ComposerToolbarViewModelAction {
|
||||
case sendMessage(plain: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions)
|
||||
case sendMessage(plain: String, html: String?, mode: ComposerMode, intentionalMentions: IntentionalMentions)
|
||||
case editLastMessage
|
||||
case attach(ComposerAttachmentType)
|
||||
|
||||
case handlePasteOrDrop(provider: NSItemProvider)
|
||||
|
||||
case composerModeChanged(mode: RoomScreenComposerMode)
|
||||
case composerModeChanged(mode: ComposerMode)
|
||||
case composerFocusedChanged(isFocused: Bool)
|
||||
|
||||
case voiceMessage(ComposerToolbarVoiceMessageAction)
|
||||
@@ -72,7 +72,7 @@ enum ComposerAttachmentType {
|
||||
}
|
||||
|
||||
struct ComposerToolbarViewState: BindableState {
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
var composerMode: ComposerMode = .default
|
||||
var composerEmpty = true
|
||||
var suggestions: [SuggestionItem] = []
|
||||
var audioPlayerState: AudioPlayerState
|
||||
@@ -288,3 +288,61 @@ extension FormatType {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ComposerMode: Equatable {
|
||||
case `default`
|
||||
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
|
||||
case edit(originalItemId: TimelineItemIdentifier)
|
||||
case recordVoiceMessage(state: AudioRecorderState)
|
||||
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)
|
||||
|
||||
var isEdit: Bool {
|
||||
switch self {
|
||||
case .edit:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isTextEditingEnabled: Bool {
|
||||
switch self {
|
||||
case .default, .reply, .edit:
|
||||
return true
|
||||
case .recordVoiceMessage, .previewVoiceMessage:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isLoadingReply: Bool {
|
||||
switch self {
|
||||
case .reply(_, let replyDetails, _):
|
||||
switch replyDetails {
|
||||
case .loading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var replyEventID: String? {
|
||||
switch self {
|
||||
case .reply(let itemID, _, _):
|
||||
return itemID.eventID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var isComposingNewMessage: Bool {
|
||||
switch self {
|
||||
case .default, .reply:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -200,8 +200,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
}
|
||||
|
||||
func process(roomAction: RoomScreenComposerAction) {
|
||||
switch roomAction {
|
||||
func process(timelineAction: TimelineComposerAction) {
|
||||
switch timelineAction {
|
||||
case .setMode(mode: let mode):
|
||||
if state.composerMode.isComposingNewMessage, mode.isEdit {
|
||||
handleSaveDraft(isVolatile: true)
|
||||
@@ -223,19 +223,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
set(mode: .default)
|
||||
set(text: "")
|
||||
}
|
||||
case .saveDraft:
|
||||
handleSaveDraft(isVolatile: false)
|
||||
case .loadDraft:
|
||||
Task {
|
||||
guard case let .success(draft) = await draftService.loadDraft(),
|
||||
let draft else {
|
||||
return
|
||||
}
|
||||
handleLoadDraft(draft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadDraft() {
|
||||
Task {
|
||||
guard case let .success(draft) = await draftService.loadDraft(),
|
||||
let draft else {
|
||||
return
|
||||
}
|
||||
handleLoadDraft(draft)
|
||||
}
|
||||
}
|
||||
|
||||
func saveDraft() {
|
||||
handleSaveDraft(isVolatile: false)
|
||||
}
|
||||
|
||||
var keyCommands: [WysiwygKeyCommand] {
|
||||
[
|
||||
.enter { [weak self] in
|
||||
@@ -480,7 +484,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
}
|
||||
|
||||
private func set(mode: RoomScreenComposerMode) {
|
||||
private func set(mode: ComposerMode) {
|
||||
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {
|
||||
replyLoadingTask?.cancel()
|
||||
}
|
||||
|
||||
+6
-1
@@ -15,10 +15,15 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import WysiwygComposer
|
||||
|
||||
// periphery: ignore - markdown protocol
|
||||
protocol ComposerToolbarViewModelProtocol {
|
||||
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
|
||||
var context: ComposerToolbarViewModelType.Context { get }
|
||||
func process(roomAction: RoomScreenComposerAction)
|
||||
var keyCommands: [WysiwygKeyCommand] { get }
|
||||
|
||||
func process(timelineAction: TimelineComposerAction)
|
||||
func loadDraft()
|
||||
func saveDraft()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ typealias PasteHandler = (NSItemProvider) -> Void
|
||||
struct MessageComposer: View {
|
||||
@Binding var plainComposerText: NSAttributedString
|
||||
let composerView: WysiwygComposerView
|
||||
let mode: RoomScreenComposerMode
|
||||
let mode: ComposerMode
|
||||
let composerFormattingEnabled: Bool
|
||||
let showResizeGrabber: Bool
|
||||
@Binding var isExpanded: Bool
|
||||
@@ -208,7 +208,7 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
|
||||
}
|
||||
|
||||
struct MessageComposer_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static let replyTypes: [TimelineItemReplyDetails] = [
|
||||
.loaded(sender: .init(id: "Dave"),
|
||||
@@ -241,7 +241,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
|
||||
]
|
||||
|
||||
static func messageComposer(_ content: NSAttributedString = .init(string: ""),
|
||||
mode: RoomScreenComposerMode = .default) -> MessageComposer {
|
||||
mode: ComposerMode = .default) -> MessageComposer {
|
||||
let viewModel = WysiwygComposerViewModel(minHeight: 22,
|
||||
maxExpandedHeight: 250)
|
||||
viewModel.setMarkdownContent(content.string)
|
||||
|
||||
+3
-3
@@ -55,7 +55,7 @@ struct MessageComposerTextField: View {
|
||||
}
|
||||
|
||||
private struct UITextViewWrapper: UIViewRepresentable {
|
||||
@Environment(\.roomContext) private var roomContext
|
||||
@Environment(\.timelineContext) private var timelineContext
|
||||
|
||||
@Binding var text: NSAttributedString
|
||||
|
||||
@@ -69,7 +69,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
|
||||
// Need to use TextKit 1 for mentions
|
||||
let textView = ElementTextView(usingTextLayoutManager: false)
|
||||
textView.roomContext = roomContext
|
||||
textView.timelineContext = timelineContext
|
||||
|
||||
textView.delegate = context.coordinator
|
||||
textView.elementDelegate = context.coordinator
|
||||
@@ -182,7 +182,7 @@ private protocol ElementTextViewDelegate: AnyObject {
|
||||
}
|
||||
|
||||
private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
|
||||
var roomContext: RoomScreenViewModel.Context?
|
||||
var timelineContext: TimelineViewModel.Context?
|
||||
|
||||
weak var elementDelegate: ElementTextViewDelegate?
|
||||
|
||||
|
||||
@@ -49,8 +49,9 @@ enum RoomScreenCoordinatorAction {
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: RoomScreenViewModelProtocol
|
||||
private var composerViewModel: ComposerToolbarViewModel
|
||||
private var roomViewModel: RoomScreenViewModelProtocol
|
||||
private var timelineViewModel: TimelineViewModelProtocol
|
||||
private var composerViewModel: ComposerToolbarViewModelProtocol
|
||||
private var wysiwygViewModel: WysiwygComposerViewModel
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -61,31 +62,33 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||
focussedEventID: parameters.focussedEventID,
|
||||
timelineController: parameters.timelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: parameters.appSettings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
self.viewModel = viewModel
|
||||
roomViewModel = RoomScreenViewModel()
|
||||
|
||||
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
||||
focussedEventID: parameters.focussedEventID,
|
||||
timelineController: parameters.timelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: parameters.appSettings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
|
||||
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
|
||||
maxCompressedHeight: ComposerConstant.maxHeight,
|
||||
maxExpandedHeight: ComposerConstant.maxHeight,
|
||||
parserStyle: .elementX)
|
||||
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: parameters.completionSuggestionService,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper(roomContext: viewModel.context),
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: parameters.composerDraftService)
|
||||
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: parameters.completionSuggestionService,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper(timelineContext: timelineViewModel.context),
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
composerDraftService: parameters.composerDraftService)
|
||||
self.composerViewModel = composerViewModel
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).sink { _ in
|
||||
viewModel.saveDraft()
|
||||
composerViewModel.saveDraft()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
@@ -93,7 +96,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
viewModel.actions
|
||||
timelineViewModel.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -123,7 +126,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
case .displayLocation(let body, let geoURI, let description):
|
||||
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
|
||||
case .composer(let action):
|
||||
composerViewModel.process(roomAction: action)
|
||||
composerViewModel.process(timelineAction: action)
|
||||
case .displayCallScreen:
|
||||
actionsSubject.send(.presentCallScreen)
|
||||
case .displayPinnedEventsTimeline:
|
||||
@@ -136,21 +139,27 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
viewModel.process(composerAction: action)
|
||||
timelineViewModel.process(composerAction: action)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomViewModel.actions
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Loading the draft requires the subscriptions to be set up first otherwise the room won't be be able to propagate the information to the composer.
|
||||
viewModel.loadDraft()
|
||||
composerViewModel.loadDraft()
|
||||
}
|
||||
|
||||
func focusOnEvent(eventID: String) {
|
||||
Task { await viewModel.focusOnEvent(eventID: eventID) }
|
||||
Task { await timelineViewModel.focusOnEvent(eventID: eventID) }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
viewModel.saveDraft()
|
||||
viewModel.stop()
|
||||
composerViewModel.saveDraft()
|
||||
timelineViewModel.stop()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
@@ -158,10 +167,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: composerViewModel.keyCommands)
|
||||
|
||||
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar)
|
||||
.onDisappear { [weak self] in
|
||||
self?.viewModel.saveDraft()
|
||||
})
|
||||
return AnyView(RoomScreen(roomViewModel: roomViewModel,
|
||||
timelineViewModel: timelineViewModel,
|
||||
composerToolbar: composerToolbar)
|
||||
.onDisappear { [weak self] in
|
||||
self?.composerViewModel.saveDraft()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import UIKit
|
||||
|
||||
enum RoomScreenInteractionHandlerAction {
|
||||
case composer(action: RoomScreenComposerAction)
|
||||
|
||||
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
|
||||
case displayMessageForwarding(itemID: TimelineItemIdentifier)
|
||||
case displayMediaUploadPreviewScreen(url: URL)
|
||||
case displayPollForm(mode: PollFormMode)
|
||||
case displayRoomMemberDetails(userID: String)
|
||||
|
||||
case showActionMenu(TimelineItemActionMenuInfo)
|
||||
case showDebugInfo(TimelineItemDebugInfo)
|
||||
|
||||
case displayAudioRecorderPermissionError
|
||||
case displayErrorToast(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class RoomScreenInteractionHandler {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let timelineController: RoomTimelineControllerProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
private let voiceMessageRecorder: VoiceMessageRecorderProtocol
|
||||
private let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let analyticsService: AnalyticsService
|
||||
private let pollInteractionHandler: PollInteractionHandlerProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomScreenInteractionHandlerAction, Never> = .init()
|
||||
var actions: AnyPublisher<RoomScreenInteractionHandlerAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var voiceMessageRecorderObserver: AnyCancellable? {
|
||||
didSet {
|
||||
appMediator.setIdleTimerDisabled(voiceMessageRecorderObserver != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private var resumeVoiceMessagePlaybackAfterScrubbing = false
|
||||
|
||||
init(roomProxy: RoomProxyProtocol,
|
||||
timelineController: RoomTimelineControllerProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
||||
voiceMessageRecorder: VoiceMessageRecorderProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appMediator: AppMediatorProtocol,
|
||||
appSettings: AppSettings,
|
||||
analyticsService: AnalyticsService) {
|
||||
self.roomProxy = roomProxy
|
||||
self.timelineController = timelineController
|
||||
self.mediaProvider = mediaProvider
|
||||
self.mediaPlayerProvider = mediaPlayerProvider
|
||||
self.voiceMessageMediaManager = voiceMessageMediaManager
|
||||
self.voiceMessageRecorder = voiceMessageRecorder
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
self.appSettings = appSettings
|
||||
self.analyticsService = analyticsService
|
||||
pollInteractionHandler = PollInteractionHandler(analyticsService: analyticsService, roomProxy: roomProxy)
|
||||
}
|
||||
|
||||
// MARK: Timeline Item Action Menu
|
||||
|
||||
func displayTimelineItemActionMenu(for itemID: TimelineItemIdentifier) {
|
||||
Task {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
// Don't show a menu for non-event based items.
|
||||
return
|
||||
}
|
||||
|
||||
actionsSubject.send(.composer(action: .removeFocus))
|
||||
actionsSubject.send(.showActionMenu(.init(item: eventTimelineItem)))
|
||||
}
|
||||
}
|
||||
|
||||
func handleTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: TimelineItemIdentifier) {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .copy:
|
||||
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
|
||||
UIPasteboard.general.string = messageTimelineItem.body
|
||||
case .edit:
|
||||
switch timelineItem {
|
||||
case let messageTimelineItem as EventBasedMessageTimelineItemProtocol:
|
||||
processEditMessageEvent(messageTimelineItem)
|
||||
case let pollTimelineItem as PollRoomTimelineItem:
|
||||
guard let eventID = pollTimelineItem.id.eventID else {
|
||||
MXLog.error("Cannot edit poll with id: \(timelineItem.id)")
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.displayPollForm(mode: .edit(eventID: eventID, poll: pollTimelineItem.poll)))
|
||||
default:
|
||||
MXLog.error("Cannot edit item with id: \(timelineItem.id)")
|
||||
}
|
||||
case .copyPermalink:
|
||||
guard let eventID = eventTimelineItem.id.eventID else {
|
||||
actionsSubject.send(.displayErrorToast(L10n.errorFailedCreatingThePermalink))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
guard case let .success(permalinkURL) = await roomProxy.matrixToEventPermalink(eventID) else {
|
||||
actionsSubject.send(.displayErrorToast(L10n.errorFailedCreatingThePermalink))
|
||||
return
|
||||
}
|
||||
|
||||
UIPasteboard.general.url = permalinkURL
|
||||
}
|
||||
case .redact:
|
||||
Task {
|
||||
await timelineController.redact(itemID)
|
||||
}
|
||||
case .reply:
|
||||
guard let eventID = eventTimelineItem.id.eventID else {
|
||||
return
|
||||
}
|
||||
|
||||
let replyInfo = buildReplyInfo(for: eventTimelineItem)
|
||||
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, eventID: eventID, eventContent: replyInfo.type)
|
||||
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .reply(itemID: eventTimelineItem.id, replyDetails: replyDetails, isThread: replyInfo.isThread))))
|
||||
case .forward(let itemID):
|
||||
actionsSubject.send(.displayMessageForwarding(itemID: itemID))
|
||||
case .viewSource:
|
||||
let debugInfo = timelineController.debugInfo(for: eventTimelineItem.id)
|
||||
MXLog.info("Showing debug info for \(eventTimelineItem.id)")
|
||||
actionsSubject.send(.showDebugInfo(debugInfo))
|
||||
case .retryDecryption(let sessionID):
|
||||
Task {
|
||||
await timelineController.retryDecryption(for: sessionID)
|
||||
}
|
||||
case .report:
|
||||
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: eventTimelineItem.sender.id))
|
||||
case .react:
|
||||
displayEmojiPicker(for: itemID)
|
||||
case .toggleReaction(let key):
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
Task { await roomProxy.timeline.toggleReaction(key, to: eventID) }
|
||||
case .endPoll(let pollStartID):
|
||||
endPoll(pollStartID: pollStartID)
|
||||
case .pin:
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
Task { await timelineController.pin(eventID: eventID) }
|
||||
case .unpin:
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
Task { await timelineController.unpin(eventID: eventID) }
|
||||
}
|
||||
|
||||
if action.switchToDefaultComposer {
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
}
|
||||
|
||||
private func processEditMessageEvent(_ messageTimelineItem: EventBasedMessageTimelineItemProtocol) {
|
||||
let text: String
|
||||
var htmlText: String?
|
||||
switch messageTimelineItem.contentType {
|
||||
case .text(let content):
|
||||
text = content.body
|
||||
htmlText = content.formattedBodyHTMLString
|
||||
case .emote(let content):
|
||||
text = "/me " + content.body
|
||||
default:
|
||||
text = messageTimelineItem.body
|
||||
}
|
||||
|
||||
// Always update the mode first and then the text so that the composer has time to save the text draft
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
|
||||
actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText)))
|
||||
}
|
||||
|
||||
// MARK: Polls
|
||||
|
||||
func sendPollResponse(pollStartID: String, optionID: String) {
|
||||
Task {
|
||||
let sendPollResponseResult = await pollInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
|
||||
|
||||
switch sendPollResponseResult {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
actionsSubject.send(.displayErrorToast(L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func endPoll(pollStartID: String) {
|
||||
Task {
|
||||
let endPollResult = await pollInteractionHandler.endPoll(pollStartID: pollStartID)
|
||||
|
||||
switch endPollResult {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
actionsSubject.send(.displayErrorToast(L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Pasting and dropping
|
||||
|
||||
func handlePasteOrDrop(_ provider: NSItemProvider) {
|
||||
guard let contentType = provider.preferredContentType,
|
||||
let preferredExtension = contentType.preferredFilenameExtension else {
|
||||
MXLog.error("Invalid NSItemProvider: \(provider)")
|
||||
actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia))
|
||||
return
|
||||
}
|
||||
|
||||
let providerSuggestedName = provider.suggestedName
|
||||
let providerDescription = provider.description
|
||||
|
||||
_ = provider.loadDataRepresentation(for: contentType) { data, error in
|
||||
Task { @MainActor in
|
||||
let loadingIndicatorIdentifier = UUID().uuidString
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
defer {
|
||||
self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
|
||||
}
|
||||
|
||||
if let error {
|
||||
self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia))
|
||||
MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let data else {
|
||||
self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia))
|
||||
MXLog.error("Invalid NSItemProvider data: \(providerDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let url = try await Task.detached {
|
||||
if let filename = providerSuggestedName {
|
||||
let hasExtension = !(filename as NSString).pathExtension.isEmpty
|
||||
let filename = hasExtension ? filename : "\(filename).\(preferredExtension)"
|
||||
return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename)
|
||||
} else {
|
||||
let filename = "\(UUID().uuidString).\(preferredExtension)"
|
||||
return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename)
|
||||
}
|
||||
}.value
|
||||
|
||||
self.actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
|
||||
} catch {
|
||||
self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia))
|
||||
MXLog.error("Failed storing NSItemProvider data \(providerDescription) with error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Voice messages
|
||||
|
||||
private func handleVoiceMessageRecorderAction(_ action: VoiceMessageRecorderAction) {
|
||||
MXLog.debug("handling voice recorder action: \(action) - (audio)")
|
||||
switch action {
|
||||
case .didStartRecording(let audioRecorder):
|
||||
let audioRecordState = AudioRecorderState()
|
||||
audioRecordState.attachAudioRecorder(audioRecorder)
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
|
||||
case .didStopRecording(let previewAudioPlayerState, let url):
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: previewAudioPlayerState, waveform: .url(url), isUploading: false))))
|
||||
voiceMessageRecorderObserver = nil
|
||||
case .didFailWithError(let error):
|
||||
switch error {
|
||||
case .audioRecorderError(.recordPermissionNotGranted):
|
||||
MXLog.info("permission to record audio has not been granted.")
|
||||
actionsSubject.send(.displayAudioRecorderPermissionError)
|
||||
default:
|
||||
MXLog.error("failed to start voice message recording. \(error)")
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startRecordingVoiceMessage() async {
|
||||
voiceMessageRecorderObserver = voiceMessageRecorder.actions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] action in
|
||||
self?.handleVoiceMessageRecorderAction(action)
|
||||
}
|
||||
|
||||
await voiceMessageRecorder.startRecording()
|
||||
}
|
||||
|
||||
func stopRecordingVoiceMessage() async {
|
||||
await voiceMessageRecorder.stopRecording()
|
||||
}
|
||||
|
||||
func cancelRecordingVoiceMessage() async {
|
||||
await voiceMessageRecorder.cancelRecording()
|
||||
voiceMessageRecorderObserver = nil
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
|
||||
func deleteCurrentVoiceMessage() async {
|
||||
if voiceMessageRecorder.isRecording {
|
||||
await voiceMessageRecorder.cancelRecording()
|
||||
} else {
|
||||
await voiceMessageRecorder.deleteRecording()
|
||||
}
|
||||
|
||||
voiceMessageRecorderObserver = nil
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .default)))
|
||||
}
|
||||
|
||||
func sendCurrentVoiceMessage() async {
|
||||
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState, let recordingURL = voiceMessageRecorder.recordingURL else {
|
||||
actionsSubject.send(.displayErrorToast(L10n.errorFailedUploadingVoiceMessage))
|
||||
return
|
||||
}
|
||||
|
||||
analyticsService.trackComposer(inThread: false,
|
||||
isEditing: false,
|
||||
isReply: false,
|
||||
messageType: .VoiceMessage,
|
||||
startsThread: nil)
|
||||
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: true))))
|
||||
await voiceMessageRecorder.stopPlayback()
|
||||
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) {
|
||||
case .success:
|
||||
await deleteCurrentVoiceMessage()
|
||||
case .failure(let error):
|
||||
MXLog.error("failed to send the voice message. \(error)")
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: false))))
|
||||
actionsSubject.send(.displayErrorToast(L10n.errorFailedUploadingVoiceMessage))
|
||||
}
|
||||
}
|
||||
|
||||
func startPlayingRecordedVoiceMessage() async {
|
||||
await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState)
|
||||
if case .failure(let error) = await voiceMessageRecorder.startPlayback() {
|
||||
MXLog.error("failed to play recorded voice message. \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func pausePlayingRecordedVoiceMessage() {
|
||||
voiceMessageRecorder.pausePlayback()
|
||||
}
|
||||
|
||||
func seekRecordedVoiceMessage(to progress: Double) async {
|
||||
await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState)
|
||||
await voiceMessageRecorder.seekPlayback(to: progress)
|
||||
}
|
||||
|
||||
func scrubVoiceMessagePlayback(scrubbing: Bool) async {
|
||||
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState else {
|
||||
return
|
||||
}
|
||||
if scrubbing {
|
||||
if audioPlayerState.playbackState == .playing {
|
||||
resumeVoiceMessagePlaybackAfterScrubbing = true
|
||||
pausePlayingRecordedVoiceMessage()
|
||||
}
|
||||
} else {
|
||||
if resumeVoiceMessagePlaybackAfterScrubbing {
|
||||
resumeVoiceMessagePlaybackAfterScrubbing = false
|
||||
await startPlayingRecordedVoiceMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Audio Playback
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async {
|
||||
MXLog.info("Toggle play/pause audio for itemID \(itemID)")
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
|
||||
fatalError("TimelineItem \(itemID) not found")
|
||||
}
|
||||
|
||||
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
|
||||
fatalError("Invalid TimelineItem type for itemID \(itemID) (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
|
||||
}
|
||||
|
||||
guard let source = voiceMessageRoomTimelineItem.content.source else {
|
||||
MXLog.error("Cannot start voice message playback, source is not defined for itemID \(itemID)")
|
||||
return
|
||||
}
|
||||
|
||||
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
|
||||
MXLog.error("Cannot play a voice message without an audio player")
|
||||
return
|
||||
}
|
||||
|
||||
// Stop any recording in progress
|
||||
if voiceMessageRecorder.isRecording {
|
||||
await voiceMessageRecorder.stopRecording()
|
||||
}
|
||||
|
||||
guard let audioPlayerState = audioPlayerState(for: itemID) else {
|
||||
fatalError("Audio player state not found for \(itemID)")
|
||||
}
|
||||
|
||||
// Ensure this one is attached
|
||||
if !audioPlayerState.isAttached {
|
||||
audioPlayerState.attachAudioPlayer(audioPlayer)
|
||||
}
|
||||
|
||||
// Detach all other states
|
||||
await mediaPlayerProvider.detachAllStates(except: audioPlayerState)
|
||||
|
||||
guard audioPlayer.mediaSource == source, audioPlayer.state != .error else {
|
||||
// Load content
|
||||
do {
|
||||
MXLog.info("Loading voice message audio content from source for itemID \(itemID)")
|
||||
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil)
|
||||
|
||||
// Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded.
|
||||
if audioPlayerState.isAttached {
|
||||
audioPlayer.load(mediaSource: source, using: url, autoplay: true)
|
||||
}
|
||||
} catch {
|
||||
MXLog.error("Failed to load voice message: \(error)")
|
||||
audioPlayerState.reportError()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if audioPlayer.state == .playing {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
|
||||
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
|
||||
return
|
||||
}
|
||||
await mediaPlayerProvider.detachAllStates(except: playerState)
|
||||
await playerState.updateState(progress: progress)
|
||||
}
|
||||
|
||||
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState? {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
|
||||
MXLog.error("TimelineItem \(itemID) not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let voiceMessageRoomTimelineItem = timelineItem as? VoiceMessageRoomTimelineItem else {
|
||||
MXLog.error("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
|
||||
return nil
|
||||
}
|
||||
|
||||
if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) {
|
||||
return playerState
|
||||
}
|
||||
|
||||
let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID),
|
||||
duration: voiceMessageRoomTimelineItem.content.duration,
|
||||
waveform: voiceMessageRoomTimelineItem.content.waveform)
|
||||
mediaPlayerProvider.register(audioPlayerState: playerState)
|
||||
return playerState
|
||||
}
|
||||
|
||||
// MARK: Other
|
||||
|
||||
func displayEmojiPicker(for itemID: TimelineItemIdentifier) {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||
timelineItem.isReactable,
|
||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
let selectedEmojis = Set(eventTimelineItem.properties.reactions.compactMap { $0.isHighlighted ? $0.key : nil })
|
||||
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
|
||||
}
|
||||
|
||||
func displayRoomMemberDetails(userID: String) async {
|
||||
actionsSubject.send(.displayRoomMemberDetails(userID: userID))
|
||||
}
|
||||
|
||||
func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
|
||||
return .none
|
||||
}
|
||||
|
||||
switch timelineItem {
|
||||
case let item as LocationRoomTimelineItem:
|
||||
guard let geoURI = item.content.geoURI else { return .none }
|
||||
return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description)
|
||||
default:
|
||||
return await displayMediaActionIfPossible(timelineItem: timelineItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func buildReplyInfo(for item: EventBasedTimelineItemProtocol) -> ReplyInfo {
|
||||
switch item {
|
||||
case let messageItem as EventBasedMessageTimelineItemProtocol:
|
||||
return .init(type: .message(messageItem.contentType), isThread: messageItem.isThreaded)
|
||||
case let pollItem as PollRoomTimelineItem:
|
||||
return .init(type: .poll(question: pollItem.poll.question), isThread: false)
|
||||
default:
|
||||
return .init(type: .message(.text(.init(body: item.body))), isThread: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> RoomTimelineControllerAction {
|
||||
var source: MediaSourceProxy?
|
||||
var body: String
|
||||
|
||||
switch timelineItem {
|
||||
case let item as ImageRoomTimelineItem:
|
||||
source = item.content.source
|
||||
body = item.content.body
|
||||
case let item as VideoRoomTimelineItem:
|
||||
source = item.content.source
|
||||
body = item.content.body
|
||||
case let item as FileRoomTimelineItem:
|
||||
source = item.content.source
|
||||
body = item.content.body
|
||||
case let item as AudioRoomTimelineItem:
|
||||
// For now we are just displaying audio messages with the File preview until we create a timeline player for them.
|
||||
source = item.content.source
|
||||
body = item.content.body
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
|
||||
guard let source else { return .none }
|
||||
switch await mediaProvider.loadFileFromSource(source, body: body) {
|
||||
case .success(let file):
|
||||
return .displayMediaFile(file: file, title: body)
|
||||
case .failure:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReplyInfo {
|
||||
let type: TimelineEventContent
|
||||
let isThread: Bool
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
// 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.
|
||||
@@ -14,431 +14,19 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
enum RoomScreenViewModelAction {
|
||||
case displayRoomDetails
|
||||
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
|
||||
case displayCameraPicker
|
||||
case displayMediaPicker
|
||||
case displayDocumentPicker
|
||||
case displayLocationPicker
|
||||
case displayPollForm(mode: PollFormMode)
|
||||
case displayMediaUploadPreviewScreen(url: URL)
|
||||
case displayRoomMemberDetails(userID: String)
|
||||
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
case displayLocation(body: String, geoURI: GeoURI, description: String?)
|
||||
case composer(action: RoomScreenComposerAction)
|
||||
case displayCallScreen
|
||||
case displayPinnedEventsTimeline
|
||||
enum RoomScreenViewModelAction { }
|
||||
|
||||
enum RoomScreenViewAction { }
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
}
|
||||
|
||||
enum RoomScreenComposerMode: Equatable {
|
||||
case `default`
|
||||
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
|
||||
case edit(originalItemId: TimelineItemIdentifier)
|
||||
case recordVoiceMessage(state: AudioRecorderState)
|
||||
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)
|
||||
|
||||
var isEdit: Bool {
|
||||
switch self {
|
||||
case .edit:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isTextEditingEnabled: Bool {
|
||||
switch self {
|
||||
case .default, .reply, .edit:
|
||||
return true
|
||||
case .recordVoiceMessage, .previewVoiceMessage:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isLoadingReply: Bool {
|
||||
switch self {
|
||||
case .reply(_, let replyDetails, _):
|
||||
switch replyDetails {
|
||||
case .loading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var replyEventID: String? {
|
||||
switch self {
|
||||
case .reply(let itemID, _, _):
|
||||
return itemID.eventID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var isComposingNewMessage: Bool {
|
||||
switch self {
|
||||
case .default, .reply:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RoomScreenViewPollAction {
|
||||
case selectOption(pollStartID: String, optionID: String)
|
||||
case end(pollStartID: String)
|
||||
case edit(pollStartID: String, poll: Poll)
|
||||
}
|
||||
|
||||
enum RoomScreenAudioPlayerAction {
|
||||
case playPause(itemID: TimelineItemIdentifier)
|
||||
case seek(itemID: TimelineItemIdentifier, progress: Double)
|
||||
}
|
||||
|
||||
enum RoomScreenViewAction {
|
||||
case itemAppeared(itemID: TimelineItemIdentifier)
|
||||
case itemDisappeared(itemID: TimelineItemIdentifier)
|
||||
|
||||
case itemTapped(itemID: TimelineItemIdentifier)
|
||||
case itemSendInfoTapped(itemID: TimelineItemIdentifier)
|
||||
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
||||
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
|
||||
case paginateBackwards
|
||||
case paginateForwards
|
||||
case scrollToBottom
|
||||
|
||||
case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
|
||||
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
|
||||
|
||||
case displayRoomDetails
|
||||
case displayRoomMemberDetails(userID: String)
|
||||
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String)
|
||||
case displayEmojiPicker(itemID: TimelineItemIdentifier)
|
||||
case displayReadReceipts(itemID: TimelineItemIdentifier)
|
||||
case displayCall
|
||||
|
||||
case handlePasteOrDrop(provider: NSItemProvider)
|
||||
case handlePollAction(RoomScreenViewPollAction)
|
||||
case handleAudioPlayerAction(RoomScreenAudioPlayerAction)
|
||||
|
||||
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
|
||||
case focusOnEventID(String)
|
||||
/// Switch back to a live timeline (from a detached one).
|
||||
case focusLive
|
||||
/// The timeline scrolled to reveal the focussed item.
|
||||
case scrolledToFocussedItem
|
||||
/// The table view has loaded the first items for a new timeline.
|
||||
case hasSwitchedTimeline
|
||||
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case tappedPinnedEventsBanner
|
||||
case viewAllPins
|
||||
}
|
||||
struct RoomScreenViewStateBindings { }
|
||||
|
||||
enum RoomScreenComposerAction {
|
||||
case setMode(mode: RoomScreenComposerMode)
|
||||
case setText(plainText: String, htmlText: String?)
|
||||
case removeFocus
|
||||
case clear
|
||||
case saveDraft
|
||||
case loadDraft
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
var roomID: String
|
||||
var roomTitle = ""
|
||||
var roomAvatar: RoomAvatar
|
||||
var members: [String: RoomMemberState] = [:]
|
||||
var typingMembers: [String] = []
|
||||
var showLoading = false
|
||||
var showReadReceipts = false
|
||||
var isEncryptedOneToOneRoom = false
|
||||
var timelineViewState: TimelineViewState // check the doc before changing this
|
||||
|
||||
var ownUserID: String
|
||||
var canCurrentUserRedactOthers = false
|
||||
var canCurrentUserRedactSelf = false
|
||||
var canCurrentUserPin = false
|
||||
var isViewSourceEnabled: Bool
|
||||
|
||||
var isPinningEnabled = false
|
||||
var lastScrollDirection: ScrollDirection?
|
||||
|
||||
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
|
||||
// It's updated from the room info, so it's faster than using the timeline
|
||||
var pinnedEventIDs: Set<String> = []
|
||||
// This is used to control the banner
|
||||
var pinnedEventsBannerState: PinnedEventsBannerState = .loading(numbersOfEvents: 0)
|
||||
|
||||
var shouldShowPinnedEventsBanner: Bool {
|
||||
isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var canJoinCall = false
|
||||
var hasOngoingCall = false
|
||||
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
|
||||
/// A closure providing the associated audio player state for an item in the timeline.
|
||||
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
|
||||
}
|
||||
|
||||
struct RoomScreenViewStateBindings {
|
||||
var isScrolledToBottom = true
|
||||
|
||||
/// The state of wether reactions listed on the timeline are expanded/collapsed.
|
||||
/// Key is itemID, value is the collapsed state.
|
||||
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
|
||||
|
||||
/// A media item that will be previewed with QuickLook.
|
||||
var mediaPreviewItem: MediaPreviewItem?
|
||||
|
||||
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
|
||||
|
||||
var debugInfo: TimelineItemDebugInfo?
|
||||
|
||||
var actionMenuInfo: TimelineItemActionMenuInfo?
|
||||
|
||||
var reactionSummaryInfo: ReactionSummaryInfo?
|
||||
|
||||
var readReceiptsSummaryInfo: ReadReceiptSummaryInfo?
|
||||
}
|
||||
|
||||
struct TimelineItemActionMenuInfo: Equatable, Identifiable {
|
||||
static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
let item: EventBasedTimelineItemProtocol
|
||||
|
||||
var id: TimelineItemIdentifier {
|
||||
item.id
|
||||
}
|
||||
}
|
||||
|
||||
struct ReactionSummaryInfo: Identifiable {
|
||||
let reactions: [AggregatedReaction]
|
||||
let selectedKey: String
|
||||
|
||||
var id: String {
|
||||
selectedKey
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadReceiptSummaryInfo: Identifiable {
|
||||
let orderedReceipts: [ReadReceipt]
|
||||
let id: TimelineItemIdentifier
|
||||
}
|
||||
|
||||
enum RoomScreenAlertInfoType: Hashable {
|
||||
case audioRecodingPermissionError
|
||||
case pollEndConfirmation(String)
|
||||
case sendingFailed
|
||||
case encryptionAuthenticity(String)
|
||||
}
|
||||
|
||||
struct RoomMemberState {
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
}
|
||||
|
||||
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
|
||||
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
|
||||
struct TimelineViewState {
|
||||
var isLive = true
|
||||
var paginationState = PaginationState.initial
|
||||
|
||||
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
|
||||
var isSwitchingTimelines = false
|
||||
|
||||
struct FocussedEvent: Equatable {
|
||||
enum Appearance {
|
||||
/// The event should be shown using an animated scroll.
|
||||
case animated
|
||||
/// The event should be shown immediately, without any animation.
|
||||
case immediate
|
||||
/// The event has already been shown.
|
||||
case hasAppeared
|
||||
}
|
||||
|
||||
/// The ID of the event.
|
||||
let eventID: String
|
||||
/// How the event should be shown, or whether it has already appeared.
|
||||
var appearance: Appearance
|
||||
}
|
||||
|
||||
/// A focussed event that was navigated to via a permalink.
|
||||
var focussedEvent: FocussedEvent?
|
||||
|
||||
// These can be removed when we have full swiftUI and moved as @State values in the view
|
||||
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||
|
||||
var timelineIDs: [String] {
|
||||
itemsDictionary.keys.elements
|
||||
}
|
||||
|
||||
var itemViewStates: [RoomTimelineItemViewState] {
|
||||
itemsDictionary.values.elements
|
||||
}
|
||||
|
||||
func hasLoadedItem(with eventID: String) -> Bool {
|
||||
itemViewStates.contains { $0.identifier.eventID == eventID }
|
||||
}
|
||||
}
|
||||
|
||||
enum ScrollDirection: Equatable {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
struct PinnedEventsState: Equatable {
|
||||
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
|
||||
didSet {
|
||||
if selectedPinEventID == nil, !pinnedEventContents.keys.isEmpty {
|
||||
// The default selected event should always be the last one.
|
||||
selectedPinEventID = pinnedEventContents.keys.last
|
||||
} else if pinnedEventContents.isEmpty {
|
||||
selectedPinEventID = nil
|
||||
} else if let selectedPinEventID, !pinnedEventContents.keys.set.contains(selectedPinEventID) {
|
||||
self.selectedPinEventID = pinnedEventContents.keys.last
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var selectedPinEventID: String?
|
||||
|
||||
var selectedPinIndex: Int {
|
||||
let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1
|
||||
guard let selectedPinEventID else {
|
||||
return defaultValue
|
||||
}
|
||||
return pinnedEventContents.keys.firstIndex(of: selectedPinEventID) ?? defaultValue
|
||||
}
|
||||
|
||||
var selectedPinContent: AttributedString {
|
||||
var content = AttributedString(" ")
|
||||
if let selectedPinEventID,
|
||||
let pinnedEventContent = pinnedEventContents[selectedPinEventID] {
|
||||
content = pinnedEventContent
|
||||
}
|
||||
content.font = .compound.bodyMD
|
||||
content.link = nil
|
||||
return content
|
||||
}
|
||||
|
||||
mutating func previousPin() {
|
||||
guard !pinnedEventContents.isEmpty else {
|
||||
return
|
||||
}
|
||||
let currentIndex = selectedPinIndex
|
||||
let nextIndex = currentIndex - 1
|
||||
if nextIndex == -1 {
|
||||
selectedPinEventID = pinnedEventContents.keys.last
|
||||
} else {
|
||||
selectedPinEventID = pinnedEventContents.keys[nextIndex % pinnedEventContents.count]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PinnedEventsBannerState: Equatable {
|
||||
case loading(numbersOfEvents: Int)
|
||||
case loaded(state: PinnedEventsState)
|
||||
|
||||
var isEmpty: Bool {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.pinnedEventContents.isEmpty
|
||||
case .loading(let numberOfEvents):
|
||||
return numberOfEvents == 0
|
||||
}
|
||||
}
|
||||
|
||||
var isLoading: Bool {
|
||||
switch self {
|
||||
case .loading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var selectedPinEventID: String? {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.selectedPinEventID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.pinnedEventContents.count
|
||||
case .loading(let numberOfEvents):
|
||||
return numberOfEvents
|
||||
}
|
||||
}
|
||||
|
||||
var selectedPinIndex: Int {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.selectedPinIndex
|
||||
case .loading(let numbersOfEvents):
|
||||
// We always want the index to be the last one when loading, since is the default one.
|
||||
return numbersOfEvents - 1
|
||||
}
|
||||
}
|
||||
|
||||
var displayedMessage: AttributedString {
|
||||
switch self {
|
||||
case .loading:
|
||||
return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription)
|
||||
case .loaded(let state):
|
||||
return state.selectedPinContent
|
||||
}
|
||||
}
|
||||
|
||||
var bannerIndicatorDescription: AttributedString {
|
||||
let index = selectedPinIndex + 1
|
||||
let boldPlaceholder = "{bold}"
|
||||
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count))
|
||||
boldString.bold()
|
||||
finalString.replace(boldPlaceholder, with: boldString)
|
||||
return finalString
|
||||
}
|
||||
|
||||
mutating func previousPin() {
|
||||
switch self {
|
||||
case .loaded(var state):
|
||||
state.previousPin()
|
||||
self = .loaded(state: state)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mutating func setPinnedEventContents(_ pinnedEventContents: OrderedDictionary<String, AttributedString>) {
|
||||
switch self {
|
||||
case .loading:
|
||||
// The default selected event should always be the last one.
|
||||
self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last))
|
||||
case .loaded(var state):
|
||||
state.pinnedEventContents = pinnedEventContents
|
||||
self = .loaded(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
// 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.
|
||||
@@ -14,957 +14,37 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Algorithms
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
|
||||
|
||||
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
|
||||
private enum Constants {
|
||||
static let paginationEventLimit: UInt16 = 20
|
||||
static let detachedTimelineSize: UInt16 = 100
|
||||
static let focusTimelineToastIndicatorID = "RoomScreenFocusTimelineToastIndicator"
|
||||
static let toastErrorID = "RoomScreenToastError"
|
||||
}
|
||||
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let timelineController: RoomTimelineControllerProtocol
|
||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let analyticsService: AnalyticsService
|
||||
private let pinnedEventStringBuilder: RoomEventStringBuilder
|
||||
|
||||
private let roomScreenInteractionHandler: RoomScreenInteractionHandler
|
||||
|
||||
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var paginateBackwardsTask: Task<Void, Never>?
|
||||
private var paginateForwardsTask: Task<Void, Never>?
|
||||
|
||||
private var pinnedEventsTimelineProvider: RoomTimelineProviderProtocol? {
|
||||
didSet {
|
||||
guard let pinnedEventsTimelineProvider else {
|
||||
return
|
||||
}
|
||||
|
||||
buildPinnedEventContent(timelineItems: pinnedEventsTimelineProvider.itemProxies)
|
||||
pinnedEventsTimelineProvider.updatePublisher
|
||||
// When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing
|
||||
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] updatedItems, _ in
|
||||
guard let self else { return }
|
||||
buildPinnedEventContent(timelineItems: updatedItems)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
init(roomProxy: RoomProxyProtocol,
|
||||
focussedEventID: String? = nil,
|
||||
timelineController: RoomTimelineControllerProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appMediator: AppMediatorProtocol,
|
||||
appSettings: AppSettings,
|
||||
analyticsService: AnalyticsService) {
|
||||
self.timelineController = timelineController
|
||||
self.mediaPlayerProvider = mediaPlayerProvider
|
||||
self.roomProxy = roomProxy
|
||||
self.appSettings = appSettings
|
||||
self.analyticsService = analyticsService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
|
||||
|
||||
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
|
||||
|
||||
roomScreenInteractionHandler = RoomScreenInteractionHandler(roomProxy: roomProxy,
|
||||
timelineController: timelineController,
|
||||
mediaProvider: mediaProvider,
|
||||
mediaPlayerProvider: mediaPlayerProvider,
|
||||
voiceMessageMediaManager: voiceMessageMediaManager,
|
||||
voiceMessageRecorder: voiceMessageRecorder,
|
||||
userIndicatorController: userIndicatorController,
|
||||
appMediator: appMediator,
|
||||
appSettings: appSettings,
|
||||
analyticsService: analyticsService)
|
||||
|
||||
super.init(initialViewState: RoomScreenViewState(roomID: roomProxy.id,
|
||||
roomTitle: roomProxy.roomTitle,
|
||||
roomAvatar: roomProxy.avatar,
|
||||
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
|
||||
timelineViewState: TimelineViewState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
||||
ownUserID: roomProxy.ownUserID,
|
||||
isViewSourceEnabled: appSettings.viewSourceEnabled,
|
||||
hasOngoingCall: roomProxy.hasOngoingCall,
|
||||
bindings: .init(reactionsCollapsed: [:])),
|
||||
imageProvider: mediaProvider)
|
||||
|
||||
if focussedEventID != nil {
|
||||
// The timeline controller will start loading a detached timeline.
|
||||
showFocusLoadingIndicator()
|
||||
}
|
||||
|
||||
setupSubscriptions()
|
||||
setupDirectRoomSubscriptionsIfNeeded()
|
||||
|
||||
// Set initial values for redacting from the macOS context menu.
|
||||
Task { await updatePermissions() }
|
||||
|
||||
state.audioPlayerStateProvider = { [weak self] itemID -> AudioPlayerState? in
|
||||
guard let self else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.roomScreenInteractionHandler.audioPlayerState(for: itemID)
|
||||
}
|
||||
|
||||
buildTimelineViews(timelineItems: timelineController.timelineItems)
|
||||
|
||||
updateMembers(roomProxy.membersPublisher.value)
|
||||
|
||||
// Note: beware if we get to e.g. restore a reply / edit,
|
||||
// maybe we are tracking a non-needed first initial state
|
||||
trackComposerMode(.default)
|
||||
|
||||
Task {
|
||||
let userID = roomProxy.ownUserID
|
||||
if case let .success(permission) = await roomProxy.canUserJoinCall(userID: userID) {
|
||||
state.canJoinCall = permission
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func loadDraft() {
|
||||
actionsSubject.send(.composer(action: .loadDraft))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
|
||||
state.bindings.mediaPreviewItem = nil
|
||||
}
|
||||
|
||||
func saveDraft() {
|
||||
actionsSubject.send(.composer(action: .saveDraft))
|
||||
}
|
||||
|
||||
override func process(viewAction: RoomScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .itemAppeared(let id):
|
||||
Task { await timelineController.processItemAppearance(id) }
|
||||
case .itemDisappeared(let id):
|
||||
Task { await timelineController.processItemDisappearance(id) }
|
||||
|
||||
case .itemTapped(let id):
|
||||
Task { await handleItemTapped(with: id) }
|
||||
case .itemSendInfoTapped(let itemID):
|
||||
handleItemSendInfoTapped(itemID: itemID)
|
||||
case .toggleReaction(let emoji, let itemId):
|
||||
Task { await timelineController.toggleReaction(emoji, to: itemId) }
|
||||
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
||||
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
|
||||
case .paginateBackwards:
|
||||
paginateBackwards()
|
||||
case .paginateForwards:
|
||||
paginateForwards()
|
||||
case .scrollToBottom:
|
||||
scrollToBottom()
|
||||
|
||||
case .displayTimelineItemMenu(let itemID):
|
||||
roomScreenInteractionHandler.displayTimelineItemActionMenu(for: itemID)
|
||||
case .handleTimelineItemMenuAction(let itemID, let action):
|
||||
roomScreenInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID)
|
||||
|
||||
case .displayRoomDetails:
|
||||
actionsSubject.send(.displayRoomDetails)
|
||||
case .displayRoomMemberDetails(userID: let userID):
|
||||
Task { await roomScreenInteractionHandler.displayRoomMemberDetails(userID: userID) }
|
||||
case .displayEmojiPicker(let itemID):
|
||||
roomScreenInteractionHandler.displayEmojiPicker(for: itemID)
|
||||
case .displayReactionSummary(let itemID, let key):
|
||||
displayReactionSummary(for: itemID, selectedKey: key)
|
||||
case .displayReadReceipts(itemID: let itemID):
|
||||
displayReadReceipts(for: itemID)
|
||||
case .displayCall:
|
||||
actionsSubject.send(.displayCallScreen)
|
||||
analyticsService.trackInteraction(name: .MobileRoomCallButton)
|
||||
case .handlePasteOrDrop(let provider):
|
||||
roomScreenInteractionHandler.handlePasteOrDrop(provider)
|
||||
case .handlePollAction(let pollAction):
|
||||
handlePollAction(pollAction)
|
||||
case .handleAudioPlayerAction(let audioPlayerAction):
|
||||
handleAudioPlayerAction(audioPlayerAction)
|
||||
|
||||
case .focusOnEventID(let eventID):
|
||||
Task { await focusOnEvent(eventID: eventID) }
|
||||
case .focusLive:
|
||||
focusLive()
|
||||
case .scrolledToFocussedItem:
|
||||
didScrollToFocussedItem()
|
||||
case .hasSwitchedTimeline:
|
||||
Task { state.timelineViewState.isSwitchingTimelines = false }
|
||||
case let .hasScrolled(direction):
|
||||
state.lastScrollDirection = direction
|
||||
case .tappedPinnedEventsBanner:
|
||||
if let eventID = state.pinnedEventsBannerState.selectedPinEventID {
|
||||
Task { await focusOnEvent(eventID: eventID) }
|
||||
}
|
||||
state.pinnedEventsBannerState.previousPin()
|
||||
case .viewAllPins:
|
||||
actionsSubject.send(.displayPinnedEventsTimeline)
|
||||
}
|
||||
}
|
||||
|
||||
func process(composerAction: ComposerToolbarViewModelAction) {
|
||||
switch composerAction {
|
||||
case .sendMessage(let message, let html, let mode, let intentionalMentions):
|
||||
Task {
|
||||
await sendCurrentMessage(message,
|
||||
html: html,
|
||||
mode: mode,
|
||||
intentionalMentions: intentionalMentions)
|
||||
}
|
||||
case .editLastMessage:
|
||||
editLastMessage()
|
||||
case .attach(let attachment):
|
||||
attach(attachment)
|
||||
case .handlePasteOrDrop(let provider):
|
||||
roomScreenInteractionHandler.handlePasteOrDrop(provider)
|
||||
case .composerModeChanged(mode: let mode):
|
||||
trackComposerMode(mode)
|
||||
case .composerFocusedChanged(isFocused: let isFocused):
|
||||
composerFocusedSubject.send(isFocused)
|
||||
case .voiceMessage(let voiceMessageAction):
|
||||
processVoiceMessageAction(voiceMessageAction)
|
||||
case .contentChanged(let isEmpty):
|
||||
guard appSettings.sharePresence else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await roomProxy.sendTypingNotification(isTyping: !isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func focusOnEvent(eventID: String) async {
|
||||
if state.timelineViewState.hasLoadedItem(with: eventID) {
|
||||
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .animated)
|
||||
return
|
||||
}
|
||||
|
||||
showFocusLoadingIndicator()
|
||||
defer { hideFocusLoadingIndicator() }
|
||||
|
||||
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
|
||||
case .success:
|
||||
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .immediate)
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed to focus on event \(eventID)")
|
||||
|
||||
if case .eventNotFound = error {
|
||||
displayErrorToast(L10n.errorMessageNotFound)
|
||||
} else {
|
||||
displayErrorToast(L10n.commonFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func focusLive() {
|
||||
timelineController.focusLive()
|
||||
}
|
||||
|
||||
private func didScrollToFocussedItem() {
|
||||
if var focussedEvent = state.timelineViewState.focussedEvent {
|
||||
focussedEvent.appearance = .hasAppeared
|
||||
state.timelineViewState.focussedEvent = focussedEvent
|
||||
hideFocusLoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
private func editLastMessage() {
|
||||
guard let item = timelineController.timelineItems.reversed().first(where: {
|
||||
guard let item = $0 as? EventBasedMessageTimelineItemProtocol else {
|
||||
return false
|
||||
}
|
||||
|
||||
return item.sender.id == roomProxy.ownUserID && item.isEditable
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
|
||||
roomScreenInteractionHandler.handleTimelineItemMenuAction(.edit, itemID: item.id)
|
||||
}
|
||||
|
||||
private func attach(_ attachment: ComposerAttachmentType) {
|
||||
switch attachment {
|
||||
case .camera:
|
||||
actionsSubject.send(.displayCameraPicker)
|
||||
case .photoLibrary:
|
||||
actionsSubject.send(.displayMediaPicker)
|
||||
case .file:
|
||||
actionsSubject.send(.displayDocumentPicker)
|
||||
case .location:
|
||||
actionsSubject.send(.displayLocationPicker)
|
||||
case .poll:
|
||||
actionsSubject.send(.displayPollForm(mode: .new))
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePollAction(_ action: RoomScreenViewPollAction) {
|
||||
switch action {
|
||||
case let .selectOption(pollStartID, optionID):
|
||||
roomScreenInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
|
||||
case let .end(pollStartID):
|
||||
displayAlert(.pollEndConfirmation(pollStartID))
|
||||
case .edit(let pollStartID, let poll):
|
||||
actionsSubject.send(.displayPollForm(mode: .edit(eventID: pollStartID, poll: poll)))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioPlayerAction(_ action: RoomScreenAudioPlayerAction) {
|
||||
switch action {
|
||||
case .playPause(let itemID):
|
||||
Task { await roomScreenInteractionHandler.playPauseAudio(for: itemID) }
|
||||
case .seek(let itemID, let progress):
|
||||
Task { await roomScreenInteractionHandler.seekAudio(for: itemID, progress: progress) }
|
||||
}
|
||||
}
|
||||
|
||||
private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) {
|
||||
switch action {
|
||||
case .startRecording:
|
||||
Task {
|
||||
await mediaPlayerProvider.detachAllStates(except: nil)
|
||||
await roomScreenInteractionHandler.startRecordingVoiceMessage()
|
||||
}
|
||||
case .stopRecording:
|
||||
Task { await roomScreenInteractionHandler.stopRecordingVoiceMessage() }
|
||||
case .cancelRecording:
|
||||
Task { await roomScreenInteractionHandler.cancelRecordingVoiceMessage() }
|
||||
case .deleteRecording:
|
||||
Task { await roomScreenInteractionHandler.deleteCurrentVoiceMessage() }
|
||||
case .send:
|
||||
Task { await roomScreenInteractionHandler.sendCurrentVoiceMessage() }
|
||||
case .startPlayback:
|
||||
Task { await roomScreenInteractionHandler.startPlayingRecordedVoiceMessage() }
|
||||
case .pausePlayback:
|
||||
roomScreenInteractionHandler.pausePlayingRecordedVoiceMessage()
|
||||
case .seekPlayback(let progress):
|
||||
Task { await roomScreenInteractionHandler.seekRecordedVoiceMessage(to: progress) }
|
||||
case .scrubPlayback(let scrubbing):
|
||||
Task { await roomScreenInteractionHandler.scrubVoiceMessagePlayback(scrubbing: scrubbing) }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMembers(_ members: [RoomMemberProxyProtocol]) {
|
||||
state.members = members.reduce(into: [String: RoomMemberState]()) { dictionary, member in
|
||||
dictionary[member.userID] = RoomMemberState(displayName: member.displayName, avatarURL: member.avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePermissions() async {
|
||||
if case let .success(value) = await roomProxy.canUserRedactOther(userID: roomProxy.ownUserID) {
|
||||
state.canCurrentUserRedactOthers = value
|
||||
} else {
|
||||
state.canCurrentUserRedactOthers = false
|
||||
}
|
||||
|
||||
if case let .success(value) = await roomProxy.canUserRedactOwn(userID: roomProxy.ownUserID) {
|
||||
state.canCurrentUserRedactSelf = value
|
||||
} else {
|
||||
state.canCurrentUserRedactSelf = false
|
||||
}
|
||||
|
||||
if state.isPinningEnabled,
|
||||
case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) {
|
||||
state.canCurrentUserPin = value
|
||||
} else {
|
||||
state.canCurrentUserPin = false
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSubscriptions() {
|
||||
timelineController.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
guard let self else { return }
|
||||
|
||||
switch callback {
|
||||
case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines):
|
||||
buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines)
|
||||
case .paginationState(let paginationState):
|
||||
if state.timelineViewState.paginationState != paginationState {
|
||||
state.timelineViewState.paginationState = paginationState
|
||||
}
|
||||
case .isLive(let isLive):
|
||||
if state.timelineViewState.isLive != isLive {
|
||||
state.timelineViewState.isLive = isLive
|
||||
|
||||
// Remove the event highlight *only* when transitioning from non-live to live.
|
||||
if isLive, state.timelineViewState.focussedEvent != nil {
|
||||
state.timelineViewState.focussedEvent = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let roomInfoSubscription = roomProxy
|
||||
.actionsPublisher
|
||||
.filter { $0 == .roomInfoUpdate }
|
||||
|
||||
roomInfoSubscription
|
||||
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
state.roomTitle = roomProxy.roomTitle
|
||||
state.roomAvatar = roomProxy.avatar
|
||||
state.hasOngoingCall = roomProxy.hasOngoingCall
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task { [weak self] in
|
||||
// Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle.
|
||||
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
|
||||
await self?.updatePinnedEventIDs()
|
||||
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await self?.updatePinnedEventIDs()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupAppSettingsSubscriptions()
|
||||
|
||||
roomProxy.membersPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] in self?.updateMembers($0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomProxy.typingMembersPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.filter { [weak self] _ in self?.appSettings.sharePresence ?? false }
|
||||
.weakAssign(to: \.state.typingMembers, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomScreenInteractionHandler.actions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .composer(let action):
|
||||
actionsSubject.send(.composer(action: action))
|
||||
case .displayAudioRecorderPermissionError:
|
||||
displayAlert(.audioRecodingPermissionError)
|
||||
case .displayErrorToast(let title):
|
||||
displayErrorToast(title)
|
||||
case .displayEmojiPicker(let itemID, let selectedEmojis):
|
||||
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
|
||||
case .displayMessageForwarding(let itemID):
|
||||
Task { await self.forwardMessage(itemID: itemID) }
|
||||
case .displayPollForm(let mode):
|
||||
actionsSubject.send(.displayPollForm(mode: mode))
|
||||
case .displayReportContent(let itemID, let senderID):
|
||||
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
|
||||
case .displayMediaUploadPreviewScreen(let url):
|
||||
actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
|
||||
case .displayRoomMemberDetails(userID: let userID):
|
||||
actionsSubject.send(.displayRoomMemberDetails(userID: userID))
|
||||
case .showActionMenu(let actionMenuInfo):
|
||||
Task {
|
||||
await self.updatePermissions()
|
||||
self.state.bindings.actionMenuInfo = actionMenuInfo
|
||||
}
|
||||
case .showDebugInfo(let debugInfo):
|
||||
state.bindings.debugInfo = debugInfo
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$pinningEnabled
|
||||
.combineLatest(appMediator.networkMonitor.reachabilityPublisher)
|
||||
.filter { $0.0 && $0.1 == .reachable }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.setupPinnedEventsTimelineProviderIfNeeded()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func setupAppSettingsSubscriptions() {
|
||||
appSettings.$sharePresence
|
||||
.weakAssign(to: \.state.showReadReceipts, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$viewSourceEnabled
|
||||
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$pinningEnabled
|
||||
.weakAssign(to: \.state.isPinningEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func setupPinnedEventsTimelineProviderIfNeeded() {
|
||||
guard pinnedEventsTimelineProvider == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
guard let timelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else {
|
||||
return
|
||||
}
|
||||
|
||||
if pinnedEventsTimelineProvider == nil {
|
||||
pinnedEventsTimelineProvider = timelineProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePinnedEventIDs() async {
|
||||
let pinnedEventIDs = await roomProxy.pinnedEventIDs
|
||||
// Only update the loading state of the banner
|
||||
if state.pinnedEventsBannerState.isLoading {
|
||||
state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count)
|
||||
}
|
||||
state.pinnedEventIDs = pinnedEventIDs
|
||||
}
|
||||
|
||||
private func setupDirectRoomSubscriptionsIfNeeded() {
|
||||
guard roomProxy.isDirect else {
|
||||
return
|
||||
}
|
||||
|
||||
let shouldShowInviteAlert = composerFocusedSubject
|
||||
.removeDuplicates()
|
||||
.map { [weak self] isFocused in
|
||||
guard let self else { return false }
|
||||
|
||||
return isFocused && self.roomProxy.isUserAloneInDirectRoom
|
||||
}
|
||||
// We want to show the alert just once, so we are taking the first "true" emitted
|
||||
.first { $0 }
|
||||
|
||||
shouldShowInviteAlert
|
||||
.sink { [weak self] _ in
|
||||
self?.showInviteAlert()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func paginateBackwards() {
|
||||
guard paginateBackwardsTask == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
paginateBackwardsTask = Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch await timelineController.paginateBackwards(requestSize: Constants.paginationEventLimit) {
|
||||
case .failure:
|
||||
displayErrorToast(L10n.errorFailedLoadingMessages)
|
||||
default:
|
||||
break
|
||||
}
|
||||
paginateBackwardsTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func paginateForwards() {
|
||||
guard paginateForwardsTask == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
paginateForwardsTask = Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch await timelineController.paginateForwards(requestSize: Constants.paginationEventLimit) {
|
||||
case .failure:
|
||||
displayErrorToast(L10n.errorFailedLoadingMessages)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if state.timelineViewState.paginationState.forward == .timelineEndReached {
|
||||
focusLive()
|
||||
}
|
||||
|
||||
paginateForwardsTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom() {
|
||||
if state.timelineViewState.isLive {
|
||||
state.timelineViewState.scrollToBottomPublisher.send(())
|
||||
} else {
|
||||
focusLive()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
|
||||
guard appMediator.appState == .active else { return }
|
||||
|
||||
await timelineController.sendReadReceipt(for: lastVisibleItemID)
|
||||
}
|
||||
|
||||
private func handleItemTapped(with itemID: TimelineItemIdentifier) async {
|
||||
state.showLoading = true
|
||||
let action = await roomScreenInteractionHandler.processItemTap(itemID)
|
||||
|
||||
switch action {
|
||||
case .displayMediaFile(let file, let title):
|
||||
actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview.
|
||||
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title)
|
||||
case .displayLocation(let body, let geoURI, let description):
|
||||
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
state.showLoading = false
|
||||
}
|
||||
|
||||
private func handleItemSendInfoTapped(itemID: TimelineItemIdentifier) {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
|
||||
MXLog.warning("Couldn't find timeline item.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
fatalError("Only events can have send info.")
|
||||
}
|
||||
|
||||
if eventTimelineItem.properties.deliveryStatus == .sendingFailed {
|
||||
displayAlert(.sendingFailed)
|
||||
} else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message {
|
||||
displayAlert(.encryptionAuthenticity(authenticityMessage))
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions) async {
|
||||
guard !message.isEmpty else {
|
||||
fatalError("This message should never be empty")
|
||||
}
|
||||
|
||||
actionsSubject.send(.composer(action: .clear))
|
||||
|
||||
switch mode {
|
||||
case .reply(let itemId, _, _):
|
||||
await timelineController.sendMessage(message,
|
||||
html: html,
|
||||
inReplyTo: itemId,
|
||||
intentionalMentions: intentionalMentions)
|
||||
case .edit(let originalItemId):
|
||||
await timelineController.edit(originalItemId,
|
||||
message: message,
|
||||
html: html,
|
||||
intentionalMentions: intentionalMentions)
|
||||
case .default:
|
||||
await timelineController.sendMessage(message,
|
||||
html: html,
|
||||
intentionalMentions: intentionalMentions)
|
||||
case .recordVoiceMessage, .previewVoiceMessage:
|
||||
fatalError("invalid composer mode.")
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
|
||||
var isEdit = false
|
||||
var isReply = false
|
||||
switch mode {
|
||||
case .edit:
|
||||
isEdit = true
|
||||
case .reply:
|
||||
isReply = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
|
||||
}
|
||||
|
||||
// MARK: - Timeline Item Building
|
||||
|
||||
private func buildPinnedEventContent(timelineItems: [TimelineItemProxy]) {
|
||||
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
|
||||
|
||||
for item in timelineItems {
|
||||
// Only remote events are pinned
|
||||
if case let .event(event) = item,
|
||||
let eventID = event.id.eventID {
|
||||
pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent),
|
||||
forKey: eventID)
|
||||
}
|
||||
}
|
||||
|
||||
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
|
||||
}
|
||||
|
||||
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||
|
||||
timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in
|
||||
// Stops the audio player when a voice message is redacted.
|
||||
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
playerState.detachAudioPlayer()
|
||||
mediaPlayerProvider.unregister(audioPlayerState: playerState)
|
||||
}
|
||||
}
|
||||
|
||||
let itemsGroupedByTimelineDisplayStyle = timelineItems.chunked { current, next in
|
||||
canGroupItem(timelineItem: current, with: next)
|
||||
}
|
||||
|
||||
for itemGroup in itemsGroupedByTimelineDisplayStyle {
|
||||
guard !itemGroup.isEmpty else {
|
||||
MXLog.error("Found empty item group")
|
||||
continue
|
||||
}
|
||||
|
||||
if itemGroup.count == 1 {
|
||||
if let firstItem = itemGroup.first {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: firstItem, groupStyle: .single),
|
||||
forKey: firstItem.id.timelineID)
|
||||
}
|
||||
} else {
|
||||
for (index, item) in itemGroup.enumerated() {
|
||||
if index == 0 {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first),
|
||||
forKey: item.id.timelineID)
|
||||
} else if index == itemGroup.count - 1 {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .last),
|
||||
forKey: item.id.timelineID)
|
||||
} else {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle),
|
||||
forKey: item.id.timelineID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isSwitchingTimelines {
|
||||
state.timelineViewState.isSwitchingTimelines = true
|
||||
}
|
||||
|
||||
state.timelineViewState.itemsDictionary = timelineItemsDictionary
|
||||
}
|
||||
|
||||
private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
|
||||
if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] {
|
||||
timelineItemViewState.groupStyle = groupStyle
|
||||
timelineItemViewState.type = .init(item: item)
|
||||
return timelineItemViewState
|
||||
} else {
|
||||
return RoomTimelineItemViewState(item: item, groupStyle: groupStyle)
|
||||
}
|
||||
}
|
||||
|
||||
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
|
||||
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol,
|
||||
let otherEventTimelineItem = otherTimelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return false
|
||||
}
|
||||
|
||||
// State events aren't rendered as messages so shouldn't be grouped.
|
||||
if eventTimelineItem is StateRoomTimelineItem || otherEventTimelineItem is StateRoomTimelineItem {
|
||||
return false
|
||||
}
|
||||
|
||||
// can be improved by adding a date threshold
|
||||
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
|
||||
}
|
||||
|
||||
// MARK: - Direct chats logics
|
||||
|
||||
private func showInviteAlert() {
|
||||
userIndicatorController.alertInfo = .init(id: .init(),
|
||||
title: L10n.screenRoomInviteAgainAlertTitle,
|
||||
message: L10n.screenRoomInviteAgainAlertMessage,
|
||||
primaryButton: .init(title: L10n.actionInvite, action: { [weak self] in self?.inviteOtherDMUserBack() }),
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
}
|
||||
|
||||
private let inviteLoadingIndicatorID = UUID().uuidString
|
||||
|
||||
private func inviteOtherDMUserBack() {
|
||||
guard roomProxy.isUserAloneInDirectRoom else {
|
||||
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
userIndicatorController.submitIndicator(.init(id: inviteLoadingIndicatorID, type: .toast, title: L10n.commonLoading))
|
||||
defer {
|
||||
userIndicatorController.retractIndicatorWithId(inviteLoadingIndicatorID)
|
||||
}
|
||||
|
||||
guard
|
||||
let members = await roomProxy.members(),
|
||||
members.count == 2,
|
||||
let otherPerson = members.first(where: { $0.userID != roomProxy.ownUserID && $0.membership == .leave })
|
||||
else {
|
||||
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
|
||||
return
|
||||
}
|
||||
|
||||
switch await roomProxy.invite(userID: otherPerson.userID) {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
userIndicatorController.alertInfo = .init(id: .init(),
|
||||
title: L10n.commonUnableToInviteTitle,
|
||||
message: L10n.commonUnableToInviteMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reactions
|
||||
|
||||
private func displayReactionSummary(for itemID: TimelineItemIdentifier, selectedKey: String) {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
|
||||
state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey)
|
||||
}
|
||||
|
||||
// MARK: - Read Receipts
|
||||
|
||||
private func displayReadReceipts(for itemID: TimelineItemIdentifier) {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
|
||||
state.bindings.readReceiptsSummaryInfo = .init(orderedReceipts: eventTimelineItem.properties.orderedReadReceipts, id: eventTimelineItem.id)
|
||||
}
|
||||
|
||||
// MARK: - Message forwarding
|
||||
|
||||
private func forwardMessage(itemID: TimelineItemIdentifier) async {
|
||||
guard let content = await timelineController.messageEventContent(for: itemID) else { return }
|
||||
actionsSubject.send(.displayMessageForwarding(forwardingItem: .init(id: itemID, roomID: roomProxy.id, content: content)))
|
||||
}
|
||||
|
||||
// MARK: - User Indicators
|
||||
|
||||
private func showFocusLoadingIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: Constants.focusTimelineToastIndicatorID,
|
||||
type: .toast(progress: .indeterminate),
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func hideFocusLoadingIndicator() {
|
||||
userIndicatorController.retractIndicatorWithId(Constants.focusTimelineToastIndicatorID)
|
||||
}
|
||||
|
||||
private func displayAlert(_ type: RoomScreenAlertInfoType) {
|
||||
switch type {
|
||||
case .audioRecodingPermissionError:
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName),
|
||||
message: L10n.dialogPermissionMicrophoneDescriptionIos,
|
||||
primaryButton: .init(title: L10n.commonSettings, action: { [weak self] in self?.appMediator.openAppSettings() }),
|
||||
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
|
||||
case .pollEndConfirmation(let pollStartID):
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: L10n.actionEndPoll,
|
||||
message: L10n.commonPollEndConfirmation,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.roomScreenInteractionHandler.endPoll(pollStartID: pollStartID) }))
|
||||
case .sendingFailed:
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: L10n.commonSendingFailed,
|
||||
primaryButton: .init(title: L10n.actionOk, action: nil))
|
||||
case .encryptionAuthenticity(let message):
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: message,
|
||||
primaryButton: .init(title: L10n.actionOk, action: nil))
|
||||
}
|
||||
}
|
||||
|
||||
private func displayErrorToast(_ title: String) {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
|
||||
type: .toast,
|
||||
title: title,
|
||||
iconName: "xmark"))
|
||||
init() {
|
||||
super.init(initialViewState: .init(bindings: .init()))
|
||||
}
|
||||
}
|
||||
|
||||
private extension RoomProxyProtocol {
|
||||
/// Checks if the other person left the room in a direct chat
|
||||
var isUserAloneInDirectRoom: Bool {
|
||||
isDirect && activeMembersCount == 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
extension RoomScreenViewModel {
|
||||
static let mock = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
|
||||
focussedEventID: nil,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
static func mock() -> RoomScreenViewModel {
|
||||
RoomScreenViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoomContextKey: EnvironmentKey {
|
||||
@MainActor static let defaultValue: RoomScreenViewModel.Context? = nil
|
||||
}
|
||||
|
||||
private struct FocussedEventID: EnvironmentKey {
|
||||
static let defaultValue: String? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Used to access and inject the room context without observing it
|
||||
var roomContext: RoomScreenViewModel.Context? {
|
||||
get { self[RoomContextKey.self] }
|
||||
set { self[RoomContextKey.self] = newValue }
|
||||
}
|
||||
|
||||
/// An event ID which will be non-nil when a timeline item should show as focussed.
|
||||
var focussedEventID: String? {
|
||||
get { self[FocussedEventID.self] }
|
||||
set { self[FocussedEventID.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
// 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.
|
||||
@@ -16,16 +16,8 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
protocol RoomScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get }
|
||||
var context: RoomScreenViewModelType.Context { get }
|
||||
func process(composerAction: ComposerToolbarViewModelAction)
|
||||
/// Updates the timeline to show and highlight the item with the corresponding event ID.
|
||||
func focusOnEvent(eventID: String) async
|
||||
func stop()
|
||||
func loadDraft()
|
||||
func saveDraft()
|
||||
var context: RoomScreenViewModel.Context { get }
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel(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)
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,17 @@ import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct RoomScreen: View {
|
||||
@ObservedObject var context: RoomScreenViewModel.Context
|
||||
@ObservedObject var roomContext: RoomScreenViewModel.Context
|
||||
@ObservedObject var timelineContext: TimelineViewModel.Context
|
||||
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModel.Context
|
||||
@State private var dragOver = false
|
||||
let composerToolbar: ComposerToolbar
|
||||
|
||||
init(context: RoomScreenViewModel.Context, composerToolbar: ComposerToolbar) {
|
||||
self.context = context
|
||||
init(roomViewModel: RoomScreenViewModelProtocol,
|
||||
timelineViewModel: TimelineViewModelProtocol,
|
||||
composerToolbar: ComposerToolbar) {
|
||||
roomContext = roomViewModel.context
|
||||
timelineContext = timelineViewModel.context
|
||||
self.composerToolbar = composerToolbar
|
||||
composerToolbarContext = composerToolbar.context
|
||||
}
|
||||
@@ -45,16 +49,16 @@ struct RoomScreen: View {
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.environmentObject(context)
|
||||
.environment(\.roomContext, context)
|
||||
.environmentObject(timelineContext)
|
||||
.environment(\.timelineContext, timelineContext)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Group {
|
||||
if context.viewState.shouldShowPinnedEventsBanner {
|
||||
if timelineContext.viewState.shouldShowPinnedEventsBanner {
|
||||
pinnedItemsBanner
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: context.viewState.shouldShowPinnedEventsBanner)
|
||||
.animation(.elementDefault, value: timelineContext.viewState.shouldShowPinnedEventsBanner)
|
||||
}
|
||||
.navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text.
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -62,30 +66,30 @@ struct RoomScreen: View {
|
||||
.toolbar { toolbar }
|
||||
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
|
||||
.overlay { loadingIndicator }
|
||||
.alert(item: $context.alertInfo)
|
||||
.sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) }
|
||||
.sheet(item: $context.actionMenuInfo) { info in
|
||||
.alert(item: $timelineContext.alertInfo)
|
||||
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
|
||||
.sheet(item: $timelineContext.actionMenuInfo) { info in
|
||||
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
|
||||
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventIDs,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled).makeActions()
|
||||
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
|
||||
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled).makeActions()
|
||||
if let actions {
|
||||
TimelineItemMenu(item: info.item, actions: actions)
|
||||
.environmentObject(context)
|
||||
.environmentObject(timelineContext)
|
||||
}
|
||||
}
|
||||
.sheet(item: $context.reactionSummaryInfo) {
|
||||
ReactionsSummaryView(reactions: $0.reactions, members: context.viewState.members, imageProvider: context.imageProvider, selectedReactionKey: $0.selectedKey)
|
||||
.sheet(item: $timelineContext.reactionSummaryInfo) {
|
||||
ReactionsSummaryView(reactions: $0.reactions, members: timelineContext.viewState.members, imageProvider: timelineContext.imageProvider, selectedReactionKey: $0.selectedKey)
|
||||
.edgesIgnoringSafeArea([.bottom])
|
||||
}
|
||||
.sheet(item: $context.readReceiptsSummaryInfo) {
|
||||
.sheet(item: $timelineContext.readReceiptsSummaryInfo) {
|
||||
ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts)
|
||||
.environmentObject(context)
|
||||
.environmentObject(timelineContext)
|
||||
}
|
||||
.interactiveQuickLook(item: $context.mediaPreviewItem)
|
||||
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
|
||||
.track(screen: .Room)
|
||||
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
|
||||
guard let provider = providers.first,
|
||||
@@ -93,7 +97,7 @@ struct RoomScreen: View {
|
||||
return false
|
||||
}
|
||||
|
||||
context.send(viewAction: .handlePasteOrDrop(provider: provider))
|
||||
timelineContext.send(viewAction: .handlePasteOrDrop(provider: provider))
|
||||
return true
|
||||
}
|
||||
.sentryTrace("\(Self.self)")
|
||||
@@ -101,23 +105,23 @@ struct RoomScreen: View {
|
||||
|
||||
private var timeline: some View {
|
||||
TimelineView()
|
||||
.id(context.viewState.roomID)
|
||||
.environmentObject(context)
|
||||
.environment(\.focussedEventID, context.viewState.timelineViewState.focussedEvent?.eventID)
|
||||
.id(timelineContext.viewState.roomID)
|
||||
.environmentObject(timelineContext)
|
||||
.environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
scrollToBottomButton
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedItemsBanner: some View {
|
||||
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
|
||||
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
onViewAllButtonTap: { context.send(viewAction: .viewAllPins) })
|
||||
PinnedItemsBannerView(state: timelineContext.viewState.pinnedEventsBannerState,
|
||||
onMainButtonTap: { timelineContext.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
onViewAllButtonTap: { timelineContext.send(viewAction: .viewAllPins) })
|
||||
.transition(.move(edge: .top))
|
||||
}
|
||||
|
||||
private var scrollToBottomButton: some View {
|
||||
Button { context.send(viewAction: .scrollToBottom) } label: {
|
||||
Button { timelineContext.send(viewAction: .scrollToBottom) } label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.compound.bodyLG)
|
||||
.fontWeight(.semibold)
|
||||
@@ -139,12 +143,12 @@ struct RoomScreen: View {
|
||||
}
|
||||
|
||||
private var isAtBottomAndLive: Bool {
|
||||
context.isScrolledToBottom && context.viewState.timelineViewState.isLive
|
||||
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineViewState.isLive
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingIndicator: some View {
|
||||
if context.viewState.showLoading {
|
||||
if timelineContext.viewState.showLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.compound.textPrimary)
|
||||
@@ -159,29 +163,29 @@ struct RoomScreen: View {
|
||||
// .principal + .primaryAction works better than .navigation leading + trailing
|
||||
// as the latter disables interaction in the action button for rooms with long names
|
||||
ToolbarItem(placement: .principal) {
|
||||
RoomHeaderView(roomName: context.viewState.roomTitle,
|
||||
roomAvatar: context.viewState.roomAvatar,
|
||||
imageProvider: context.imageProvider)
|
||||
RoomHeaderView(roomName: timelineContext.viewState.roomTitle,
|
||||
roomAvatar: timelineContext.viewState.roomAvatar,
|
||||
imageProvider: timelineContext.imageProvider)
|
||||
// Using a button stops it from getting truncated in the navigation bar
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .displayRoomDetails)
|
||||
timelineContext.send(viewAction: .displayRoomDetails)
|
||||
}
|
||||
}
|
||||
|
||||
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
callButton
|
||||
.disabled(context.viewState.canJoinCall == false)
|
||||
.disabled(timelineContext.viewState.canJoinCall == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var callButton: some View {
|
||||
if context.viewState.hasOngoingCall {
|
||||
if timelineContext.viewState.hasOngoingCall {
|
||||
Button {
|
||||
context.send(viewAction: .displayCall)
|
||||
timelineContext.send(viewAction: .displayCall)
|
||||
} label: {
|
||||
Label(L10n.actionJoin, icon: \.videoCallSolid)
|
||||
.labelStyle(.titleAndIcon)
|
||||
@@ -190,7 +194,7 @@ struct RoomScreen: View {
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
|
||||
} else {
|
||||
Button {
|
||||
context.send(viewAction: .displayCall)
|
||||
timelineContext.send(viewAction: .displayCall)
|
||||
} label: {
|
||||
CompoundIcon(\.videoCallSolid)
|
||||
}
|
||||
@@ -206,21 +210,24 @@ struct RoomScreen: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
|
||||
name: "Preview room",
|
||||
hasOngoingCall: true)),
|
||||
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 roomViewModel = RoomScreenViewModel.mock()
|
||||
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
|
||||
name: "Preview room",
|
||||
hasOngoingCall: true)),
|
||||
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(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
RoomScreen(roomViewModel: roomViewModel,
|
||||
timelineViewModel: timelineViewModel,
|
||||
composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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: RoomScreenViewModel.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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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")
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.Context
|
||||
let itemID: TimelineItemIdentifier
|
||||
let reactions: [AggregatedReaction]
|
||||
let isLayoutRTL: Bool
|
||||
|
||||
private var collapsed: Binding<Bool>
|
||||
|
||||
init(context: RoomScreenViewModel.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: RoomScreenViewModel.mock.context,
|
||||
itemID: .init(timelineID: "1"),
|
||||
reactions: [AggregatedReaction.mockReactionWithLongText,
|
||||
AggregatedReaction.mockReactionWithLongTextRTL])
|
||||
Divider()
|
||||
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
|
||||
itemID: .init(timelineID: "2"),
|
||||
reactions: Array(AggregatedReaction.mockReactions.prefix(3)))
|
||||
Divider()
|
||||
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
|
||||
itemID: .init(timelineID: "3"),
|
||||
reactions: AggregatedReaction.mockReactions)
|
||||
Divider()
|
||||
TimelineReactionsView(context: RoomScreenViewModel.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()
|
||||
}
|
||||
}
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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)))
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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
@@ -1,78 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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")))
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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))
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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))
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
//
|
||||
// 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(RoomScreenViewModel.mock.context)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func previewBubble() -> some View {
|
||||
modifier(PreviewBubbleModifier())
|
||||
}
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
//
|
||||
// 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 focussedEventID = "RoomTimelineItemFixtures.default.5"
|
||||
static let viewModel = RoomScreenViewModel(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(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
.previewDisplayName("Timeline")
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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."))))))
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// 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: ""))
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
protocol TextBasedRoomTimelineItem: EventBasedMessageTimelineItemProtocol { }
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// 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 }
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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))
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
import OrderedCollections
|
||||
|
||||
/// A table view cell that displays a timeline item in a room. The cell is intended
|
||||
/// to be configured to display a SwiftUI view and not use any UIKit.
|
||||
class TimelineItemCell: UITableViewCell {
|
||||
static let reuseIdentifier = "TimelineItemCell"
|
||||
|
||||
// periphery:ignore - retaining purpose
|
||||
var item: RoomTimelineItemViewState?
|
||||
|
||||
override func prepareForReuse() {
|
||||
item = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A table view cell that displays member typing notifications. The cell is intended
|
||||
/// to be configured to display a SwiftUI view and not use any UIKit.
|
||||
class TimelineTypingIndicatorCell: UITableViewCell {
|
||||
static let reuseIdentifier = "TimelineTypingIndicatorCell"
|
||||
}
|
||||
|
||||
class TypingMembersObservableObject: ObservableObject {
|
||||
@Published var members: [String] = []
|
||||
|
||||
init(members: [String]) {
|
||||
self.members = members
|
||||
}
|
||||
}
|
||||
|
||||
/// A table view controller that displays the timeline of a room.
|
||||
///
|
||||
/// This class subclasses `UIViewController` as `UITableViewController` adds some
|
||||
/// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1).
|
||||
/// Also this TableViewController uses a **flipped tableview**
|
||||
class TimelineTableViewController: UIViewController {
|
||||
private let coordinator: TimelineView.Coordinator
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>() {
|
||||
didSet {
|
||||
guard canApplySnapshot else {
|
||||
hasPendingItems = true
|
||||
return
|
||||
}
|
||||
|
||||
applySnapshot()
|
||||
|
||||
if timelineItemsDictionary.isEmpty {
|
||||
paginatePublisher.send()
|
||||
}
|
||||
|
||||
sendLastVisibleItemReadReceipt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not it is safe to update the data source with the latest items.
|
||||
private var canApplySnapshot: Bool {
|
||||
if isLive {
|
||||
// Backward pagination jumps if items are inserted whilst actively dragging.
|
||||
!isDraggingScrollView
|
||||
} else {
|
||||
// Forward pagination breaks inertial scrolling when fixing the offset.
|
||||
!scrollViewIsScrolling
|
||||
}
|
||||
}
|
||||
|
||||
/// There are pending items in `timelineItemsDictionary` that haven't been applied to the data source.
|
||||
private var hasPendingItems = false
|
||||
|
||||
/// The scroll view is scrolling either directly with a drag or indirectly with inertia.
|
||||
private var scrollViewIsScrolling = false {
|
||||
didSet {
|
||||
if !scrollViewIsScrolling, hasPendingItems, !isLive {
|
||||
hasPendingItems = false
|
||||
applySnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scroll view is being dragged by the user (doesn't include scrolling with inertia)
|
||||
private var isDraggingScrollView = false {
|
||||
didSet {
|
||||
if !isDraggingScrollView, hasPendingItems, isLive {
|
||||
hasPendingItems = false
|
||||
applySnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not the current timeline is live or built around an event ID.
|
||||
var isLive = true {
|
||||
didSet {
|
||||
// Update isScrolledToBottom when switching back to a live timeline.
|
||||
if isLive { scrollViewDidScroll(tableView) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The state of pagination (in both directions) of the current timeline.
|
||||
var paginationState: PaginationState = .initial {
|
||||
didSet {
|
||||
// Paginate again if the threshold hasn't been satisfied.
|
||||
paginatePublisher.send(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the table view is about to load items from a new timeline or not.
|
||||
var isSwitchingTimelines = false
|
||||
|
||||
/// The focussed event if navigating to an event permalink within the room.
|
||||
var focussedEvent: TimelineViewState.FocussedEvent? {
|
||||
didSet {
|
||||
guard let focussedEvent, focussedEvent.appearance != .hasAppeared else { return }
|
||||
scrollToItem(eventID: focussedEvent.eventID, animated: focussedEvent.appearance == .animated)
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to hold an observable object that the typing indicator can use
|
||||
let typingMembers = TypingMembersObservableObject(members: [])
|
||||
|
||||
/// Updates the typing members but also updates table view items
|
||||
func setTypingMembers(_ members: [String]) {
|
||||
DispatchQueue.main.async {
|
||||
// Avoid `Publishing changes from within view update warnings`
|
||||
self.typingMembers.members = members
|
||||
}
|
||||
}
|
||||
|
||||
@Binding private var isScrolledToBottom: Bool
|
||||
|
||||
private var timelineItemsIDs: [String] {
|
||||
timelineItemsDictionary.keys.elements.reversed()
|
||||
}
|
||||
|
||||
/// The table's diffable data source.
|
||||
private var dataSource: UITableViewDiffableDataSource<TimelineSection, String>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// A publisher used to throttle back pagination requests.
|
||||
///
|
||||
/// Our view actions get wrapped in a `Task` so it is possible that a second call in
|
||||
/// quick succession can execute before ``paginationState`` acknowledges that
|
||||
/// pagination is in progress.
|
||||
private let paginatePublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
/// A value to determine the scroll velocity threshold to detect a change in direction of the scroll view
|
||||
private let scrollVelocityThreshold: CGFloat = 50.0
|
||||
/// A publisher used to throttle scroll direction changes
|
||||
private let scrollDirectionPublisher = PassthroughSubject<ScrollDirection, Never>()
|
||||
/// Whether or not the view has been shown on screen yet.
|
||||
private var hasAppearedOnce = false
|
||||
|
||||
init(coordinator: TimelineView.Coordinator,
|
||||
isScrolledToBottom: Binding<Bool>,
|
||||
scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
|
||||
self.coordinator = coordinator
|
||||
_isScrolledToBottom = isScrolledToBottom
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
|
||||
tableView.register(TimelineTypingIndicatorCell.self, forCellReuseIdentifier: TimelineTypingIndicatorCell.reuseIdentifier)
|
||||
tableView.separatorStyle = .none
|
||||
tableView.allowsSelection = false
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
tableView.backgroundColor = UIColor(.compound.bgCanvasDefault)
|
||||
tableView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
||||
view.addSubview(tableView)
|
||||
|
||||
// Prevents XCUITest from invoking the diffable dataSource's cellProvider
|
||||
// for each possible cell, causing layout issues
|
||||
tableView.accessibilityElementsHidden = ProcessInfo.shouldDisableTimelineAccessibility
|
||||
|
||||
scrollToBottomPublisher
|
||||
.sink { [weak self] _ in
|
||||
self?.scrollToNewestItem(animated: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
paginatePublisher
|
||||
.collect(.byTime(DispatchQueue.main, 0.1))
|
||||
.sink { [weak self] _ in
|
||||
self?.paginateIfNeeded()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
scrollDirectionPublisher
|
||||
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
|
||||
.removeDuplicates()
|
||||
.sink { direction in
|
||||
coordinator.send(viewAction: .hasScrolled(direction: direction))
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.sendLastVisibleItemReadReceipt()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
configureDataSource()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not available.") }
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
sendLastVisibleItemReadReceipt()
|
||||
|
||||
guard !hasAppearedOnce else { return }
|
||||
tableView.contentOffset.y = -1
|
||||
hasAppearedOnce = true
|
||||
paginatePublisher.send()
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
guard tableView.frame.size != view.frame.size else {
|
||||
return
|
||||
}
|
||||
|
||||
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
|
||||
}
|
||||
|
||||
/// Configures a diffable data source for the timeline's table view.
|
||||
private func configureDataSource() {
|
||||
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, id in
|
||||
switch id {
|
||||
case TimelineTypingIndicatorCell.reuseIdentifier:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineTypingIndicatorCell.reuseIdentifier, for: indexPath)
|
||||
guard let self else {
|
||||
return cell
|
||||
}
|
||||
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
TypingIndicatorView(typingMembers: self.typingMembers)
|
||||
}
|
||||
.margins(.vertical, 0)
|
||||
.minSize(height: 0)
|
||||
.background(Color.clear)
|
||||
|
||||
// Flipping the cell can create some issues with cell resizing, so flip the content View
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
||||
|
||||
return cell
|
||||
default:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
||||
guard let self, let cell = cell as? TimelineItemCell else { return cell }
|
||||
|
||||
// A local reference to avoid capturing self in the cell configuration.
|
||||
let coordinator = self.coordinator
|
||||
|
||||
let viewState = timelineItemsDictionary[id]
|
||||
cell.item = viewState
|
||||
guard let viewState else {
|
||||
return cell
|
||||
}
|
||||
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
RoomTimelineItemView(viewState: viewState)
|
||||
.id(id)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
|
||||
.environment(\.roomContext, coordinator.context)
|
||||
}
|
||||
.margins(.all, 0) // Margins are handled in the stylers
|
||||
.minSize(height: 1)
|
||||
.background(Color.clear)
|
||||
|
||||
// Flipping the cell can create some issues with cell resizing, so flip the content View
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// We only animate when there's a new last message, so its safe
|
||||
// to animate from the bottom (which is the top as we're flipped).
|
||||
dataSource?.defaultRowAnimation = (UIAccessibility.isReduceMotionEnabled ? .none : .top)
|
||||
tableView.delegate = self
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(accessibilityReduceMotionDidChange),
|
||||
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
@objc private func accessibilityReduceMotionDidChange() {
|
||||
dataSource?.defaultRowAnimation = (UIAccessibility.isReduceMotionEnabled ? .none : .top)
|
||||
}
|
||||
|
||||
/// Updates the table view with the latest items from the ``timelineItems`` array. After
|
||||
/// updating the data, the table will be scrolled to the bottom if it was visible otherwise
|
||||
/// the scroll position will be updated to maintain the position of the last visible item.
|
||||
private func applySnapshot() {
|
||||
guard let dataSource else { return }
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
|
||||
snapshot.appendSections([.typingIndicator])
|
||||
snapshot.appendItems([TimelineTypingIndicatorCell.reuseIdentifier])
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(timelineItemsIDs)
|
||||
|
||||
let currentSnapshot = dataSource.snapshot()
|
||||
|
||||
// We only animate when new items come at the end of a live timeline, ignoring transitions through empty.
|
||||
let newestItemIdentifier = snapshot.mainItemIdentifiers.first
|
||||
let currentNewestItemIdentifier = currentSnapshot.mainItemIdentifiers.first
|
||||
let newestItemIDChanged = snapshot.numberOfMainItems > 0 && currentSnapshot.numberOfMainItems > 0 && newestItemIdentifier != currentNewestItemIdentifier
|
||||
let animated = isLive && !isSwitchingTimelines && newestItemIDChanged
|
||||
|
||||
let layout: Layout? = if !isLive, newestItemIDChanged {
|
||||
snapshotLayout()
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: animated)
|
||||
|
||||
if let focussedEvent, focussedEvent.appearance != .hasAppeared {
|
||||
scrollToItem(eventID: focussedEvent.eventID, animated: focussedEvent.appearance == .animated)
|
||||
} else if let layout {
|
||||
restoreLayout(layout)
|
||||
} else if isSwitchingTimelines {
|
||||
scrollToNewestItem(animated: false)
|
||||
}
|
||||
|
||||
if isSwitchingTimelines {
|
||||
coordinator.send(viewAction: .hasSwitchedTimeline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolls to the newest item in the timeline.
|
||||
private func scrollToNewestItem(animated: Bool) {
|
||||
guard !timelineItemsIDs.isEmpty else {
|
||||
return
|
||||
}
|
||||
tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: animated)
|
||||
scrollDirectionPublisher.send(.bottom)
|
||||
}
|
||||
|
||||
/// Scrolls to the oldest item in the timeline.
|
||||
private func scrollToOldestItem(animated: Bool) {
|
||||
guard !timelineItemsIDs.isEmpty else {
|
||||
return
|
||||
}
|
||||
tableView.scrollToRow(at: IndexPath(item: timelineItemsIDs.count - 1, section: 1), at: .bottom, animated: animated)
|
||||
scrollDirectionPublisher.send(.top)
|
||||
}
|
||||
|
||||
/// Scrolls to the item with the corresponding event ID if loaded in the timeline.
|
||||
private func scrollToItem(eventID: String, animated: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in // Fixes #2805
|
||||
guard let self else { return }
|
||||
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }),
|
||||
let indexPath = dataSource?.indexPath(for: kvPair.key) {
|
||||
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
|
||||
coordinator.send(viewAction: .scrolledToFocussedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether or not pagination is needed in either direction and requests one if so.
|
||||
///
|
||||
/// **Note:** Prefer not to call this directly, instead using ``paginatePublisher`` to throttle requests.
|
||||
private func paginateIfNeeded() {
|
||||
guard !hasPendingItems else { return }
|
||||
|
||||
if paginationState.backward == .idle,
|
||||
tableView.contentOffset.y > tableView.contentSize.height - tableView.visibleSize.height * 2.0 {
|
||||
coordinator.send(viewAction: .paginateBackwards)
|
||||
}
|
||||
if !isLive,
|
||||
paginationState.forward == .idle,
|
||||
tableView.contentOffset.y < tableView.visibleSize.height {
|
||||
coordinator.send(viewAction: .paginateForwards)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendLastVisibleItemReadReceipt() {
|
||||
// Find the last visible timeline item and send a read receipt for it
|
||||
guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else {
|
||||
return
|
||||
}
|
||||
|
||||
// These are already in reverse order because the table view is flipped
|
||||
for indexPath in visibleIndexPaths {
|
||||
if let visibleItemTimelineID = dataSource?.itemIdentifier(for: indexPath),
|
||||
let visibleItemID = timelineItemsDictionary[visibleItemTimelineID]?.identifier {
|
||||
coordinator.send(viewAction: .sendReadReceiptIfNeeded(visibleItemID))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension TimelineTableViewController: UITableViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
paginatePublisher.send(())
|
||||
|
||||
// Dispatch to fix runtime warning about making changes during a view update.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let isScrolledToBottom = scrollView.contentOffset.y <= 0
|
||||
|
||||
// Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling.
|
||||
if self.isScrolledToBottom != isScrolledToBottom {
|
||||
self.isScrolledToBottom = isScrolledToBottom
|
||||
}
|
||||
}
|
||||
|
||||
// We never want the table view to be fully at the bottom to allow the status bar tap to work properly
|
||||
if scrollView.contentOffset.y == 0 {
|
||||
scrollView.contentOffset.y = -1
|
||||
}
|
||||
|
||||
let velocity = scrollView.panGestureRecognizer.velocity(in: scrollView.superview).y
|
||||
if velocity > scrollVelocityThreshold {
|
||||
scrollDirectionPublisher.send(.top)
|
||||
} else if velocity < -scrollVelocityThreshold {
|
||||
scrollDirectionPublisher.send(.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
scrollToOldestItem(animated: true)
|
||||
return false
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
isDraggingScrollView = true
|
||||
scrollViewIsScrolling = true
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
sendLastVisibleItemReadReceipt()
|
||||
|
||||
isDraggingScrollView = false
|
||||
if !decelerate {
|
||||
scrollViewIsScrolling = false
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
sendLastVisibleItemReadReceipt()
|
||||
scrollViewIsScrolling = false
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
sendLastVisibleItemReadReceipt()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
extension TimelineTableViewController {
|
||||
/// The sections of the table view used in the diffable data source.
|
||||
enum TimelineSection {
|
||||
case main
|
||||
case typingIndicator
|
||||
}
|
||||
|
||||
/// A representation of the table's layout based on a particular item.
|
||||
private struct Layout {
|
||||
let id: TimelineItemIdentifier
|
||||
let frame: CGRect
|
||||
}
|
||||
|
||||
/// The current layout of the table, based on the newest timeline item.
|
||||
private func snapshotLayout() -> Layout? {
|
||||
guard let newestItemID = newestVisibleItemID(),
|
||||
let newestCellFrame = cellFrame(for: newestItemID.timelineID) else {
|
||||
return nil
|
||||
}
|
||||
return Layout(id: newestItemID, frame: newestCellFrame)
|
||||
}
|
||||
|
||||
/// Restores the timeline's layout from an old snapshot.
|
||||
private func restoreLayout(_ layout: Layout) {
|
||||
if let indexPath = dataSource?.indexPath(for: layout.id.timelineID) {
|
||||
// Scroll the item into view.
|
||||
tableView.scrollToRow(at: indexPath, at: .top, animated: false)
|
||||
|
||||
// Remove any unwanted offset that was added by scrollToRow.
|
||||
if let frame = cellFrame(for: layout.id.timelineID) {
|
||||
let deltaY = frame.maxY - layout.frame.maxY
|
||||
if deltaY != 0 {
|
||||
tableView.contentOffset.y -= deltaY
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the frame of the cell for a particular timeline item.
|
||||
private func cellFrame(for id: String) -> CGRect? {
|
||||
guard let timelineCell = tableView.visibleCells.first(where: { ($0 as? TimelineItemCell)?.item?.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tableView.convert(timelineCell.frame, to: tableView.superview)
|
||||
}
|
||||
|
||||
/// The item ID of the newest visible item in the timeline.
|
||||
private func newestVisibleItemID() -> TimelineItemIdentifier? {
|
||||
guard let timelineCell = tableView.visibleCells.first(where: {
|
||||
guard let cell = $0 as? TimelineItemCell else { return false }
|
||||
return !(cell.item?.type is PaginationIndicatorRoomTimelineItem)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
return (timelineCell as? TimelineItemCell)?.item?.identifier
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSDiffableDataSourceSnapshot<TimelineTableViewController.TimelineSection, String> {
|
||||
var numberOfMainItems: Int {
|
||||
guard sectionIdentifiers.contains(.main) else { return 0 }
|
||||
return numberOfItems(inSection: .main)
|
||||
}
|
||||
|
||||
var mainItemIdentifiers: [String] {
|
||||
guard sectionIdentifiers.contains(.main) else { return [] }
|
||||
return itemIdentifiers(inSection: .main)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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: RoomScreenViewModel.Context
|
||||
|
||||
init(viewModelContext: RoomScreenViewModel.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: RoomScreenViewAction) {
|
||||
context.send(viewAction: viewAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel(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(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// 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 = RoomScreenViewModel.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))
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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 = RoomScreenViewModel.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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//
|
||||
// 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\"}"))
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//
|
||||
// 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: RoomScreenViewModel.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user