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:
Stefan Ceriu
2024-11-13 14:02:47 +02:00
committed by GitHub
parent 3a600a94d5
commit b122b02bee
69 changed files with 1583 additions and 272 deletions
@@ -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))
}
}
}
+15 -2
View File
@@ -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)),
@@ -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
}
@@ -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
}
}
}
@@ -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)")
@@ -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)
}
}
@@ -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)),