mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Share extension (#3506)
* Setup simple share extension * Switch the app url scheme to be the full bundle identifier * Setup a share extension that show a SwiftUI view, uses rust tracing and redirects to the hosting aplication * Move media as json through the custom scheme into the main app and deep link into the media upload preview screen * Fix message forwarding and global search screen room summary provider filtering. * Tweak the message forwarding and global search screen designs. * Add a room selection screen to use after receiving a share request from the share extension * Fix share extension entitlements * Share the temporary directory between the main app and the extensions; rename the caches one. * Remove the no longer needed notification avatar flipping fix. * Extract the placeholder avatar image generator from the NSE * Nest `AvatarSize` within the new `Avatars` enum * Donate an `INSendMessageIntent` to the system every time we send a message so they appear as share suggestions * Support suggestions in the share extension itself * Improve sharing animations and fix presentation when room already on the stack * Clear all routes when sharing without a preselected room. * Fix broken unit tests * Various initial tweaks following code review. * Correctly clean up and dismiss the share extension for all paths. * Move the share extension path to a constants enum * Rename UserSessionFlowCoordinator specific share extension states and events * Add UserSession and Room flow coordinator share route tests * Tweak the share extension logic.
This commit is contained in:
@@ -235,6 +235,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
||||
} else {
|
||||
handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias))
|
||||
}
|
||||
case .share:
|
||||
guard isExternalURL else {
|
||||
MXLog.error("Received unexpected internal share route")
|
||||
break
|
||||
}
|
||||
handleAppRoute(route)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum AppRoute: Equatable {
|
||||
enum AppRoute: Equatable, Hashable {
|
||||
/// The app's home screen.
|
||||
case roomList
|
||||
/// A room, shown as the root of the stack (popping any child rooms).
|
||||
@@ -41,6 +41,8 @@ enum AppRoute: Equatable {
|
||||
case settings
|
||||
/// The setting screen for key backup.
|
||||
case chatBackupSettings
|
||||
/// An external share request e.g. from the ShareExtension
|
||||
case share(ShareExtensionPayload)
|
||||
}
|
||||
|
||||
struct AppRouteURLParser {
|
||||
@@ -48,6 +50,7 @@ struct AppRouteURLParser {
|
||||
|
||||
init(appSettings: AppSettings) {
|
||||
urlParsers = [
|
||||
AppGroupURLParser(),
|
||||
MatrixPermalinkParser(),
|
||||
ElementWebURLParser(domains: appSettings.elementWebHosts),
|
||||
ElementCallURLParser()
|
||||
@@ -73,6 +76,30 @@ protocol URLParser {
|
||||
func route(from url: URL) -> AppRoute?
|
||||
}
|
||||
|
||||
struct AppGroupURLParser: URLParser {
|
||||
func route(from url: URL) -> AppRoute? {
|
||||
guard let scheme = url.scheme,
|
||||
scheme == InfoPlistReader.app.appScheme,
|
||||
url.pathComponents.last == ShareExtensionConstants.urlPath else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let query = url.query(percentEncoded: false),
|
||||
let queryData = query.data(using: .utf8) else {
|
||||
MXLog.error("Failed processing share parameters")
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let payload = try JSONDecoder().decode(ShareExtensionPayload.self, from: queryData)
|
||||
return .share(payload)
|
||||
} catch {
|
||||
MXLog.error("Failed decoding share payload with error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parser for Element Call links. This always returns a `.genericCallLink`.
|
||||
struct ElementCallURLParser: URLParser {
|
||||
private let knownHosts = ["call.element.io"]
|
||||
|
||||
@@ -83,7 +83,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
|
||||
.roomDetails, .roomMemberDetails, .userProfile,
|
||||
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
|
||||
.call, .genericCallLink, .settings:
|
||||
.call, .genericCallLink, .settings, .share:
|
||||
// These routes aren't in this flow so clear the entire stack.
|
||||
clearRoute(animated: animated)
|
||||
case .chatBackupSettings:
|
||||
|
||||
@@ -293,9 +293,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
|
||||
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
.sink { action in
|
||||
switch action {
|
||||
case .complete:
|
||||
break // Moving to next state is Handled by the global session verification listener
|
||||
|
||||
@@ -65,7 +65,9 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
|
||||
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
|
||||
|
||||
guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory) else {
|
||||
guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: userSession.mediaProvider) else {
|
||||
fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil")
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
|
||||
case eventID(String)
|
||||
/// The flow will start by showing the room's details.
|
||||
case roomDetails
|
||||
/// An external media share request
|
||||
case share(ShareExtensionPayload)
|
||||
|
||||
var isEventID: Bool {
|
||||
guard case .eventID = self else { return false }
|
||||
@@ -41,6 +43,32 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct FocusEvent: Hashable {
|
||||
/// The event ID that the timeline should be focussed around
|
||||
let eventID: String
|
||||
/// if the focus is coming from the pinned timeline, this should also update the pin banner
|
||||
let shouldSetPin: Bool
|
||||
}
|
||||
|
||||
private enum PinnedEventsTimelineSource: Hashable {
|
||||
case room
|
||||
case details(isRoot: Bool)
|
||||
}
|
||||
|
||||
private enum PresentationAction: Hashable {
|
||||
case eventFocus(FocusEvent)
|
||||
case share(ShareExtensionPayload)
|
||||
|
||||
var focusedEvent: FocusEvent? {
|
||||
switch self {
|
||||
case .eventFocus(let focusEvent):
|
||||
return focusEvent
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let roomID: String
|
||||
@@ -112,6 +140,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
fatalError("This flow coordinator expect a route")
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
|
||||
guard stateMachine.state != .complete else {
|
||||
fatalError("This flow coordinator is `finished` ☠️")
|
||||
@@ -152,7 +181,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
|
||||
}
|
||||
case .event(let eventID, let roomID, let via):
|
||||
Task { await handleRoomRoute(roomID: roomID, via: via, focussedEventID: eventID, animated: animated) }
|
||||
Task {
|
||||
await handleRoomRoute(roomID: roomID,
|
||||
via: via,
|
||||
presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: false)),
|
||||
animated: animated)
|
||||
}
|
||||
case .childEvent(let eventID, let roomID, let via):
|
||||
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
|
||||
childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
||||
@@ -161,6 +195,21 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
} else {
|
||||
roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false))
|
||||
}
|
||||
case .share(let payload):
|
||||
guard case let .mediaFile(roomID, _) = payload else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let roomID, roomID == self.roomID else {
|
||||
fatalError("Navigation route doesn't belong to this room flow.")
|
||||
}
|
||||
|
||||
Task {
|
||||
await handleRoomRoute(roomID: roomID,
|
||||
via: [],
|
||||
presentationAction: .share(payload),
|
||||
animated: animated)
|
||||
}
|
||||
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
|
||||
break // These are converted to a room ID route one level above.
|
||||
case .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings:
|
||||
@@ -176,7 +225,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
|
||||
}
|
||||
|
||||
private func handleRoomRoute(roomID: String, via: [String], focussedEventID: String? = nil, animated: Bool) async {
|
||||
private func handleRoomRoute(roomID: String, via: [String], presentationAction: PresentationAction? = nil, animated: Bool) async {
|
||||
guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
|
||||
|
||||
guard let room = await userSession.clientProxy.roomForIdentifier(roomID) else {
|
||||
@@ -187,8 +236,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
switch room {
|
||||
case .joined(let roomProxy):
|
||||
await storeAndSubscribeToRoomProxy(roomProxy)
|
||||
let focussedEvent = focussedEventID.map { FocusEvent(eventID: $0, shouldSetPin: false) }
|
||||
stateMachine.tryEvent(.presentRoom(focussedEvent: focussedEvent), userInfo: EventUserInfo(animated: animated))
|
||||
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
|
||||
default:
|
||||
stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated))
|
||||
}
|
||||
@@ -376,9 +424,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
presentJoinRoomScreen(via: via, animated: true)
|
||||
case (_, .dismissJoinRoomScreen, .complete):
|
||||
dismissFlow(animated: animated)
|
||||
|
||||
case (_, .presentRoom(let focussedEvent), .room):
|
||||
Task { await self.presentRoom(fromState: context.fromState, focussedEvent: focussedEvent, animated: animated) }
|
||||
|
||||
case (_, .presentRoom(let presentationAction), .room):
|
||||
Task {
|
||||
await self.presentRoom(fromState: context.fromState,
|
||||
presentationAction: presentationAction,
|
||||
animated: animated)
|
||||
}
|
||||
case (_, .dismissFlow, .complete):
|
||||
dismissFlow(animated: animated)
|
||||
|
||||
@@ -445,9 +497,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
break
|
||||
|
||||
case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL)):
|
||||
presentMediaUploadPreviewScreen(for: fileURL)
|
||||
presentMediaUploadPreviewScreen(for: fileURL, animated: animated)
|
||||
case (.room, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL)):
|
||||
presentMediaUploadPreviewScreen(for: fileURL)
|
||||
presentMediaUploadPreviewScreen(for: fileURL, animated: animated)
|
||||
case (.mediaUploadPreview, .dismissMediaUploadPreview, .room):
|
||||
break
|
||||
|
||||
@@ -542,7 +594,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
/// - fromState: The state that asked for the room presentation.
|
||||
/// - focussedEvent: An (optional) struct that contains the event ID that the timeline should be focussed around, and a boolean telling if such event should update the pinned events banner
|
||||
/// - animated: whether it should animate the transition
|
||||
private func presentRoom(fromState: State, focussedEvent: FocusEvent?, animated: Bool) async {
|
||||
private func presentRoom(fromState: State, presentationAction: PresentationAction?, animated: Bool) async {
|
||||
// If any sheets are presented dismiss them, rely on their dismissal callbacks to transition the state machine
|
||||
// through the correct states before presenting the room
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
@@ -559,9 +611,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
default:
|
||||
// The room is already on the stack, no need to present it again
|
||||
|
||||
// Check if we need to focus on an event
|
||||
if let focussedEvent {
|
||||
roomScreenCoordinator?.focusOnEvent(focussedEvent)
|
||||
switch presentationAction {
|
||||
case .eventFocus(let focusedEvent):
|
||||
roomScreenCoordinator?.focusOnEvent(focusedEvent)
|
||||
case .share(.mediaFile(_, let mediaFile)):
|
||||
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url))
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return
|
||||
@@ -580,8 +636,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
|
||||
|
||||
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
|
||||
initialFocussedEventID: focussedEvent?.eventID,
|
||||
timelineItemFactory: timelineItemFactory)
|
||||
initialFocussedEventID: presentationAction?.focusedEvent?.eventID,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
self.timelineController = timelineController
|
||||
|
||||
analytics.trackViewRoom(isDM: roomProxy.infoPublisher.value.isDirect, isSpace: roomProxy.infoPublisher.value.isSpace)
|
||||
@@ -592,7 +649,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy,
|
||||
roomProxy: roomProxy,
|
||||
focussedEvent: focussedEvent,
|
||||
focussedEvent: presentationAction?.focusedEvent,
|
||||
timelineController: timelineController,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
mediaPlayerProvider: MediaPlayerProvider(),
|
||||
@@ -655,6 +712,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
self?.stateMachine.tryEvent(.dismissFlow)
|
||||
}
|
||||
}
|
||||
|
||||
switch presentationAction {
|
||||
case .share(.mediaFile(_, let mediaFile)):
|
||||
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), userInfo: EventUserInfo(animated: animated))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func presentJoinRoomScreen(via: [String], animated: Bool) {
|
||||
@@ -679,7 +743,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) {
|
||||
await storeAndSubscribeToRoomProxy(roomProxy)
|
||||
stateMachine.tryEvent(.presentRoom(focussedEvent: nil), userInfo: EventUserInfo(animated: animated))
|
||||
stateMachine.tryEvent(.presentRoom(presentationAction: nil), userInfo: EventUserInfo(animated: animated))
|
||||
|
||||
analytics.trackJoinedRoom(isDM: roomProxy.infoPublisher.value.isDirect,
|
||||
isSpace: roomProxy.infoPublisher.value.isSpace,
|
||||
@@ -894,7 +958,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMediaUploadPreviewScreen(for url: URL) {
|
||||
private func presentMediaUploadPreviewScreen(for url: URL, animated: Bool) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController,
|
||||
@@ -918,7 +982,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator)
|
||||
|
||||
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
|
||||
navigationStackCoordinator.setSheetCoordinator(stackCoordinator, animated: animated) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissMediaUploadPreview)
|
||||
}
|
||||
}
|
||||
@@ -1091,7 +1155,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
let roomTimelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
|
||||
initialFocussedEventID: nil,
|
||||
timelineItemFactory: timelineItemFactory)
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
let parameters = RoomPollsHistoryScreenCoordinatorParameters(pollInteractionHandler: PollInteractionHandler(analyticsService: analytics, roomProxy: roomProxy),
|
||||
roomTimelineController: roomTimelineController)
|
||||
@@ -1361,7 +1426,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
|
||||
case .displayRoomScreenWithFocussedPin(let eventID):
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
stateMachine.tryEvent(.presentRoom(focussedEvent: .init(eventID: eventID, shouldSetPin: true)))
|
||||
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true))))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -1427,6 +1492,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
coordinator.handleAppRoute(.event(eventID: eventID, roomID: roomID, via: via), animated: true)
|
||||
case .roomDetails:
|
||||
coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true)
|
||||
case .share(let payload):
|
||||
coordinator.handleAppRoute(.share(payload), animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1483,7 +1550,7 @@ private extension RoomFlowCoordinator {
|
||||
case presentJoinRoomScreen(via: [String])
|
||||
case dismissJoinRoomScreen
|
||||
|
||||
case presentRoom(focussedEvent: FocusEvent?)
|
||||
case presentRoom(presentationAction: PresentationAction?)
|
||||
case dismissFlow
|
||||
|
||||
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
|
||||
@@ -1559,15 +1626,3 @@ private extension Result {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum PinnedEventsTimelineSource: Hashable {
|
||||
case room
|
||||
case details(isRoot: Bool)
|
||||
}
|
||||
|
||||
struct FocusEvent: Hashable {
|
||||
/// The event ID that the timeline should be focussed around
|
||||
let eventID: String
|
||||
/// if the focus is coming from the pinned timeline, this should also update the pin banner
|
||||
let shouldSetPin: Bool
|
||||
}
|
||||
|
||||
@@ -206,6 +206,18 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
presentCallScreen(genericCallLink: url)
|
||||
case .settings, .chatBackupSettings:
|
||||
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
||||
case .share(let payload):
|
||||
switch payload {
|
||||
case .mediaFile(let roomID, _):
|
||||
if let roomID {
|
||||
stateMachine.processEvent(.selectRoom(roomID: roomID,
|
||||
via: [],
|
||||
entryPoint: .share(payload)),
|
||||
userInfo: .init(animated: animated))
|
||||
} else {
|
||||
stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +255,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case .room: .room(roomID: roomID, via: via)
|
||||
case .roomDetails: .roomDetails(roomID: roomID)
|
||||
case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored.
|
||||
case .share(let payload): .share(payload)
|
||||
}
|
||||
roomFlowCoordinator.handleAppRoute(route, animated: animated)
|
||||
} else {
|
||||
@@ -296,6 +309,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case (.userProfileScreen, .dismissedUserProfileScreen, .roomList):
|
||||
break
|
||||
|
||||
case (.roomList, .showShareExtensionRoomList, .shareExtensionRoomList(let sharePayload)):
|
||||
clearRoute(animated: animated)
|
||||
presentRoomSelectionScreen(sharePayload: sharePayload, animated: animated)
|
||||
case (.shareExtensionRoomList, .dismissedShareExtensionRoomList, .roomList):
|
||||
dismissRoomSelectionScreen()
|
||||
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
}
|
||||
@@ -583,6 +602,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
coordinator.handleAppRoute(.event(eventID: eventID, roomID: roomID, via: via), animated: animated)
|
||||
case .roomDetails:
|
||||
coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: animated)
|
||||
case .share(let payload):
|
||||
coordinator.handleAppRoute(.share(payload), animated: animated)
|
||||
}
|
||||
|
||||
Task {
|
||||
@@ -894,6 +915,52 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Sharing
|
||||
|
||||
private func presentRoomSelectionScreen(sharePayload: ShareExtensionPayload, animated: Bool) {
|
||||
guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let coordinator = RoomSelectionScreenCoordinator(parameters: .init(clientProxy: userSession.clientProxy,
|
||||
roomSummaryProvider: roomSummaryProvider,
|
||||
mediaProvider: userSession.mediaProvider))
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .dismiss:
|
||||
navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
case .confirm(let roomID):
|
||||
let sharePayload = switch sharePayload {
|
||||
case .mediaFile(_, let mediaFile):
|
||||
ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile)
|
||||
}
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
|
||||
stateMachine.processEvent(.selectRoom(roomID: roomID,
|
||||
via: [],
|
||||
entryPoint: .share(sharePayload)),
|
||||
userInfo: .init(animated: animated))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
stackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(stackCoordinator, animated: animated) { [weak self] in
|
||||
self?.stateMachine.processEvent(.dismissedShareExtensionRoomList)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissRoomSelectionScreen() {
|
||||
navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
|
||||
// MARK: Toasts and loading indicators
|
||||
|
||||
private static let loadingIndicatorIdentifier = "\(UserSessionFlowCoordinator.self)-Loading"
|
||||
|
||||
@@ -42,10 +42,12 @@ class UserSessionFlowCoordinatorStateMachine {
|
||||
/// Showing the user profile screen. This screen clears the navigation.
|
||||
case userProfileScreen
|
||||
|
||||
case shareExtensionRoomList(sharePayload: ShareExtensionPayload)
|
||||
|
||||
/// The selected room ID from the state if available.
|
||||
var selectedRoomID: String? {
|
||||
switch self {
|
||||
case .initial, .userProfileScreen:
|
||||
case .initial, .userProfileScreen, .shareExtensionRoomList:
|
||||
nil
|
||||
case .roomList(let selectedRoomID),
|
||||
.feedbackScreen(let selectedRoomID),
|
||||
@@ -116,6 +118,9 @@ class UserSessionFlowCoordinatorStateMachine {
|
||||
case showUserProfileScreen(userID: String)
|
||||
/// The user profile screen has been dismissed.
|
||||
case dismissedUserProfileScreen
|
||||
|
||||
case showShareExtensionRoomList(sharePayload: ShareExtensionPayload)
|
||||
case dismissedShareExtensionRoomList
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
@@ -184,6 +189,11 @@ class UserSessionFlowCoordinatorStateMachine {
|
||||
case (.userProfileScreen, .dismissedUserProfileScreen):
|
||||
return .roomList(selectedRoomID: nil)
|
||||
|
||||
case (.roomList, .showShareExtensionRoomList(let sharePayload)):
|
||||
return .shareExtensionRoomList(sharePayload: sharePayload)
|
||||
case (.shareExtensionRoomList, .dismissedShareExtensionRoomList):
|
||||
return .roomList(selectedRoomID: nil)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12939,15 +12939,15 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
|
||||
|
||||
//MARK: - buildRoomTimelineController
|
||||
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount = 0
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount: Int {
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount
|
||||
returnValue = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -12955,29 +12955,29 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount = newValue
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingCallsCount = newValue
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCalled: Bool {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount > 0
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCalled: Bool {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount > 0
|
||||
}
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol)?
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol)] = []
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
|
||||
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue: RoomTimelineControllerProtocol!
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue: RoomTimelineControllerProtocol! {
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingReturnValue: RoomTimelineControllerProtocol!
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue: RoomTimelineControllerProtocol! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: RoomTimelineControllerProtocol? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue
|
||||
returnValue = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -12985,39 +12985,39 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue = newValue
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryUnderlyingReturnValue = newValue
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure: ((JoinedRoomProxyProtocol, String?, RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol)?
|
||||
var buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderClosure: ((JoinedRoomProxyProtocol, String?, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) -> RoomTimelineControllerProtocol)?
|
||||
|
||||
func buildRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryCallsCount += 1
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedArguments = (roomProxy: roomProxy, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory)
|
||||
func buildRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, initialFocussedEventID: String?, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderCallsCount += 1
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments = (roomProxy: roomProxy, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
|
||||
DispatchQueue.main.async {
|
||||
self.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReceivedInvocations.append((roomProxy: roomProxy, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory))
|
||||
self.buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedInvocations.append((roomProxy: roomProxy, initialFocussedEventID: initialFocussedEventID, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
|
||||
}
|
||||
if let buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryClosure(roomProxy, initialFocussedEventID, timelineItemFactory)
|
||||
if let buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderClosure = buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderClosure {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderClosure(roomProxy, initialFocussedEventID, timelineItemFactory, mediaProvider)
|
||||
} else {
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue
|
||||
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - buildRoomPinnedTimelineController
|
||||
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount = 0
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCallsCount: Int {
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount
|
||||
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -13025,29 +13025,29 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount = newValue
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount = newValue
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCalled: Bool {
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCallsCount > 0
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
|
||||
}
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol)?
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol)] = []
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
|
||||
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue: RoomTimelineControllerProtocol?
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReturnValue: RoomTimelineControllerProtocol? {
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: RoomTimelineControllerProtocol?
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue: RoomTimelineControllerProtocol? {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: RoomTimelineControllerProtocol?? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue
|
||||
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
@@ -13055,26 +13055,26 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue = newValue
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue = newValue
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure: ((JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol?)?
|
||||
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure: ((JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> RoomTimelineControllerProtocol?)?
|
||||
|
||||
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol? {
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCallsCount += 1
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory)
|
||||
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? {
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
|
||||
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
|
||||
DispatchQueue.main.async {
|
||||
self.buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory))
|
||||
self.buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
|
||||
}
|
||||
if let buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure {
|
||||
return await buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure(roomProxy, timelineItemFactory)
|
||||
if let buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure {
|
||||
return await buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure(roomProxy, timelineItemFactory, mediaProvider)
|
||||
} else {
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReturnValue
|
||||
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ extension RoomTimelineControllerFactoryMock {
|
||||
convenience init(configuration: RoomTimelineControllerFactoryMockConfiguration) {
|
||||
self.init()
|
||||
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue = configuration.timelineController ?? {
|
||||
buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue = configuration.timelineController ?? {
|
||||
let timelineController = MockRoomTimelineController()
|
||||
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
|
||||
return timelineController
|
||||
|
||||
@@ -6,29 +6,55 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
enum AvatarSize {
|
||||
case user(on: UserAvatarSizeOnScreen)
|
||||
case room(on: RoomAvatarSizeOnScreen)
|
||||
// custom
|
||||
case custom(CGFloat)
|
||||
enum Avatars {
|
||||
enum Size {
|
||||
case user(on: UserAvatarSizeOnScreen)
|
||||
case room(on: RoomAvatarSizeOnScreen)
|
||||
// custom
|
||||
case custom(CGFloat)
|
||||
|
||||
/// Value in UIKit points
|
||||
var value: CGFloat {
|
||||
switch self {
|
||||
case .user(let screen):
|
||||
return screen.value
|
||||
case .room(let screen):
|
||||
return screen.value
|
||||
case .custom(let val):
|
||||
return val
|
||||
/// Value in UIKit points
|
||||
var value: CGFloat {
|
||||
switch self {
|
||||
case .user(let screen):
|
||||
return screen.value
|
||||
case .room(let screen):
|
||||
return screen.value
|
||||
case .custom(let val):
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
/// Value in pixels by using the scale of the main screen
|
||||
var scaledValue: CGFloat {
|
||||
value * UIScreen.main.scale
|
||||
}
|
||||
|
||||
var scaledSize: CGSize {
|
||||
CGSize(width: scaledValue, height: scaledValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Value in pixels by using the scale of the main screen
|
||||
var scaledValue: CGFloat {
|
||||
value * UIScreen.main.scale
|
||||
|
||||
@MainActor
|
||||
static func generatePlaceholderAvatarImageData(name: String, id: String, size: CGSize) -> Data? {
|
||||
let image = PlaceholderAvatarImage(name: name, contentID: id)
|
||||
.clipShape(Circle())
|
||||
.frame(width: size.width, height: size.height)
|
||||
|
||||
let renderer = ImageRenderer(content: image)
|
||||
|
||||
// Specify the scale so the image is rendered correctly. We don't have access to the screen
|
||||
// here so a hardcoded 3.0 will have to do
|
||||
renderer.scale = 3.0
|
||||
|
||||
guard let image = renderer.uiImage else {
|
||||
MXLog.info("Generating notification icon placeholder failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
return image.pngData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +113,7 @@ enum RoomAvatarSizeOnScreen {
|
||||
case home
|
||||
case messageForwarding
|
||||
case globalSearch
|
||||
case roomSelection
|
||||
case details
|
||||
case notificationSettings
|
||||
case roomDirectorySearch
|
||||
@@ -104,6 +131,8 @@ enum RoomAvatarSizeOnScreen {
|
||||
return 36
|
||||
case .globalSearch:
|
||||
return 36
|
||||
case .roomSelection:
|
||||
return 36
|
||||
case .home:
|
||||
return 52
|
||||
case .details:
|
||||
@@ -113,9 +142,3 @@ enum RoomAvatarSizeOnScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AvatarSize {
|
||||
var scaledSize: CGSize {
|
||||
CGSize(width: scaledValue, height: scaledValue)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ extension FileManager {
|
||||
|
||||
@discardableResult
|
||||
func writeDataToTemporaryDirectory(data: Data, fileName: String) throws -> URL {
|
||||
let newURL = URL.temporaryDirectory.appendingPathComponent(fileName)
|
||||
let newURL = URL.appGroupTemporaryDirectory.appendingPathComponent(fileName)
|
||||
|
||||
try data.write(to: newURL)
|
||||
|
||||
|
||||
@@ -183,37 +183,17 @@ extension UNMutableNotificationContent {
|
||||
@MainActor
|
||||
private func getPlaceholderAvatarImageData(name: String, id: String) async -> Data? {
|
||||
// The version value is used in case the design of the placeholder is updated to force a replacement
|
||||
let shouldFlipAvatar = shouldFlipAvatar()
|
||||
let prefix = "notification_placeholder\(shouldFlipAvatar ? "V9F" : "V9")"
|
||||
let prefix = "notification_placeholderV9"
|
||||
|
||||
let fileName = "\(prefix)_\(name)_\(id).png"
|
||||
if let data = try? Data(contentsOf: URL.temporaryDirectory.appendingPathComponent(fileName)) {
|
||||
MXLog.info("Found existing notification icon placeholder")
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
MXLog.info("Generating notification icon placeholder")
|
||||
let image = PlaceholderAvatarImage(name: name,
|
||||
contentID: id)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 50, height: 50)
|
||||
let renderer = ImageRenderer(content: image)
|
||||
|
||||
// Specify the scale so the image is rendered correctly. We don't have access to the screen
|
||||
// here so a hardcoded 3.0 will have to do
|
||||
renderer.scale = 3.0
|
||||
|
||||
guard let image = renderer.uiImage else {
|
||||
MXLog.info("Generating notification icon placeholder failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
let data: Data?
|
||||
|
||||
if shouldFlipAvatar {
|
||||
data = image.flippedVertically().pngData()
|
||||
} else {
|
||||
data = image.pngData()
|
||||
}
|
||||
let data = Avatars.generatePlaceholderAvatarImageData(name: name, id: id, size: .init(width: 50, height: 50))
|
||||
|
||||
if let data {
|
||||
do {
|
||||
@@ -224,44 +204,7 @@ extension UNMutableNotificationContent {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/// On simulators and macOS the image is rendered correctly
|
||||
/// On devices before iOS 17 and iOS 17.2.0 it's rendered upside down and needs to be flipped
|
||||
/// On all other versions it's rendered correctly and **doesn't** need to be flipped
|
||||
private func shouldFlipAvatar() -> Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return false
|
||||
#else
|
||||
if ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let version = Version(UIDevice.current.systemVersion) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if version < Version(17, 0, 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if version == Version(17, 2, 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
func flippedVertically() -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = scale
|
||||
return UIGraphicsImageRenderer(size: size, format: format).image { context in
|
||||
context.cgContext.concatenate(CGAffineTransform(scaleX: 1, y: -1))
|
||||
self.draw(at: CGPoint(x: 0, y: -size.height))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ extension URL: @retroactive ExpressibleByStringLiteral {
|
||||
}
|
||||
|
||||
/// The base directory where all application support data is stored.
|
||||
static var cachesBaseDirectory: URL {
|
||||
static var sessionCachesBaseDirectory: URL {
|
||||
let url = appGroupContainerDirectory
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Caches", isDirectory: true)
|
||||
@@ -69,7 +69,20 @@ extension URL: @retroactive ExpressibleByStringLiteral {
|
||||
|
||||
try? FileManager.default.createDirectoryIfNeeded(at: url)
|
||||
|
||||
// Caches are excluded from backups automatically anyway.
|
||||
// Caches are excluded from backups automatically.
|
||||
// https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/// The app group temporary directory
|
||||
static var appGroupTemporaryDirectory: URL {
|
||||
let url = appGroupContainerDirectory
|
||||
.appendingPathComponent("tmp", isDirectory: true)
|
||||
|
||||
try? FileManager.default.createDirectoryIfNeeded(at: url)
|
||||
|
||||
// Temporary files are excluded from backups automatically.
|
||||
// https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html
|
||||
|
||||
return url
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum ExtensionLogger {
|
||||
private static var isConfigured = false
|
||||
|
||||
/// Memory formatter, uses exact 2 fraction digits and no grouping
|
||||
private static var numberFormatter: NumberFormatter {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.alwaysShowsDecimalSeparator = true
|
||||
formatter.decimalSeparator = "."
|
||||
formatter.groupingSeparator = ""
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.minimumFractionDigits = 2
|
||||
return formatter
|
||||
}
|
||||
|
||||
private static var formattedMemoryAvailable: String {
|
||||
let freeBytes = os_proc_available_memory()
|
||||
let freeMB = Double(freeBytes) / 1024 / 1024
|
||||
guard let formattedStr = numberFormatter.string(from: NSNumber(value: freeMB)) else {
|
||||
return ""
|
||||
}
|
||||
return "\(formattedStr) MB"
|
||||
}
|
||||
|
||||
/// Details: https://developer.apple.com/forums/thread/105088
|
||||
/// - Returns: Current memory footprint
|
||||
private static var memoryFootprint: Float? {
|
||||
// The `TASK_VM_INFO_COUNT` and `TASK_VM_INFO_REV1_COUNT` macros are too
|
||||
// complex for the Swift C importer, so we have to define them ourselves.
|
||||
let TASK_VM_INFO_COUNT = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
|
||||
guard let offset = MemoryLayout.offset(of: \task_vm_info_data_t.min_address) else {
|
||||
return nil
|
||||
}
|
||||
let TASK_VM_INFO_REV1_COUNT = mach_msg_type_number_t(offset / MemoryLayout<integer_t>.size)
|
||||
var info = task_vm_info_data_t()
|
||||
var count = TASK_VM_INFO_COUNT
|
||||
let kr = withUnsafeMutablePointer(to: &info) { infoPtr in
|
||||
infoPtr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), intPtr, &count)
|
||||
}
|
||||
}
|
||||
guard kr == KERN_SUCCESS, count >= TASK_VM_INFO_REV1_COUNT else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Float(info.phys_footprint)
|
||||
}
|
||||
|
||||
/// Formatted memory footprint for debugging purposes
|
||||
/// - Returns: Memory footprint in MBs as a readable string
|
||||
static var formattedMemoryFootprint: String {
|
||||
let usedBytes = UInt64(memoryFootprint ?? 0)
|
||||
let usedMB = Double(usedBytes) / 1024 / 1024
|
||||
guard let formattedStr = numberFormatter.string(from: NSNumber(value: usedMB)) else {
|
||||
return ""
|
||||
}
|
||||
return "\(formattedStr) MB"
|
||||
}
|
||||
|
||||
static func configure(currentTarget: String, logLevel: TracingConfiguration.LogLevel) {
|
||||
guard !isConfigured else {
|
||||
return
|
||||
}
|
||||
isConfigured = true
|
||||
|
||||
MXLog.configure(currentTarget: currentTarget, filePrefix: currentTarget, logLevel: logLevel)
|
||||
}
|
||||
|
||||
static func logMemory(with tag: String) {
|
||||
MXLog.info("\(tag) Memory: footprint: \(formattedMemoryFootprint) - available: \(formattedMemoryAvailable)")
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,13 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
private let subtitle: String?
|
||||
private let badges: [Badge]
|
||||
|
||||
private let avatarSize: AvatarSize
|
||||
private let avatarSize: Avatars.Size
|
||||
private let mediaProvider: MediaProviderProtocol?
|
||||
private var onAvatarTap: ((URL) -> Void)?
|
||||
@ViewBuilder private var footer: () -> Footer
|
||||
|
||||
init(room: RoomDetails,
|
||||
avatarSize: AvatarSize,
|
||||
avatarSize: Avatars.Size,
|
||||
mediaProvider: MediaProviderProtocol? = nil,
|
||||
onAvatarTap: ((URL) -> Void)? = nil,
|
||||
@ViewBuilder footer: @escaping () -> Footer) {
|
||||
@@ -72,7 +72,7 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
|
||||
init(member: RoomMemberDetails,
|
||||
isVerified: Bool = false,
|
||||
avatarSize: AvatarSize,
|
||||
avatarSize: Avatars.Size,
|
||||
mediaProvider: MediaProviderProtocol? = nil,
|
||||
onAvatarTap: ((URL) -> Void)? = nil,
|
||||
@ViewBuilder footer: @escaping () -> Footer) {
|
||||
@@ -88,7 +88,7 @@ struct AvatarHeaderView<Footer: View>: View {
|
||||
|
||||
init(user: UserProfileProxy,
|
||||
isVerified: Bool,
|
||||
avatarSize: AvatarSize,
|
||||
avatarSize: Avatars.Size,
|
||||
mediaProvider: MediaProviderProtocol? = nil,
|
||||
onAvatarTap: ((URL) -> Void)? = nil,
|
||||
@ViewBuilder footer: @escaping () -> Footer) {
|
||||
|
||||
@@ -11,7 +11,7 @@ struct LoadableAvatarImage: View {
|
||||
private let url: URL?
|
||||
private let name: String?
|
||||
private let contentID: String?
|
||||
private let avatarSize: AvatarSize
|
||||
private let avatarSize: Avatars.Size
|
||||
private let mediaProvider: MediaProviderProtocol?
|
||||
private let onTap: ((URL) -> Void)?
|
||||
|
||||
@@ -19,7 +19,7 @@ struct LoadableAvatarImage: View {
|
||||
|
||||
init(url: URL?, name: String?,
|
||||
contentID: String?,
|
||||
avatarSize: AvatarSize,
|
||||
avatarSize: Avatars.Size,
|
||||
mediaProvider: MediaProviderProtocol?,
|
||||
onTap: ((URL) -> Void)? = nil) {
|
||||
self.url = url
|
||||
|
||||
@@ -12,12 +12,12 @@ struct OverridableAvatarImage: View {
|
||||
private let url: URL?
|
||||
private let name: String?
|
||||
private let contentID: String?
|
||||
private let avatarSize: AvatarSize
|
||||
private let avatarSize: Avatars.Size
|
||||
private let mediaProvider: MediaProviderProtocol?
|
||||
|
||||
@ScaledMetric private var frameSize: CGFloat
|
||||
|
||||
init(overrideURL: URL?, url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, mediaProvider: MediaProviderProtocol?) {
|
||||
init(overrideURL: URL?, url: URL?, name: String?, contentID: String?, avatarSize: Avatars.Size, mediaProvider: MediaProviderProtocol?) {
|
||||
self.overrideURL = overrideURL
|
||||
self.url = url
|
||||
self.name = name
|
||||
|
||||
@@ -22,7 +22,7 @@ enum RoomAvatar: Equatable {
|
||||
struct RoomAvatarImage: View {
|
||||
let avatar: RoomAvatar
|
||||
|
||||
let avatarSize: AvatarSize
|
||||
let avatarSize: Avatars.Size
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
|
||||
private(set) var onAvatarTap: ((URL) -> Void)?
|
||||
|
||||
@@ -18,7 +18,7 @@ struct StackedAvatarsView: View {
|
||||
let lineWidth: CGFloat
|
||||
var shouldStackFromLast = false
|
||||
let avatars: [StackedAvatarInfo]
|
||||
let avatarSize: AvatarSize
|
||||
let avatarSize: Avatars.Size
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -30,7 +30,7 @@ enum GlobalSearchScreenViewAction {
|
||||
|
||||
struct GlobalSearchRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let alias: String?
|
||||
let title: String
|
||||
let description: String
|
||||
let avatar: RoomAvatar
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearch
|
||||
.map(\.bindings.searchQuery)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] searchQuery in
|
||||
self?.roomSummaryProvider.setFilter(.search(query: searchQuery))
|
||||
if searchQuery.isEmpty {
|
||||
self?.roomSummaryProvider.setFilter(.all(filters: []))
|
||||
} else {
|
||||
self?.roomSummaryProvider.setFilter(.search(query: searchQuery))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -66,8 +70,8 @@ class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearch
|
||||
private func updateRooms(with summaries: [RoomSummary]) {
|
||||
state.rooms = summaries.compactMap { summary in
|
||||
GlobalSearchRoom(id: summary.id,
|
||||
name: summary.name,
|
||||
alias: summary.canonicalAlias,
|
||||
title: summary.name,
|
||||
description: summary.roomListDescription,
|
||||
avatar: summary.avatar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ struct GlobalSearchScreenListRow: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack { // The list row swallows listRowBackgrounds for some reason
|
||||
ListRow(label: .avatar(title: room.name,
|
||||
description: room.alias ?? room.id,
|
||||
ListRow(label: .avatar(title: room.title,
|
||||
description: room.description,
|
||||
icon: avatar),
|
||||
kind: .label)
|
||||
}
|
||||
@@ -42,8 +42,8 @@ struct GlobalSearchScreenListRow_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
List {
|
||||
GlobalSearchScreenListRow(room: .init(id: "123",
|
||||
name: "Tech central",
|
||||
alias: "The best place in the whole wide world",
|
||||
title: "Tech central",
|
||||
description: "The best place in the whole wide world",
|
||||
avatar: .room(id: "123",
|
||||
name: "Tech central",
|
||||
avatarURL: .picturesDirectory)),
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ enum MessageForwardingScreenViewAction {
|
||||
|
||||
struct MessageForwardingRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let alias: String?
|
||||
let title: String
|
||||
let description: String
|
||||
let avatar: RoomAvatar
|
||||
}
|
||||
|
||||
|
||||
+9
-4
@@ -45,8 +45,11 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
|
||||
.map(\.bindings.searchQuery)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] searchQuery in
|
||||
guard let self else { return }
|
||||
self.roomSummaryProvider.setFilter(.search(query: searchQuery))
|
||||
if searchQuery.isEmpty {
|
||||
self?.roomSummaryProvider.setFilter(.all(filters: []))
|
||||
} else {
|
||||
self?.roomSummaryProvider.setFilter(.search(query: searchQuery))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -79,8 +82,10 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
|
||||
continue
|
||||
}
|
||||
|
||||
let room = MessageForwardingRoom(id: summary.id, name: summary.name, alias: summary.canonicalAlias, avatar: summary.avatar)
|
||||
rooms.append(room)
|
||||
rooms.append(.init(id: summary.id,
|
||||
title: summary.name,
|
||||
description: summary.roomListDescription,
|
||||
avatar: summary.avatar))
|
||||
}
|
||||
|
||||
state.rooms = rooms
|
||||
|
||||
@@ -68,8 +68,8 @@ private struct MessageForwardingListRow: View {
|
||||
let context: MessageForwardingScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ListRow(label: .avatar(title: room.name,
|
||||
description: room.alias ?? room.id,
|
||||
ListRow(label: .avatar(title: room.title,
|
||||
description: room.description,
|
||||
icon: avatar),
|
||||
kind: .selection(isSelected: isSelected) {
|
||||
context.send(viewAction: .selectRoom(roomID: room.id))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct RoomSelectionScreenCoordinatorParameters {
|
||||
let clientProxy: ClientProxyProtocol
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
}
|
||||
|
||||
enum RoomSelectionScreenCoordinatorAction {
|
||||
case dismiss
|
||||
case confirm(roomID: String)
|
||||
}
|
||||
|
||||
final class RoomSelectionScreenCoordinator: CoordinatorProtocol {
|
||||
private var viewModel: RoomSelectionScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<RoomSelectionScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actionsPublisher: AnyPublisher<RoomSelectionScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: RoomSelectionScreenCoordinatorParameters) {
|
||||
viewModel = RoomSelectionScreenViewModel(clientProxy: parameters.clientProxy,
|
||||
roomSummaryProvider: parameters.roomSummaryProvider,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.actionsSubject.send(.dismiss)
|
||||
case .confirm(let roomID):
|
||||
self?.actionsSubject.send(.confirm(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(RoomSelectionScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum RoomSelectionScreenViewModelAction {
|
||||
case dismiss
|
||||
case confirm(roomID: String)
|
||||
}
|
||||
|
||||
struct RoomSelectionScreenViewState: BindableState {
|
||||
var rooms: [RoomSelectionRoom] = []
|
||||
var selectedRoomID: String?
|
||||
var bindings = RoomSelectionScreenViewStateBindings()
|
||||
}
|
||||
|
||||
struct RoomSelectionScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
}
|
||||
|
||||
enum RoomSelectionScreenViewAction {
|
||||
case cancel
|
||||
case confirm
|
||||
case selectRoom(roomID: String)
|
||||
case reachedTop
|
||||
case reachedBottom
|
||||
}
|
||||
|
||||
struct RoomSelectionRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String
|
||||
let avatar: RoomAvatar
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias RoomSelectionScreenViewModelType = StateStoreViewModel<RoomSelectionScreenViewState, RoomSelectionScreenViewAction>
|
||||
|
||||
class RoomSelectionScreenViewModel: RoomSelectionScreenViewModelType, RoomSelectionScreenViewModelProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
|
||||
private var actionsSubject: PassthroughSubject<RoomSelectionScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actionsPublisher: AnyPublisher<RoomSelectionScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(clientProxy: ClientProxyProtocol,
|
||||
roomSummaryProvider: RoomSummaryProviderProtocol,
|
||||
mediaProvider: MediaProviderProtocol) {
|
||||
self.clientProxy = clientProxy
|
||||
self.roomSummaryProvider = roomSummaryProvider
|
||||
|
||||
super.init(initialViewState: RoomSelectionScreenViewState(), mediaProvider: mediaProvider)
|
||||
|
||||
roomSummaryProvider.roomListPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateRooms()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
context.$viewState
|
||||
.map(\.bindings.searchQuery)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] searchQuery in
|
||||
if searchQuery.isEmpty {
|
||||
self?.roomSummaryProvider.setFilter(.all(filters: []))
|
||||
} else {
|
||||
self?.roomSummaryProvider.setFilter(.search(query: searchQuery))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
updateRooms()
|
||||
}
|
||||
|
||||
override func process(viewAction: RoomSelectionScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
roomSummaryProvider.setFilter(.all(filters: []))
|
||||
case .confirm:
|
||||
guard let selectedRoomID = state.selectedRoomID else {
|
||||
return
|
||||
}
|
||||
|
||||
actionsSubject.send(.confirm(roomID: selectedRoomID))
|
||||
case .selectRoom(let roomID):
|
||||
state.selectedRoomID = roomID
|
||||
case .reachedTop:
|
||||
updateVisibleRange(edge: .top)
|
||||
case .reachedBottom:
|
||||
updateVisibleRange(edge: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func updateRooms() {
|
||||
var rooms = [RoomSelectionRoom]()
|
||||
|
||||
for summary in roomSummaryProvider.roomListPublisher.value {
|
||||
rooms.append(.init(id: summary.id,
|
||||
title: summary.name,
|
||||
description: summary.roomListDescription,
|
||||
avatar: summary.avatar))
|
||||
}
|
||||
|
||||
state.rooms = rooms
|
||||
}
|
||||
|
||||
/// The actual range values don't matter as long as they contain the lower
|
||||
/// or upper bounds. updateVisibleRange is a hybrid API that powers both
|
||||
/// sliding sync visible range update and list paginations
|
||||
/// For lists other than the home screen one we don't care about visible ranges,
|
||||
/// we just need the respective bounds to be there to trigger a next page load or
|
||||
/// a reset to just one page
|
||||
private func updateVisibleRange(edge: UIRectEdge) {
|
||||
switch edge {
|
||||
case .top:
|
||||
roomSummaryProvider.updateVisibleRange(0..<0)
|
||||
case .bottom:
|
||||
let roomCount = roomSummaryProvider.roomListPublisher.value.count
|
||||
roomSummaryProvider.updateVisibleRange(roomCount..<roomCount)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol RoomSelectionScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<RoomSelectionScreenViewModelAction, Never> { get }
|
||||
var context: RoomSelectionScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct RoomSelectionScreen: View {
|
||||
@ObservedObject var context: RoomSelectionScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
ForEach(context.viewState.rooms) { room in
|
||||
RoomSelectionListRow(room: room,
|
||||
isSelected: context.viewState.selectedRoomID == room.id,
|
||||
context: context)
|
||||
}
|
||||
// Replace these with ScrollView's `scrollPosition` when dropping iOS 16.
|
||||
} header: {
|
||||
emptyRectangle
|
||||
.onAppear {
|
||||
context.send(viewAction: .reachedTop)
|
||||
}
|
||||
} footer: {
|
||||
emptyRectangle
|
||||
.onAppear {
|
||||
context.send(viewAction: .reachedBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.actionShare) {
|
||||
context.send(viewAction: .confirm)
|
||||
}
|
||||
.disabled(context.viewState.selectedRoomID == nil)
|
||||
}
|
||||
}
|
||||
.searchController(query: $context.searchQuery, showsCancelButton: false)
|
||||
.compoundSearchField()
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
/// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
|
||||
private var emptyRectangle: some View {
|
||||
Rectangle()
|
||||
.frame(width: 0, height: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoomSelectionListRow: View {
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
let room: RoomSelectionRoom
|
||||
let isSelected: Bool
|
||||
let context: RoomSelectionScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ListRow(label: .avatar(title: room.title,
|
||||
description: room.description,
|
||||
icon: avatar),
|
||||
kind: .selection(isSelected: isSelected) {
|
||||
context.send(viewAction: .selectRoom(roomID: room.id))
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder @MainActor
|
||||
var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
RoomAvatarImage(avatar: room.avatar,
|
||||
avatarSize: .room(on: .roomSelection),
|
||||
mediaProvider: context.mediaProvider)
|
||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct RoomSelectionScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
let summaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
|
||||
let viewModel = RoomSelectionScreenViewModel(clientProxy: ClientProxyMock(.init()),
|
||||
roomSummaryProvider: summaryProvider,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
|
||||
NavigationStack {
|
||||
RoomSelectionScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -833,7 +833,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
|
||||
alternateRoomSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
|
||||
eventStringBuilder: eventStringBuilder,
|
||||
name: "MessageForwarding",
|
||||
name: "AlternateAllRooms",
|
||||
notificationSettings: notificationSettings,
|
||||
appSettings: appSettings)
|
||||
try await alternateRoomSummaryProvider?.setRoomList(roomListService.allRooms())
|
||||
|
||||
@@ -69,6 +69,20 @@ extension RoomSummary: CustomStringConvertible {
|
||||
- notificationMode: \(notificationMode?.rawValue ?? "nil")
|
||||
"""
|
||||
}
|
||||
|
||||
/// Used where summaries are shown in a list e.g. message forwarding,
|
||||
/// global search, share destination list etc.
|
||||
var roomListDescription: String {
|
||||
if isDirect {
|
||||
return canonicalAlias ?? ""
|
||||
}
|
||||
|
||||
if let alias = canonicalAlias {
|
||||
return alias
|
||||
}
|
||||
|
||||
return heroes.compactMap(\.displayName).formatted(.list(type: .and))
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomSummary {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import IntentsUI
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
@@ -14,7 +15,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
private let roomProxy: JoinedRoomProxyProtocol
|
||||
private let liveTimelineProvider: RoomTimelineProviderProtocol
|
||||
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let appSettings: AppSettings
|
||||
|
||||
private let serialDispatchQueue: DispatchQueue
|
||||
|
||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||
@@ -40,11 +43,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
timelineProxy: TimelineProxyProtocol,
|
||||
initialFocussedEventID: String?,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
appSettings: AppSettings) {
|
||||
self.roomProxy = roomProxy
|
||||
liveTimelineProvider = timelineProxy.timelineProvider
|
||||
self.timelineItemFactory = timelineItemFactory
|
||||
self.mediaProvider = mediaProvider
|
||||
self.appSettings = appSettings
|
||||
|
||||
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
|
||||
|
||||
activeTimeline = timelineProxy
|
||||
@@ -153,11 +159,63 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
intentionalMentions: intentionalMentions) {
|
||||
case .success:
|
||||
MXLog.info("Finished sending message")
|
||||
await donateSendMessageIntent()
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed sending message with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func donateSendMessageIntent() async {
|
||||
guard let displayName = roomProxy.details.name ?? roomProxy.details.canonicalAlias, !displayName.isEmpty else {
|
||||
MXLog.error("Failed donating send message intent, room missing name or alias.")
|
||||
return
|
||||
}
|
||||
|
||||
let groupName = INSpeakableString(spokenPhrase: displayName)
|
||||
|
||||
let sendMessageIntent = INSendMessageIntent(recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: roomProxy.id,
|
||||
serviceName: nil,
|
||||
sender: nil,
|
||||
attachments: nil)
|
||||
|
||||
let avatarURL = switch roomProxy.details.avatar {
|
||||
case .room(_, _, let avatarURL):
|
||||
avatarURL
|
||||
case .heroes(let userProfiles):
|
||||
userProfiles.first?.avatarURL
|
||||
}
|
||||
|
||||
func addPlacehoder() {
|
||||
if let imageData = Avatars.generatePlaceholderAvatarImageData(name: displayName, id: roomProxy.id, size: .init(width: 100, height: 100)) {
|
||||
sendMessageIntent.setImage(INImage(imageData: imageData), forParameterNamed: \.speakableGroupName)
|
||||
}
|
||||
}
|
||||
|
||||
if let avatarURL {
|
||||
let mediaSource = MediaSourceProxy(url: avatarURL, mimeType: nil)
|
||||
|
||||
if case let .success(avatarData) = await mediaProvider.loadThumbnailForSource(source: mediaSource, size: .init(width: 100, height: 100)) {
|
||||
sendMessageIntent.setImage(INImage(imageData: avatarData), forParameterNamed: \.speakableGroupName)
|
||||
} else {
|
||||
addPlacehoder()
|
||||
}
|
||||
} else {
|
||||
addPlacehoder()
|
||||
}
|
||||
|
||||
let interaction = INInteraction(intent: sendMessageIntent, response: nil)
|
||||
|
||||
do {
|
||||
try await interaction.donate()
|
||||
} catch {
|
||||
MXLog.error("Failed donating send message intent with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleReaction(_ reaction: String, to eventOrTransactionID: EventOrTransactionId) async {
|
||||
MXLog.info("Toggle reaction \(reaction) to \(eventOrTransactionID)")
|
||||
|
||||
|
||||
+6
-2
@@ -10,16 +10,19 @@ import Foundation
|
||||
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
||||
func buildRoomTimelineController(roomProxy: JoinedRoomProxyProtocol,
|
||||
initialFocussedEventID: String?,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol {
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol {
|
||||
RoomTimelineController(roomProxy: roomProxy,
|
||||
timelineProxy: roomProxy.timeline,
|
||||
initialFocussedEventID: initialFocussedEventID,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: mediaProvider,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
|
||||
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol? {
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? {
|
||||
guard let pinnedEventsTimeline = await roomProxy.pinnedEventsTimeline else {
|
||||
return nil
|
||||
}
|
||||
@@ -27,6 +30,7 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
|
||||
timelineProxy: pinnedEventsTimeline,
|
||||
initialFocussedEventID: nil,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: mediaProvider,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -11,9 +11,11 @@ import Foundation
|
||||
protocol RoomTimelineControllerFactoryProtocol {
|
||||
func buildRoomTimelineController(roomProxy: JoinedRoomProxyProtocol,
|
||||
initialFocussedEventID: String?,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol
|
||||
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol?
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol?
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
|
||||
@@ -72,19 +72,19 @@ extension SessionDirectories {
|
||||
init() {
|
||||
let sessionDirectoryName = UUID().uuidString
|
||||
dataDirectory = .sessionsBaseDirectory.appending(component: sessionDirectoryName)
|
||||
cacheDirectory = .cachesBaseDirectory.appending(component: sessionDirectoryName)
|
||||
cacheDirectory = .sessionCachesBaseDirectory.appending(component: sessionDirectoryName)
|
||||
}
|
||||
|
||||
/// Creates the session directories for a user who signed in before the data directory was stored.
|
||||
init(userID: String) {
|
||||
dataDirectory = .legacySessionDirectory(for: userID)
|
||||
cacheDirectory = .cachesBaseDirectory.appending(component: dataDirectory.lastPathComponent)
|
||||
cacheDirectory = .sessionCachesBaseDirectory.appending(component: dataDirectory.lastPathComponent)
|
||||
}
|
||||
|
||||
/// Creates the session directories for a user who has a single session directory stored without a separate caches directory.
|
||||
init(dataDirectory: URL) {
|
||||
self.dataDirectory = dataDirectory
|
||||
cacheDirectory = .cachesBaseDirectory.appending(component: dataDirectory.lastPathComponent)
|
||||
cacheDirectory = .sessionCachesBaseDirectory.appending(component: dataDirectory.lastPathComponent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ShareExtensionConstants {
|
||||
static let urlPath = "share"
|
||||
}
|
||||
|
||||
enum ShareExtensionPayload: Hashable, Codable {
|
||||
case mediaFile(roomID: String?, mediaFile: ShareExtensionMediaFile)
|
||||
}
|
||||
|
||||
struct ShareExtensionMediaFile: Hashable, Codable {
|
||||
let url: URL
|
||||
let suggestedName: String?
|
||||
}
|
||||
@@ -684,6 +684,7 @@ class MockScreen: Identifiable {
|
||||
timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org",
|
||||
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
|
||||
stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
let flowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
||||
|
||||
Reference in New Issue
Block a user