RoomScreenViewModel is now TimelineViewModel (#3157)

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
Mauro
2024-08-13 13:36:40 +02:00
committed by GitHub
parent 4519275559
commit 1ad361a6e8
88 changed files with 2266 additions and 1973 deletions
@@ -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
}
}
}
@@ -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()
}
@@ -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)
@@ -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)
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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")))
}
}
@@ -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())
}
}
@@ -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))
}
}
@@ -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 { }
@@ -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)
}
}
}