mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Sliding Sync + New Timeline API (#189)
* Begin adopting new Timeline API. * Add edited indicator and reactions. * vector-im/element-x-ios/issues/65 - Sliding sync support * Fix missing room display name, wrong placeholder avatar text color and various other warnings that would fail the build on the CI * Various tweaks: * using release version of the demo branch of the sdk * enabled home screen last room messages * switched debug mode rust logging to warn * enabled redactions * enabled new logout flows and soft logout * enabled replies * Fix room member display name and avatar crashes / race condition, fix unit tests * Make the ClientProxy and the UserSession MainActors * Remove unused MatrixRustSDK imports, we should strive to keep these only in top level services and proxies * Don't start either of the syncs while in soft logout * #181: Style the session verification banner to match Figma. * #181: Update verification modal. * #181: Update snapshot tests. * Make session verification state machine less pedantic * Remove unnecessary weak selfs * Various tweaks following code review: * add start and stop sync client proxy methods * move ss proxy url the build settings * made media provider load results discardable * added publishers for the roomSummaryProvider's total number of rooms and state * Fix when sender details are shown * Disable sync v2, causes duplicates in the timeline (as expected) * Move ClientProxy media loading off the main queue and into a detached task * Another attempt at moving image loading off the main queue * Moved home screen diffing and latest room fetching to the background * Prevent the timeline composer from becoming the first responder when not needed * Bump to a newer version of the RustSDK * Fixes vector-im/element-x-ios/issues/107 - New home screen design * Implement thumbnail loading instead of full image avatars. * Revert "Disable sync v2, causes duplicates in the timeline (as expected)" * Add support for local echoes, dispatching detached tasks to a concurrenc GCD queue * Move the session verification banner to a List Section to avoid UI glitches * Optimise room mapping after sliding sync updates and thumbnail fetching * Replace home screen List with a LazyVStack in an attempt to fix performance. Moved move summary provider room updating to a background thread * Fixes vector-im/element-x-ios/issues/177 - New Bubbles Design * Define in group state for timeline items * Add replies into the bubble * Add timeline width environment value * Add `RoundedCorner` shape with specific corners rounding * Add in group state for previews * Implement bubble grouping logic * Timeline avatar layout changes * Fix placeholder avatars for dark mode * New bubbles design * Update mock timeline items * Update timeline separator design * Update room screen reference screenshots * Add changelog * Formatting fixes * Add some space before single or beginning outgoing items * Redesign the message composer * Handle the msgtype enum. * Update room name label line limit and incoming bubble background. Disabled syncv2, ss withCommonExtensions and session verification controller checking * Increase default back pagination limit. * Stop parsing links and tidy up composer button. * Also fix the frame of an image whilst loading. * Bump SDK package version. * Remove app states about settings * Add strings * Use colors on placeholder avatars * Tiny changes for placeholder avatars * Update settings screen design * Provide a user display name from the mock client * Settings screen presentation logic * Add changelog * Update reference screenshots Co-authored-by: Doug <douglase@element.io> Co-authored-by: ismailgulek <ismailgulek@users.noreply.github.com> Co-authored-by: ismailgulek <ismailg@matrix.org>
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
"untranslated" = "Untranslated";
|
||||
|
||||
"action_confirm" = "Confirm";
|
||||
"action_match" = "Match";
|
||||
|
||||
"screenshot_detected_title" = "You took a screenshot";
|
||||
"screenshot_detected_message" = "Would you like to submit a bug report?";
|
||||
|
||||
"settings_appearance" = "Appearance";
|
||||
"settings_timeline_style" = "Timeline Style";
|
||||
"room_timeline_style_plain_long_description" = "Plain Timeline";
|
||||
"room_timeline_style_bubbled_long_description" = "Bubbled Timeline";
|
||||
@@ -14,6 +16,13 @@
|
||||
|
||||
"room_timeline_replying_to" = "Replying to %@";
|
||||
|
||||
"session_verification_banner_title" = "Help keep your messages secure";
|
||||
"session_verification_banner_message" = "Looks like you’re using a new device. Verify its you.";
|
||||
"session_verification_screen_emojis_title" = "Lets check if these";
|
||||
"session_verification_screen_emojis_message" = "Open Element on one of your other sessions to compare.";
|
||||
|
||||
"home_screen_all_chats" = "All Chats";
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
"authentication_login_title" = "Welcome back!";
|
||||
|
||||
@@ -52,8 +52,6 @@ class AppCoordinator: Coordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private let memberDetailProviderManager: MemberDetailProviderManager
|
||||
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
private let screenshotDetector: ScreenshotDetector
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
@@ -71,15 +69,16 @@ class AppCoordinator: Coordinator {
|
||||
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
|
||||
|
||||
splashViewController = SplashViewController()
|
||||
|
||||
mainNavigationController = ElementNavigationController(rootViewController: splashViewController)
|
||||
mainNavigationController.navigationBar.prefersLargeTitles = true
|
||||
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = mainNavigationController
|
||||
window.tintColor = .element.accent
|
||||
|
||||
navigationRouter = NavigationRouter(navigationController: mainNavigationController)
|
||||
|
||||
memberDetailProviderManager = MemberDetailProviderManager()
|
||||
|
||||
ServiceLocator.serviceLocator = ServiceLocator(userIndicatorPresenter: UserIndicatorTypePresenter(presentingViewController: mainNavigationController))
|
||||
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
@@ -115,7 +114,7 @@ class AppCoordinator: Coordinator {
|
||||
// This exposes the full Rust side tracing subscriber filter for more flexibility.
|
||||
// We can filter by level, crate and even file. See more details here:
|
||||
// https://docs.rs/tracing-subscriber/0.2.7/tracing_subscriber/filter/struct.EnvFilter.html#examples
|
||||
setupTracing(configuration: "info,hyper=warn,sled=warn,matrix_sdk_sled=warn")
|
||||
setupTracing(configuration: "warn,hyper=warn,sled=warn,matrix_sdk_sled=warn")
|
||||
|
||||
loggerConfiguration.logLevel = .debug
|
||||
#else
|
||||
@@ -131,7 +130,7 @@ class AppCoordinator: Coordinator {
|
||||
MXLog.configure(loggerConfiguration)
|
||||
}
|
||||
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
guard let self = self else { return }
|
||||
@@ -171,12 +170,7 @@ class AppCoordinator: Coordinator {
|
||||
case (.remoteSigningOut(let isSoft), .completedSigningOut, .signedOut):
|
||||
self.presentSplashScreen(isSoftLogout: isSoft)
|
||||
self.hideLoadingIndicator()
|
||||
|
||||
case (.homeScreen, .showSettingsScreen, .settingsScreen):
|
||||
self.presentSettingsScreen()
|
||||
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
|
||||
self.tearDownDismissedSettingsScreen()
|
||||
|
||||
|
||||
case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen):
|
||||
self.presentSessionVerification()
|
||||
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen):
|
||||
@@ -191,8 +185,6 @@ class AppCoordinator: Coordinator {
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity function_body_length
|
||||
|
||||
private func restoreUserSession() {
|
||||
Task {
|
||||
@@ -265,21 +257,23 @@ class AppCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func tearDownUserSession(isSoftLogout: Bool = false) {
|
||||
Task {
|
||||
deobserveUserSessionChanges()
|
||||
|
||||
if !isSoftLogout {
|
||||
userSession.clientProxy.stopSync()
|
||||
|
||||
deobserveUserSessionChanges()
|
||||
|
||||
if !isSoftLogout {
|
||||
Task {
|
||||
// first log out from the server
|
||||
_ = await userSession.clientProxy.logout()
|
||||
|
||||
|
||||
// regardless of the result, clear user data
|
||||
userSessionStore.logout(userSession: userSession)
|
||||
userSession = nil
|
||||
}
|
||||
|
||||
// complete logging out
|
||||
stateMachine.processEvent(.completedSigningOut)
|
||||
}
|
||||
|
||||
// complete logging out
|
||||
stateMachine.processEvent(.completedSigningOut)
|
||||
}
|
||||
|
||||
private func presentSplashScreen(isSoftLogout: Bool = false) {
|
||||
@@ -297,9 +291,10 @@ class AppCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func presentHomeScreen() {
|
||||
userSession.clientProxy.startSync()
|
||||
|
||||
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
memberDetailProviderManager: memberDetailProviderManager)
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
let coordinator = HomeScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
@@ -309,7 +304,7 @@ class AppCoordinator: Coordinator {
|
||||
case .presentRoom(let roomIdentifier):
|
||||
self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier))
|
||||
case .presentSettings:
|
||||
self.stateMachine.processEvent(.showSettingsScreen)
|
||||
self.presentSettingsScreen()
|
||||
case .presentBugReport:
|
||||
self.presentBugReportScreen()
|
||||
case .verifySession:
|
||||
@@ -353,31 +348,29 @@ class AppCoordinator: Coordinator {
|
||||
// MARK: Rooms
|
||||
|
||||
private func presentRoomWithIdentifier(_ roomIdentifier: String) {
|
||||
guard let roomProxy = userSession.clientProxy.rooms.first(where: { $0.id == roomIdentifier }) else {
|
||||
guard let roomProxy = userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
|
||||
MXLog.error("Invalid room identifier: \(roomIdentifier)")
|
||||
return
|
||||
}
|
||||
let userId = userSession.clientProxy.userIdentifier
|
||||
|
||||
let memberDetailProvider = memberDetailProviderManager.memberDetailProviderForRoomProxy(roomProxy)
|
||||
|
||||
let timelineItemFactory = RoomTimelineItemFactory(mediaProvider: userSession.mediaProvider,
|
||||
memberDetailProvider: memberDetailProvider,
|
||||
let timelineItemFactory = RoomTimelineItemFactory(userID: userId,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
roomProxy: roomProxy,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
|
||||
|
||||
let timelineController = RoomTimelineController(userId: userId,
|
||||
roomId: roomIdentifier,
|
||||
timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
|
||||
timelineProvider: roomProxy.timelineProvider,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
memberDetailProvider: memberDetailProvider)
|
||||
|
||||
roomProxy: roomProxy)
|
||||
|
||||
let parameters = RoomScreenCoordinatorParameters(timelineController: timelineController,
|
||||
roomName: roomProxy.displayName ?? roomProxy.name,
|
||||
roomAvatar: userSession.mediaProvider.imageFromURLString(roomProxy.avatarURL),
|
||||
roomEncryptionBadge: roomProxy.encryptionBadgeImage)
|
||||
roomAvatar: userSession.mediaProvider.imageFromURLString(roomProxy.avatarURL, size: MediaProviderDefaultAvatarSize))
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -396,32 +389,39 @@ class AppCoordinator: Coordinator {
|
||||
// MARK: Settings
|
||||
|
||||
private func presentSettingsScreen() {
|
||||
let parameters = SettingsCoordinatorParameters(navigationRouter: navigationRouter,
|
||||
let navController = ElementNavigationController()
|
||||
let newNavigationRouter = NavigationRouter(navigationController: navController)
|
||||
|
||||
let parameters = SettingsCoordinatorParameters(navigationRouter: newNavigationRouter,
|
||||
userSession: userSession,
|
||||
bugReportService: bugReportService)
|
||||
let coordinator = SettingsCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self.dismissSettingsScreen()
|
||||
case .logout:
|
||||
self.dismissSettingsScreen()
|
||||
self.stateMachine.processEvent(.signOut)
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
navigationRouter.push(coordinator) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.stateMachine.processEvent(.dismissedSettingsScreen)
|
||||
}
|
||||
navController.viewControllers = [coordinator.toPresentable()]
|
||||
navigationRouter.present(navController, animated: true)
|
||||
}
|
||||
|
||||
private func tearDownDismissedSettingsScreen() {
|
||||
guard let coordinator = childCoordinators.last as? SettingsCoordinator else {
|
||||
fatalError("Invalid coordinator hierarchy: \(childCoordinators)")
|
||||
|
||||
@objc
|
||||
private func dismissSettingsScreen() {
|
||||
MXLog.debug("dismissSettingsScreen")
|
||||
|
||||
guard let coordinator = childCoordinators.first(where: { $0 is SettingsCoordinator }) else {
|
||||
return
|
||||
}
|
||||
|
||||
navigationRouter.dismissModule()
|
||||
remove(childCoordinator: coordinator)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,7 @@ class AppCoordinatorStateMachine {
|
||||
|
||||
/// Showing the session verification flows
|
||||
case sessionVerificationScreen
|
||||
|
||||
/// Showing the settings screen
|
||||
case settingsScreen
|
||||
|
||||
|
||||
/// Processing a sign out request
|
||||
case signingOut
|
||||
|
||||
@@ -77,11 +74,6 @@ class AppCoordinatorStateMachine {
|
||||
case showSessionVerificationScreen
|
||||
/// Session verification has finished
|
||||
case dismissedSessionVerificationScreen
|
||||
|
||||
/// Request settings screen presentation
|
||||
case showSettingsScreen
|
||||
/// The settings screen has been dismissed
|
||||
case dismissedSettingsScreen
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
@@ -97,9 +89,6 @@ class AppCoordinatorStateMachine {
|
||||
|
||||
machine.addRoutes(event: .signOut, transitions: [.any => .signingOut])
|
||||
machine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut])
|
||||
|
||||
machine.addRoutes(event: .showSettingsScreen, transitions: [.homeScreen => .settingsScreen])
|
||||
machine.addRoutes(event: .dismissedSettingsScreen, transitions: [.settingsScreen => .homeScreen])
|
||||
|
||||
machine.addRoutes(event: .showSessionVerificationScreen, transitions: [.homeScreen => .sessionVerificationScreen])
|
||||
machine.addRoutes(event: .dismissedSessionVerificationScreen, transitions: [.sessionVerificationScreen => .homeScreen])
|
||||
|
||||
@@ -20,6 +20,7 @@ final class BuildSettings {
|
||||
// MARK: - Servers
|
||||
|
||||
static let defaultHomeserverAddress = "matrix.org"
|
||||
static let slidingSyncProxyBaseURL = URL(staticString: "https://slidingsync.lab.element.dev")
|
||||
|
||||
// MARK: - Bug report
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import Foundation
|
||||
extension ElementL10n {
|
||||
/// Confirm
|
||||
public static let actionConfirm = ElementL10n.tr("Untranslated", "action_confirm")
|
||||
/// Match
|
||||
public static let actionMatch = ElementL10n.tr("Untranslated", "action_match")
|
||||
/// Forgot password
|
||||
public static let authenticationLoginForgotPassword = ElementL10n.tr("Untranslated", "authentication_login_forgot_password")
|
||||
/// Welcome back!
|
||||
@@ -20,6 +22,8 @@ extension ElementL10n {
|
||||
public static let authenticationServerInfoMatrixDescription = ElementL10n.tr("Untranslated", "authentication_server_info_matrix_description")
|
||||
/// Choose your server to store your data
|
||||
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
|
||||
/// All Chats
|
||||
public static let homeScreenAllChats = ElementL10n.tr("Untranslated", "home_screen_all_chats")
|
||||
/// Mobile
|
||||
public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device")
|
||||
/// Tablet
|
||||
@@ -48,6 +52,16 @@ extension ElementL10n {
|
||||
public static let serverSelectionServerUrl = ElementL10n.tr("Untranslated", "server_selection_server_url")
|
||||
/// Choose your server
|
||||
public static let serverSelectionTitle = ElementL10n.tr("Untranslated", "server_selection_title")
|
||||
/// Looks like you’re using a new device. Verify its you.
|
||||
public static let sessionVerificationBannerMessage = ElementL10n.tr("Untranslated", "session_verification_banner_message")
|
||||
/// Help keep your messages secure
|
||||
public static let sessionVerificationBannerTitle = ElementL10n.tr("Untranslated", "session_verification_banner_title")
|
||||
/// Open Element on one of your other sessions to compare.
|
||||
public static let sessionVerificationScreenEmojisMessage = ElementL10n.tr("Untranslated", "session_verification_screen_emojis_message")
|
||||
/// Lets check if these
|
||||
public static let sessionVerificationScreenEmojisTitle = ElementL10n.tr("Untranslated", "session_verification_screen_emojis_title")
|
||||
/// Appearance
|
||||
public static let settingsAppearance = ElementL10n.tr("Untranslated", "settings_appearance")
|
||||
/// Timeline Style
|
||||
public static let settingsTimelineStyle = ElementL10n.tr("Untranslated", "settings_timeline_style")
|
||||
/// Untranslated
|
||||
|
||||
+5
-3
@@ -16,7 +16,9 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol EventBriefFactoryProtocol {
|
||||
func buildEventBriefFor(message: RoomMessageProtocol?) async -> EventBrief?
|
||||
extension Collection {
|
||||
/// Returns the element at the specified index if it is within bounds, otherwise nil.
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,16 @@ extension String {
|
||||
let detector = try? NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive)
|
||||
return detector?.numberOfMatches(in: self, range: range) ?? 0 == 1
|
||||
}
|
||||
|
||||
/// Calculates a numeric hash same as Element Web
|
||||
/// See original function here https://github.com/matrix-org/matrix-react-sdk/blob/321dd49db4fbe360fc2ff109ac117305c955b061/src/utils/FormattingUtils.js#L47
|
||||
var hashCode: Int32 {
|
||||
var hash: Int32 = 0
|
||||
|
||||
for character in self {
|
||||
let shiftedHash = hash << 5
|
||||
hash = shiftedHash.subtractingReportingOverflow(hash).partialValue + Int32(character.unicodeScalars[character.unicodeScalars.startIndex].value)
|
||||
}
|
||||
return abs(hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Task where Failure == Never {
|
||||
static func dispatched(on queue: DispatchQueue,
|
||||
priority: TaskPriority? = nil,
|
||||
operation: @escaping @Sendable () -> Success) -> Task<Success, Failure> {
|
||||
Task.detached(priority: priority) {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
continuation.resume(returning: operation())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Task where Failure == Error {
|
||||
static func dispatched(on queue: DispatchQueue,
|
||||
priority: TaskPriority? = nil,
|
||||
operation: @escaping @Sendable () throws -> Success) -> Task<Success, Failure> {
|
||||
Task.detached(priority: priority) {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
let result = try operation()
|
||||
continuation.resume(returning: result)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RoundedCorner: Shape {
|
||||
let radius: CGFloat
|
||||
let corners: UIRectCorner
|
||||
|
||||
init(radius: CGFloat, corners: UIRectCorner) {
|
||||
self.radius = radius
|
||||
self.corners = corners
|
||||
}
|
||||
|
||||
init(radius: CGFloat, inGroupState: TimelineItemInGroupState) {
|
||||
self.init(radius: radius, corners: inGroupState.roundedCorners)
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect,
|
||||
byRoundingCorners: corners,
|
||||
cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
|
||||
func cornerRadius(_ radius: CGFloat, inGroupState: TimelineItemInGroupState) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, inGroupState: inGroupState))
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//
|
||||
|
||||
import AppAuth
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
struct LoginCoordinatorParameters {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//
|
||||
|
||||
import AppAuth
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
struct SoftLogoutCoordinatorParameters {
|
||||
|
||||
@@ -20,7 +20,6 @@ import SwiftUI
|
||||
struct HomeScreenCoordinatorParameters {
|
||||
let userSession: UserSessionProtocol
|
||||
let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||
let memberDetailProviderManager: MemberDetailProviderManager
|
||||
}
|
||||
|
||||
enum HomeScreenCoordinatorAction {
|
||||
@@ -40,8 +39,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
private let hostingController: UIViewController
|
||||
private var viewModel: HomeScreenViewModelProtocol
|
||||
|
||||
private var roomSummaries: [RoomSummary] = []
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: Public
|
||||
@@ -55,7 +52,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
init(parameters: HomeScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = HomeScreenViewModel(attributedStringBuilder: parameters.attributedStringBuilder)
|
||||
viewModel = HomeScreenViewModel(userSession: parameters.userSession,
|
||||
attributedStringBuilder: parameters.attributedStringBuilder)
|
||||
|
||||
let view = HomeScreen(context: viewModel.context)
|
||||
hostingController = UIHostingController(rootView: view)
|
||||
@@ -72,42 +70,10 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
self.callback?(.verifySession)
|
||||
}
|
||||
}
|
||||
|
||||
parameters.userSession.clientProxy
|
||||
.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
if case .updatedRoomsList = callback {
|
||||
self?.updateRoomsList()
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
parameters.userSession.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
switch callback {
|
||||
case .sessionVerificationNeeded:
|
||||
self?.viewModel.showSessionVerificationBanner()
|
||||
case .didVerifySession:
|
||||
self?.viewModel.hideSessionVerificationBanner()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
updateRoomsList()
|
||||
|
||||
Task {
|
||||
if case let .success(userAvatarURLString) = await parameters.userSession.clientProxy.loadUserAvatarURLString() {
|
||||
if case let .success(avatar) = await parameters.userSession.mediaProvider.loadImageFromURLString(userAvatarURLString) {
|
||||
self.viewModel.updateWithUserAvatar(avatar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
|
||||
func start() { }
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
@@ -116,26 +82,6 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
func updateRoomsList() {
|
||||
roomSummaries = parameters.userSession.clientProxy.rooms.compactMap { roomProxy in
|
||||
guard roomProxy.isJoined, !roomProxy.isSpace, !roomProxy.isTombstoned else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let summary = self.roomSummaries.first(where: { $0.id == roomProxy.id }) {
|
||||
return summary
|
||||
}
|
||||
|
||||
let memberDetailProvider = parameters.memberDetailProviderManager.memberDetailProviderForRoomProxy(roomProxy)
|
||||
|
||||
return RoomSummary(roomProxy: roomProxy,
|
||||
mediaProvider: parameters.userSession.mediaProvider,
|
||||
eventBriefFactory: EventBriefFactory(memberDetailProvider: memberDetailProvider))
|
||||
}
|
||||
|
||||
viewModel.updateWithRoomSummaries(roomSummaries)
|
||||
}
|
||||
|
||||
private func processUserMenuAction(_ action: HomeScreenViewUserMenuAction) {
|
||||
switch action {
|
||||
case .settings:
|
||||
|
||||
@@ -35,6 +35,7 @@ enum HomeScreenViewAction {
|
||||
case selectRoom(roomIdentifier: String)
|
||||
case userMenu(action: HomeScreenViewUserMenuAction)
|
||||
case verifySession
|
||||
case skipSessionVerification
|
||||
}
|
||||
|
||||
struct HomeScreenViewState: BindableState {
|
||||
@@ -44,24 +45,16 @@ struct HomeScreenViewState: BindableState {
|
||||
|
||||
var rooms: [HomeScreenRoom] = []
|
||||
|
||||
var isLoadingRooms = false
|
||||
|
||||
var visibleDMs: [HomeScreenRoom] {
|
||||
searchFilteredRooms.filter(\.isDirect)
|
||||
var isLoadingRooms: Bool {
|
||||
rooms.isEmpty
|
||||
}
|
||||
|
||||
|
||||
var visibleRooms: [HomeScreenRoom] {
|
||||
searchFilteredRooms.filter { !$0.isDirect }
|
||||
}
|
||||
|
||||
private var searchFilteredRooms: LazyFilterSequence<LazySequence<[HomeScreenRoom]>.Elements> {
|
||||
guard !bindings.searchQuery.isEmpty else {
|
||||
// This extra filter is fine for now as there are always downstream filters
|
||||
// but if that changes, this approach should be reconsidered.
|
||||
return rooms.lazy.filter { _ in true }
|
||||
if bindings.searchQuery.isEmpty {
|
||||
return rooms
|
||||
}
|
||||
|
||||
return rooms.lazy.filter { $0.displayName?.localizedStandardContains(bindings.searchQuery) ?? false }
|
||||
return rooms.lazy.filter { $0.name.localizedStandardContains(bindings.searchQuery) }
|
||||
}
|
||||
|
||||
var bindings = HomeScreenViewStateBindings()
|
||||
@@ -74,17 +67,15 @@ struct HomeScreenViewStateBindings {
|
||||
struct HomeScreenRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
|
||||
var displayName: String?
|
||||
let name: String
|
||||
|
||||
var topic: String?
|
||||
var lastMessage: String?
|
||||
let hasUnreads: Bool
|
||||
|
||||
let timestamp: String?
|
||||
|
||||
var lastMessage: AttributedString?
|
||||
|
||||
var avatar: UIImage?
|
||||
|
||||
let isDirect: Bool
|
||||
let isEncrypted: Bool
|
||||
let isSpace: Bool
|
||||
let isTombstoned: Bool
|
||||
}
|
||||
|
||||
extension MutableCollection where Element == HomeScreenRoom {
|
||||
|
||||
@@ -20,29 +20,54 @@ import SwiftUI
|
||||
typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, HomeScreenViewAction>
|
||||
|
||||
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
|
||||
private let userSession: UserSessionProtocol
|
||||
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
private let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||
|
||||
private var roomUpdateListeners = Set<AnyCancellable>()
|
||||
private var roomsUpdateTask: Task<Void, Never>? {
|
||||
willSet {
|
||||
roomsUpdateTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private var roomSummaries: [RoomSummaryProtocol]? {
|
||||
didSet {
|
||||
state.isLoadingRooms = (roomSummaries?.count ?? 0 == 0)
|
||||
}
|
||||
}
|
||||
|
||||
var callback: ((HomeScreenViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(attributedStringBuilder: AttributedStringBuilderProtocol) {
|
||||
init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) {
|
||||
self.userSession = userSession
|
||||
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
|
||||
self.attributedStringBuilder = attributedStringBuilder
|
||||
|
||||
super.init(initialViewState: HomeScreenViewState(isLoadingRooms: true))
|
||||
super.init(initialViewState: HomeScreenViewState())
|
||||
|
||||
userSession.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
switch callback {
|
||||
case .sessionVerificationNeeded:
|
||||
self?.state.showSessionVerificationBanner = true
|
||||
case .didVerifySession:
|
||||
self?.state.showSessionVerificationBanner = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
roomSummaryProvider.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
switch callback {
|
||||
case .updatedRoomSummaries:
|
||||
Task {
|
||||
await self?.updateRooms()
|
||||
}
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
Task {
|
||||
if case let .success(userAvatarURLString) = await userSession.clientProxy.loadUserAvatarURLString() {
|
||||
if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURLString(userAvatarURLString, size: MediaProviderDefaultAvatarSize) {
|
||||
state.userAvatar = avatar
|
||||
}
|
||||
}
|
||||
|
||||
await updateRooms()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -50,124 +75,63 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
override func process(viewAction: HomeScreenViewAction) async {
|
||||
switch viewAction {
|
||||
case .loadRoomData(let roomIdentifier):
|
||||
loadRoomDataForIdentifier(roomIdentifier)
|
||||
loadDataForRoomIdentifier(roomIdentifier)
|
||||
case .selectRoom(let roomIdentifier):
|
||||
callback?(.selectRoom(roomIdentifier: roomIdentifier))
|
||||
case .userMenu(let action):
|
||||
callback?(.userMenu(action: action))
|
||||
case .verifySession:
|
||||
callback?(.verifySession)
|
||||
case .skipSessionVerification:
|
||||
state.showSessionVerificationBanner = false
|
||||
}
|
||||
}
|
||||
|
||||
func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) {
|
||||
roomsUpdateTask = Task {
|
||||
await updateWithRoomSummaries(roomSummaries)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol]) async {
|
||||
var rooms = [HomeScreenRoom]()
|
||||
for summary in roomSummaries {
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
rooms.append(await buildOrUpdateRoomForSummary(summary))
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
state.rooms = rooms
|
||||
self.roomSummaries = roomSummaries
|
||||
|
||||
roomUpdateListeners.removeAll()
|
||||
roomSummaries.forEach { roomSummary in
|
||||
roomSummary.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
if let index = self.state.rooms.firstIndex(where: { $0.id == roomSummary.id }) {
|
||||
switch callback {
|
||||
case .updatedLastMessage:
|
||||
var room = self.state.rooms[index]
|
||||
room.lastMessage = await self.lastMessageFromEventBrief(roomSummary.lastMessage)
|
||||
self.state.rooms[index] = room
|
||||
default:
|
||||
self.state.rooms[index] = await self.buildOrUpdateRoomForSummary(roomSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &roomUpdateListeners)
|
||||
}
|
||||
}
|
||||
|
||||
func updateWithUserAvatar(_ avatar: UIImage) {
|
||||
state.userAvatar = avatar
|
||||
}
|
||||
|
||||
func showSessionVerificationBanner() {
|
||||
state.showSessionVerificationBanner = true
|
||||
}
|
||||
|
||||
func hideSessionVerificationBanner() {
|
||||
state.showSessionVerificationBanner = false
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadRoomDataForIdentifier(_ roomIdentifier: String) {
|
||||
guard let roomSummary = roomSummaries?.first(where: { $0.id == roomIdentifier }) else {
|
||||
MXLog.error("Invalid room identifier")
|
||||
private func loadDataForRoomIdentifier(_ identifier: String) {
|
||||
guard let summary = roomSummaryProvider.roomSummaries.first(where: { $0.id == identifier }),
|
||||
var room = state.rooms.first(where: { $0.id == identifier }) else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { await roomSummary.loadDetails() }
|
||||
}
|
||||
|
||||
private func buildOrUpdateRoomForSummary(_ roomSummary: RoomSummaryProtocol) async -> HomeScreenRoom {
|
||||
guard var room = state.rooms.first(where: { $0.id == roomSummary.id }) else {
|
||||
return HomeScreenRoom(id: roomSummary.id,
|
||||
displayName: roomSummary.displayName,
|
||||
topic: roomSummary.topic,
|
||||
lastMessage: await lastMessageFromEventBrief(roomSummary.lastMessage),
|
||||
avatar: roomSummary.avatar,
|
||||
isDirect: roomSummary.isDirect,
|
||||
isEncrypted: roomSummary.isEncrypted,
|
||||
isSpace: roomSummary.isSpace,
|
||||
isTombstoned: roomSummary.isTombstoned)
|
||||
if room.avatar != nil {
|
||||
return
|
||||
}
|
||||
|
||||
room.avatar = roomSummary.avatar
|
||||
room.displayName = roomSummary.displayName
|
||||
room.topic = roomSummary.topic
|
||||
if let avatarURLString = summary.avatarURLString {
|
||||
Task {
|
||||
if case let .success(image) = await userSession.mediaProvider.loadImageFromURLString(avatarURLString, size: MediaProviderDefaultAvatarSize) {
|
||||
if let index = state.rooms.firstIndex(of: room) {
|
||||
room.avatar = image
|
||||
state.rooms[index] = room
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRooms() async {
|
||||
state.rooms = await Task.detached {
|
||||
var rooms = [HomeScreenRoom]()
|
||||
|
||||
for summary in self.roomSummaryProvider.roomSummaries {
|
||||
let avatarImage = await self.userSession.mediaProvider.imageFromURLString(summary.avatarURLString, size: MediaProviderDefaultAvatarSize)
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
private func lastMessageFromEventBrief(_ eventBrief: EventBrief?) async -> String? {
|
||||
guard let eventBrief = eventBrief else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let senderDisplayName = senderDisplayNameForBrief(eventBrief)
|
||||
|
||||
if let htmlBody = eventBrief.htmlBody,
|
||||
let lastMessageAttributedString = await attributedStringBuilder.fromHTML(htmlBody) {
|
||||
return "\(senderDisplayName): \(String(lastMessageAttributedString.characters))"
|
||||
} else {
|
||||
return "\(senderDisplayName): \(eventBrief.body)"
|
||||
}
|
||||
}
|
||||
|
||||
private func senderDisplayNameForBrief(_ brief: EventBrief) -> String {
|
||||
brief.senderDisplayName ?? brief.senderId
|
||||
var timestamp: String?
|
||||
if let lastMessageTimestamp = summary.lastMessageTimestamp {
|
||||
timestamp = lastMessageTimestamp.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
|
||||
rooms.append(HomeScreenRoom(id: summary.id,
|
||||
name: summary.name,
|
||||
hasUnreads: summary.unreadNotificationCount > 0,
|
||||
timestamp: timestamp,
|
||||
lastMessage: summary.lastMessage,
|
||||
avatar: avatarImage))
|
||||
}
|
||||
|
||||
return rooms
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,4 @@ protocol HomeScreenViewModelProtocol {
|
||||
var callback: ((HomeScreenViewModelAction) -> Void)? { get set }
|
||||
|
||||
var context: HomeScreenViewModelType.Context { get }
|
||||
|
||||
func updateWithUserAvatar(_ avatar: UIImage)
|
||||
func updateWithRoomSummaries(_ roomSummaries: [RoomSummaryProtocol])
|
||||
|
||||
func showSessionVerificationBanner()
|
||||
func hideSessionVerificationBanner()
|
||||
}
|
||||
|
||||
@@ -29,36 +29,20 @@ struct HomeScreen: View {
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
if context.viewState.showSessionVerificationBanner {
|
||||
HStack {
|
||||
Text(ElementL10n.verificationVerifyDevice)
|
||||
Spacer()
|
||||
Button(ElementL10n.startVerification) {
|
||||
context.send(viewAction: .verifySession)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.element.quaternaryContent)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
List {
|
||||
Section(ElementL10n.rooms) {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
RoomCell(room: room, context: context)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
ScrollView {
|
||||
if context.viewState.showSessionVerificationBanner {
|
||||
sessionVerificationBanner
|
||||
}
|
||||
|
||||
Section(ElementL10n.bottomActionPeople) {
|
||||
ForEach(context.viewState.visibleDMs) { room in
|
||||
RoomCell(room: room, context: context)
|
||||
.listRowBackground(Color.clear)
|
||||
LazyVStack {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
}
|
||||
}
|
||||
.searchable(text: $context.searchQuery)
|
||||
.animation(.default, value: context.viewState.visibleRooms)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.searchable(text: $context.searchQuery)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -66,7 +50,7 @@ struct HomeScreen: View {
|
||||
.transition(.slide)
|
||||
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(ElementL10n.homeScreenAllChats)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
userMenuButton
|
||||
@@ -119,6 +103,37 @@ struct HomeScreen: View {
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionVerificationBanner: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(ElementL10n.sessionVerificationBannerTitle)
|
||||
.font(.element.subheadlineBold)
|
||||
.foregroundColor(.element.systemPrimaryLabel)
|
||||
Text(ElementL10n.sessionVerificationBannerMessage)
|
||||
.font(.element.footnote)
|
||||
.foregroundColor(.element.systemSecondaryLabel)
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button(ElementL10n.actionSkip) {
|
||||
context.send(viewAction: .skipSessionVerification)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.elementCapsule)
|
||||
|
||||
Button(ElementL10n.continue) {
|
||||
context.send(viewAction: .verifySession)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.elementCapsuleProminent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.element.systemSecondaryBackground)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func settings() {
|
||||
context.send(viewAction: .userMenu(action: .settings))
|
||||
@@ -137,62 +152,6 @@ struct HomeScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomCell: View {
|
||||
@ScaledMetric private var avatarSize = 32.0
|
||||
|
||||
let room: HomeScreenRoom
|
||||
let context: HomeScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
context.send(viewAction: .selectRoom(roomIdentifier: room.id))
|
||||
} label: {
|
||||
HStack(spacing: 16.0) {
|
||||
if let avatar = room.avatar {
|
||||
Image(uiImage: avatar)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
PlaceholderAvatarImage(text: room.displayName ?? room.id)
|
||||
.clipShape(Circle())
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2.0) {
|
||||
Text(roomName(room))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if let roomTopic = room.topic, roomTopic.count > 0 {
|
||||
Text(roomTopic)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let lastMessage = room.lastMessage {
|
||||
Text(lastMessage)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: room)
|
||||
.frame(minHeight: 60.0)
|
||||
.task {
|
||||
context.send(viewAction: .loadRoomData(roomIdentifier: room.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func roomName(_ room: HomeScreenRoom) -> String {
|
||||
room.displayName ?? room.id + (room.isEncrypted ? "🛡" : "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct HomeScreen_Previews: PreviewProvider {
|
||||
@@ -202,28 +161,13 @@ struct HomeScreen_Previews: PreviewProvider {
|
||||
body.preferredColorScheme(.dark)
|
||||
.tint(.element.accent)
|
||||
}
|
||||
|
||||
|
||||
static var body: some View {
|
||||
let viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder())
|
||||
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "John Doe"),
|
||||
mediaProvider: MockMediaProvider())
|
||||
|
||||
let eventBrief = EventBrief(eventId: "id",
|
||||
senderId: "senderId",
|
||||
senderDisplayName: "Sender",
|
||||
body: "Some message",
|
||||
htmlBody: nil,
|
||||
date: .now)
|
||||
|
||||
let roomSummaries = [MockRoomSummary(displayName: "Alpha", topic: "Topic"),
|
||||
MockRoomSummary(displayName: "Beta"),
|
||||
MockRoomSummary(displayName: "Omega", lastMessage: eventBrief)]
|
||||
|
||||
viewModel.updateWithRoomSummaries(roomSummaries)
|
||||
|
||||
if let avatarImage = UIImage(systemName: "person.fill") {
|
||||
viewModel.updateWithUserAvatar(avatarImage)
|
||||
}
|
||||
|
||||
viewModel.showSessionVerificationBanner()
|
||||
let viewModel = HomeScreenViewModel(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
|
||||
return NavigationView {
|
||||
HomeScreen(context: viewModel.context)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeScreenRoomCell: View {
|
||||
@ScaledMetric private var avatarSize = 44.0
|
||||
|
||||
let room: HomeScreenRoom
|
||||
let context: HomeScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
context.send(viewAction: .selectRoom(roomIdentifier: room.id))
|
||||
} label: {
|
||||
HStack(spacing: 16.0) {
|
||||
if let avatar = room.avatar {
|
||||
Image(uiImage: avatar)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
PlaceholderAvatarImage(text: room.name, contentId: room.id)
|
||||
.clipShape(Circle())
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
}
|
||||
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 2.0) {
|
||||
Text(room.name)
|
||||
.font(.element.callout.bold())
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.lineLimit(1)
|
||||
|
||||
if let lastMessage = room.lastMessage {
|
||||
Text(lastMessage)
|
||||
.font(lastMessageFont)
|
||||
.foregroundColor(lastMessageForegroundColor)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(.top, 2)
|
||||
.animation(nil, value: UUID()) // Text animations look ugly
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3.0) {
|
||||
if let timestamp = room.timestamp {
|
||||
Text(timestamp)
|
||||
.font(.element.caption1)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
}
|
||||
|
||||
if room.hasUnreads {
|
||||
Rectangle()
|
||||
.frame(width: 12, height: 12)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 64.0)
|
||||
.task {
|
||||
context.send(viewAction: .loadRoomData(roomIdentifier: room.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lastMessageFont: Font {
|
||||
if room.hasUnreads {
|
||||
return .element.subheadline.bold()
|
||||
} else {
|
||||
return .element.subheadline
|
||||
}
|
||||
}
|
||||
|
||||
var lastMessageForegroundColor: Color {
|
||||
if room.hasUnreads {
|
||||
return .element.primaryContent
|
||||
} else {
|
||||
return .element.secondaryContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeScreenRoomCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
body.preferredColorScheme(.light)
|
||||
.tint(.element.accent)
|
||||
body.preferredColorScheme(.dark)
|
||||
.tint(.element.accent)
|
||||
}
|
||||
|
||||
static var body: some View {
|
||||
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "John Doe"),
|
||||
mediaProvider: MockMediaProvider())
|
||||
|
||||
let viewModel = HomeScreenViewModel(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
|
||||
return VStack {
|
||||
ForEach(viewModel.context.viewState.rooms) { room in
|
||||
HomeScreenRoomCell(room: room, context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ struct RoomScreenCoordinatorParameters {
|
||||
let timelineController: RoomTimelineControllerProtocol
|
||||
let roomName: String?
|
||||
let roomAvatar: UIImage?
|
||||
let roomEncryptionBadge: UIImage?
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: Coordinator, Presentable {
|
||||
@@ -45,8 +44,7 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
|
||||
let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
|
||||
timelineViewFactory: RoomTimelineViewFactory(),
|
||||
roomName: parameters.roomName,
|
||||
roomAvatar: parameters.roomAvatar,
|
||||
roomEncryptionBadge: parameters.roomEncryptionBadge)
|
||||
roomAvatar: parameters.roomAvatar)
|
||||
|
||||
let view = RoomScreen(context: viewModel.context)
|
||||
roomScreenViewModel = viewModel
|
||||
|
||||
@@ -43,6 +43,7 @@ enum RoomScreenViewAction {
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
var roomId: String
|
||||
var roomTitle = ""
|
||||
var roomAvatar: UIImage?
|
||||
var roomEncryptionBadge: UIImage?
|
||||
@@ -54,7 +55,6 @@ struct RoomScreenViewState: BindableState {
|
||||
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
|
||||
var messageComposerDisabled = false // Remove this when we have local echoes
|
||||
var sendButtonDisabled: Bool {
|
||||
bindings.composerText.count == 0
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, Roo
|
||||
|
||||
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
|
||||
private enum Constants {
|
||||
static let backPaginationPageSize: UInt = 30
|
||||
static let backPaginationPageSize: UInt = 50
|
||||
}
|
||||
|
||||
private let timelineController: RoomTimelineControllerProtocol
|
||||
@@ -36,7 +36,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
self.timelineController = timelineController
|
||||
self.timelineViewFactory = timelineViewFactory
|
||||
|
||||
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
|
||||
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomId,
|
||||
roomTitle: roomName ?? "Unknown room 💥",
|
||||
roomAvatar: roomAvatar,
|
||||
roomEncryptionBadge: roomEncryptionBadge,
|
||||
bindings: .init(composerText: "", composerFocused: false)))
|
||||
@@ -106,19 +107,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
fatalError("This message should never be empty")
|
||||
}
|
||||
|
||||
state.messageComposerDisabled = true
|
||||
|
||||
switch state.composerMode {
|
||||
case .reply(let itemId, _):
|
||||
await timelineController.sendReply(state.bindings.composerText, to: itemId)
|
||||
default:
|
||||
await timelineController.sendMessage(state.bindings.composerText)
|
||||
}
|
||||
let message = state.bindings.composerText
|
||||
|
||||
state.bindings.composerText = ""
|
||||
state.composerMode = .default
|
||||
|
||||
state.messageComposerDisabled = false
|
||||
switch state.composerMode {
|
||||
case .reply(let itemId, _):
|
||||
await timelineController.sendReply(message, to: itemId)
|
||||
default:
|
||||
await timelineController.sendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayError(_ type: RoomScreenErrorType) {
|
||||
@@ -144,14 +143,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
return []
|
||||
}
|
||||
|
||||
let actions: [TimelineItemContextMenuAction] = [
|
||||
var actions: [TimelineItemContextMenuAction] = [
|
||||
.copy, .quote, .copyPermalink, .reply
|
||||
]
|
||||
|
||||
#warning("Outgoing actions to be handled with the new Timeline API.")
|
||||
// if timelineItem.isOutgoing {
|
||||
// actions.append(.redact)
|
||||
// }
|
||||
if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isOutgoing {
|
||||
actions.append(.redact)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
@@ -26,46 +26,48 @@ struct MessageComposer: View {
|
||||
let replyCancellationAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom) {
|
||||
let rect = RoundedRectangle(cornerRadius: 8.0)
|
||||
VStack(alignment: .leading, spacing: 2.0) {
|
||||
if case let .reply(_, displayName) = type {
|
||||
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
|
||||
}
|
||||
let rect = RoundedRectangle(cornerRadius: borderRadius)
|
||||
VStack(alignment: .leading, spacing: 4.0) {
|
||||
if case let .reply(_, displayName) = type {
|
||||
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
|
||||
}
|
||||
HStack(alignment: .center) {
|
||||
MessageComposerTextField(placeholder: "Send a message",
|
||||
text: $text,
|
||||
focused: $focused,
|
||||
maxHeight: 300)
|
||||
|
||||
Button {
|
||||
sendAction()
|
||||
} label: {
|
||||
Image(systemName: "paperplane")
|
||||
.font(.element.title3)
|
||||
.foregroundColor(sendingDisabled ? .element.tempActionBackground : .element.tempActionForeground)
|
||||
.padding(8.0)
|
||||
.background(
|
||||
Circle()
|
||||
.foregroundColor(sendingDisabled ? .clear : .element.tempActionBackground)
|
||||
)
|
||||
}
|
||||
.disabled(sendingDisabled)
|
||||
.animation(.elementDefault, value: sendingDisabled)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.padding(4.0)
|
||||
}
|
||||
.padding(4.0)
|
||||
.frame(minHeight: 44.0)
|
||||
.clipShape(rect)
|
||||
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
|
||||
.animation(.elementDefault, value: type)
|
||||
.animation(.elementDefault, value: borderWidth)
|
||||
|
||||
Button {
|
||||
sendAction()
|
||||
} label: {
|
||||
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
|
||||
.background(Circle()
|
||||
.foregroundColor(.global.white)
|
||||
)
|
||||
}
|
||||
.padding(.bottom, 6.0)
|
||||
.disabled(sendingDisabled)
|
||||
.opacity(sendingDisabled ? 0.5 : 1.0)
|
||||
.animation(.elementDefault, value: sendingDisabled)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
}
|
||||
.padding(.leading, 12.0)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(rect)
|
||||
.animation(.elementDefault, value: type)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
.element.accent
|
||||
}
|
||||
|
||||
private var borderWidth: CGFloat {
|
||||
focused ? 2.0 : 1.0
|
||||
private var borderRadius: CGFloat {
|
||||
switch type {
|
||||
case .default:
|
||||
return 28.0
|
||||
case .reply:
|
||||
return 12.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +85,9 @@ private struct MessageComposerReplyHeader: View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.element.caption2)
|
||||
Image(systemName: "x.circle")
|
||||
.font(.element.callout)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.padding(4.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
let textView = UITextView()
|
||||
textView.delegate = context.coordinator
|
||||
|
||||
textView.textColor = .element.primaryContent
|
||||
textView.isEditable = true
|
||||
textView.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
textView.isSelectable = true
|
||||
@@ -92,9 +93,8 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
|
||||
UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight)
|
||||
|
||||
if focused, textView.window != nil, !textView.isFirstResponder {
|
||||
// Avoid cycle detected through attribute warnings
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { // Avoid cycle detected through attribute warnings
|
||||
if focused, textView.window != nil, !textView.isFirstResponder {
|
||||
textView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ struct RoomHeaderView: View {
|
||||
.scaledToFill()
|
||||
.accessibilityIdentifier("roomAvatarImage")
|
||||
} else {
|
||||
PlaceholderAvatarImage(text: context.viewState.roomTitle)
|
||||
PlaceholderAvatarImage(text: context.viewState.roomTitle,
|
||||
contentId: context.viewState.roomId)
|
||||
.accessibilityIdentifier("roomAvatarPlaceholderImage")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ struct RoomScreen: View {
|
||||
context.send(viewAction: .cancelReply)
|
||||
}
|
||||
.padding()
|
||||
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
+73
-38
@@ -24,25 +24,30 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ScaledMetric private var minBubbleWidth = 44
|
||||
@Environment(\.timelineWidth) private var timelineWidth
|
||||
@ScaledMetric private var senderNameVerticalPadding = 3
|
||||
private let bubbleWidthPercentIncoming = 0.72 // 281/390
|
||||
private let bubbleWidthPercentOutgoing = 0.68 // 267/390
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: alignment, spacing: -5) {
|
||||
VStack(alignment: alignment, spacing: -12) {
|
||||
if !timelineItem.isOutgoing {
|
||||
header
|
||||
.zIndex(1)
|
||||
}
|
||||
if timelineItem.isOutgoing {
|
||||
HStack {
|
||||
Spacer()
|
||||
styledContentWithReactions
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.leading, 51)
|
||||
} else {
|
||||
styledContentWithReactions
|
||||
VStack(alignment: alignment) {
|
||||
if timelineItem.isOutgoing {
|
||||
HStack {
|
||||
Spacer()
|
||||
styledContentWithReactions
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 51)
|
||||
} else {
|
||||
styledContentWithReactions
|
||||
.padding(.leading, 24)
|
||||
.padding(.trailing, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,10 +61,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
TimelineSenderAvatarView(timelineItem: timelineItem)
|
||||
Text(timelineItem.senderDisplayName ?? timelineItem.senderId)
|
||||
.font(.body)
|
||||
.font(.element.footnoteBold)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, senderNameVerticalPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,9 +78,10 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
|
||||
if !timelineItem.properties.reactions.isEmpty {
|
||||
TimelineReactionsView(reactions: timelineItem.properties.reactions,
|
||||
alignment: alignment) { key in
|
||||
alignment: .leading) { key in
|
||||
context.send(viewAction: .sendReaction(key: key, eventID: timelineItem.id))
|
||||
}
|
||||
.frame(width: bubbleWidth - 24)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
@@ -83,24 +89,27 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
|
||||
@ViewBuilder
|
||||
var styledContent: some View {
|
||||
if timelineItem.isOutgoing {
|
||||
styledContentOutgoing
|
||||
} else {
|
||||
styledContentIncoming
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var styledContentOutgoing: some View {
|
||||
if timelineItem.inGroupState == .single || timelineItem.inGroupState == .beginning {
|
||||
Spacer()
|
||||
.frame(height: 8)
|
||||
}
|
||||
if shouldAvoidBubbling {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
content()
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
Text(timelineItem.timestamp)
|
||||
.foregroundColor(.global.white)
|
||||
.font(.element.caption2)
|
||||
.padding(4)
|
||||
.background(Color(white: 0, opacity: 0.7))
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
.offset(x: -8, y: -8)
|
||||
}
|
||||
content()
|
||||
.frame(width: bubbleWidth)
|
||||
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
content()
|
||||
.frame(minWidth: minBubbleWidth, alignment: .leading)
|
||||
.frame(width: bubbleWidth - 24, alignment: .leading)
|
||||
|
||||
if timelineItem.properties.isEdited {
|
||||
Text(ElementL10n.editedSuffix)
|
||||
@@ -108,25 +117,50 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
.clipped()
|
||||
.background(bubbleColor)
|
||||
.cornerRadius(12)
|
||||
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
.background(Color.element.systemGray5)
|
||||
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var styledContentIncoming: some View {
|
||||
if shouldAvoidBubbling {
|
||||
content()
|
||||
.frame(width: bubbleWidth)
|
||||
.cornerRadius(12, inGroupState: timelineItem.inGroupState)
|
||||
} else {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
content()
|
||||
.frame(width: bubbleWidth - 24, alignment: .leading)
|
||||
|
||||
if timelineItem.properties.isEdited {
|
||||
Text(ElementL10n.editedSuffix)
|
||||
.font(.element.caption2)
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
.background(Color.element.systemGray6) // Demo time!
|
||||
.cornerRadius(12, inGroupState: timelineItem.inGroupState) // Demo time!
|
||||
// .overlay(
|
||||
// RoundedCorner(radius: 18, inGroupState: timelineItem.inGroupState)
|
||||
// .stroke(Color.element.systemGray5)
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldAvoidBubbling: Bool {
|
||||
timelineItem is ImageRoomTimelineItem
|
||||
}
|
||||
|
||||
private var bubbleColor: Color {
|
||||
let opacity = colorScheme == .light ? 0.06 : 0.15
|
||||
return timelineItem.isOutgoing ? .element.accent.opacity(opacity) : .element.system
|
||||
}
|
||||
|
||||
private var alignment: HorizontalAlignment {
|
||||
timelineItem.isOutgoing ? .trailing : .leading
|
||||
}
|
||||
|
||||
private var bubbleWidth: CGFloat {
|
||||
timelineWidth * (timelineItem.isOutgoing ? bubbleWidthPercentOutgoing : bubbleWidthPercentIncoming)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
@@ -144,6 +178,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
.timelineStyle(.bubbles)
|
||||
.timelineWidth(390)
|
||||
.padding(.horizontal, 8)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
|
||||
@@ -50,3 +50,22 @@ extension View {
|
||||
environment(\.timelineStyle, style)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Width
|
||||
|
||||
private struct TimelineWidthKey: EnvironmentKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var timelineWidth: CGFloat {
|
||||
get { self[TimelineWidthKey.self] }
|
||||
set { self[TimelineWidthKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func timelineWidth(_ width: CGFloat) -> some View {
|
||||
environment(\.timelineWidth, width)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ struct EmoteRoomTimelineView: View {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "face.dashed").padding(.top, 1.0)
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
shouldShowSenderDetails: true,
|
||||
inGroupState: .single,
|
||||
isOutgoing: false,
|
||||
senderId: senderId)
|
||||
}
|
||||
|
||||
@@ -18,19 +18,32 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct FormattedBodyText: View {
|
||||
#warning("this is a dirty fix for demo, should be refactored after new timeline api")
|
||||
let isOutgoing: Bool
|
||||
let attributedComponents: [AttributedStringBuilderComponent]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8.0) {
|
||||
ForEach(attributedComponents, id: \.self) { component in
|
||||
if component.isBlockquote {
|
||||
HStack(spacing: 4.0) {
|
||||
Rectangle()
|
||||
.foregroundColor(Color.red)
|
||||
.frame(width: 4.0)
|
||||
if isOutgoing {
|
||||
Text(component.attributedString)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.padding(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
.clipped()
|
||||
.background(Color.element.systemGray4)
|
||||
.cornerRadius(13)
|
||||
} else {
|
||||
Text(component.attributedString)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.padding(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
|
||||
.clipped()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 13)
|
||||
.stroke(Color.element.systemGray5)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(component.attributedString)
|
||||
@@ -44,7 +57,8 @@ struct FormattedBodyText: View {
|
||||
}
|
||||
|
||||
extension FormattedBodyText {
|
||||
init(text: String) {
|
||||
init(isOutgoing: Bool, text: String) {
|
||||
self.isOutgoing = isOutgoing
|
||||
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)]
|
||||
}
|
||||
}
|
||||
@@ -86,11 +100,11 @@ struct FormattedBodyText_Previews: PreviewProvider {
|
||||
let attributedString = attributedStringBuilder.fromHTML(htmlString)
|
||||
|
||||
if let components = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) {
|
||||
FormattedBodyText(attributedComponents: components)
|
||||
FormattedBodyText(isOutgoing: true, attributedComponents: components)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
FormattedBodyText(text: "Some plain text that's not an attributed component.")
|
||||
FormattedBodyText(isOutgoing: true, text: "Some plain text that's not an attributed component.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,39 +21,32 @@ struct ImageRoomTimelineView: View {
|
||||
let timelineItem: ImageRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
if timelineItem.image != nil || timelineItem.blurhash != nil { // Fixes view heights after loading finishes
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let image = timelineItem.image {
|
||||
if let aspectRatio = timelineItem.aspectRatio {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(aspectRatio, contentMode: .fit)
|
||||
} else {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
} else if let blurhash = timelineItem.blurhash,
|
||||
// Build a small blurhash image so that it's fast
|
||||
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
.animation(.elementDefault, value: timelineItem.image)
|
||||
.frame(maxHeight: 1000.0)
|
||||
} else {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
HStack {
|
||||
Spacer()
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let image = timelineItem.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
} else if let blurhash = timelineItem.blurhash,
|
||||
// Build a small blurhash image so that it's fast
|
||||
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
} else {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.foregroundColor(.element.systemGray6)
|
||||
.opacity(0.3)
|
||||
|
||||
ProgressView("Loading")
|
||||
Spacer()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
.animation(.elementDefault, value: timelineItem.image)
|
||||
.frame(maxHeight: 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +63,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||
text: "Some image",
|
||||
timestamp: "Now",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .single,
|
||||
isOutgoing: false,
|
||||
senderId: "Bob",
|
||||
source: nil,
|
||||
@@ -79,6 +73,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||
text: "Some other image",
|
||||
timestamp: "Now",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .single,
|
||||
isOutgoing: false,
|
||||
senderId: "Bob",
|
||||
source: nil,
|
||||
@@ -88,6 +83,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
|
||||
text: "Blurhashed image",
|
||||
timestamp: "Now",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .single,
|
||||
isOutgoing: false,
|
||||
senderId: "Bob",
|
||||
source: nil,
|
||||
|
||||
@@ -25,9 +25,9 @@ struct NoticeRoomTimelineView: View {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
shouldShowSenderDetails: true,
|
||||
inGroupState: .single,
|
||||
isOutgoing: false,
|
||||
senderId: senderId)
|
||||
}
|
||||
|
||||
@@ -18,22 +18,32 @@ import SwiftUI
|
||||
|
||||
struct PlaceholderAvatarImage: View {
|
||||
private let textForImage: String
|
||||
private let contentId: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.element.accent
|
||||
bgColor
|
||||
Text(textForImage)
|
||||
.padding(4)
|
||||
.foregroundColor(.white)
|
||||
// Make the text resizable (i.e. Make it large and then allow it to scale down)
|
||||
.font(.system(size: 200).weight(.semibold))
|
||||
.minimumScaleFactor(0.001)
|
||||
.foregroundColor(.element.background)
|
||||
.font(.title2.bold())
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
}
|
||||
|
||||
init(text: String) {
|
||||
init(text: String, contentId: String? = nil) {
|
||||
textForImage = text.first?.uppercased() ?? ""
|
||||
self.contentId = contentId
|
||||
}
|
||||
|
||||
private var bgColor: Color {
|
||||
guard let contentId = contentId else {
|
||||
return .element.accent
|
||||
}
|
||||
|
||||
let colors = Color.element.contentAndAvatars
|
||||
let colorIndex = Int(contentId.hashCode % Int32(colors.count))
|
||||
return Color.element.contentAndAvatars[colorIndex % colors.count]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +55,7 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider {
|
||||
|
||||
@ViewBuilder
|
||||
static var body: some View {
|
||||
PlaceholderAvatarImage(text: "X")
|
||||
PlaceholderAvatarImage(text: "X", contentId: "@userid:matrix.org")
|
||||
.clipShape(Circle())
|
||||
.frame(width: 150, height: 100)
|
||||
}
|
||||
|
||||
@@ -21,33 +21,12 @@ struct SeparatorRoomTimelineView: View {
|
||||
let timelineItem: SeparatorRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
LabelledDivider(label: timelineItem.text)
|
||||
Text(timelineItem.text)
|
||||
.font(.element.footnote)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.id(timelineItem.id)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct LabelledDivider: View {
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
init(label: String, color: Color = Color.element.secondaryContent) {
|
||||
self.label = label
|
||||
self.color = color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
line
|
||||
Text(label)
|
||||
.foregroundColor(color)
|
||||
.fixedSize()
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
var line: some View {
|
||||
VStack { Divider().background(color) }
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ struct TextRoomTimelineView: View {
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
if let attributedComponents = timelineItem.attributedComponents {
|
||||
FormattedBodyText(attributedComponents: attributedComponents)
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
|
||||
} else {
|
||||
FormattedBodyText(text: timelineItem.text)
|
||||
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
|
||||
}
|
||||
}
|
||||
.id(timelineItem.id)
|
||||
@@ -75,6 +75,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
|
||||
text: text,
|
||||
timestamp: timestamp,
|
||||
shouldShowSenderDetails: shouldShowSenderDetails,
|
||||
inGroupState: .single,
|
||||
isOutgoing: isOutgoing,
|
||||
senderId: senderId)
|
||||
}
|
||||
|
||||
@@ -28,26 +28,26 @@ struct TimelineItemList: View {
|
||||
|
||||
let bottomVisiblePublisher: PassthroughSubject<Bool, Never>
|
||||
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
|
||||
|
||||
|
||||
@State private var viewFrame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
// The observer behaves differently when not in an reader
|
||||
ScrollViewReader { _ in
|
||||
List {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
||||
.animation(.elementDefault, value: context.viewState.isBackPaginating)
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
||||
.animation(.elementDefault, value: context.viewState.isBackPaginating)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
// No idea why previews don't work otherwise
|
||||
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
|
||||
timelineItem
|
||||
.contextMenu(menuItems: {
|
||||
.contextMenu {
|
||||
context.viewState.contextMenuBuilder?(timelineItem.id)
|
||||
})
|
||||
}
|
||||
.opacity(opacityForItem(timelineItem))
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -65,6 +65,8 @@ struct TimelineItemList: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.background(ViewFrameReader(frame: $viewFrame))
|
||||
.environment(\.timelineWidth, viewFrame.width)
|
||||
.timelineStyle(settings.timelineStyle)
|
||||
.environment(\.defaultMinListRowHeight, 0.0)
|
||||
.introspectTableView { tableView in
|
||||
@@ -81,24 +83,24 @@ struct TimelineItemList: View {
|
||||
// Check if there are enough items. Otherwise ask for more
|
||||
attemptBackPagination()
|
||||
}
|
||||
.onAppear(perform: {
|
||||
.onAppear {
|
||||
if timelineItems != context.viewState.items {
|
||||
timelineItems = context.viewState.items
|
||||
}
|
||||
})
|
||||
.onReceive(scrollToBottomPublisher, perform: {
|
||||
}
|
||||
.onReceive(scrollToBottomPublisher) {
|
||||
tableViewObserver.scrollToBottom(animated: true)
|
||||
})
|
||||
.onReceive(tableViewObserver.scrollViewTopVisiblePublisher, perform: { isTopVisible in
|
||||
}
|
||||
.onReceive(tableViewObserver.scrollViewTopVisiblePublisher) { isTopVisible in
|
||||
if !isTopVisible || context.viewState.isBackPaginating {
|
||||
return
|
||||
}
|
||||
|
||||
attemptBackPagination()
|
||||
})
|
||||
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher, perform: { isBottomVisible in
|
||||
}
|
||||
.onReceive(tableViewObserver.scrollViewBottomVisiblePublisher) { isBottomVisible in
|
||||
bottomVisiblePublisher.send(isBottomVisible)
|
||||
})
|
||||
}
|
||||
.onChange(of: context.viewState.items) { _ in
|
||||
// Don't update the list while moving
|
||||
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
|
||||
@@ -109,7 +111,7 @@ struct TimelineItemList: View {
|
||||
tableViewObserver.saveCurrentOffset()
|
||||
timelineItems = context.viewState.items
|
||||
}
|
||||
.onReceive(tableViewObserver.scrollViewDidRestPublisher, perform: {
|
||||
.onReceive(tableViewObserver.scrollViewDidRestPublisher) {
|
||||
if hasPendingChanges == false {
|
||||
return
|
||||
}
|
||||
@@ -117,13 +119,13 @@ struct TimelineItemList: View {
|
||||
tableViewObserver.saveCurrentOffset()
|
||||
timelineItems = context.viewState.items
|
||||
hasPendingChanges = false
|
||||
})
|
||||
.onChange(of: timelineItems, perform: { _ in
|
||||
}
|
||||
.onChange(of: timelineItems) { _ in
|
||||
tableViewObserver.restoreSavedOffset()
|
||||
|
||||
// Check if there are enough items. Otherwise ask for more
|
||||
attemptBackPagination()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import SwiftUI
|
||||
struct TimelineSenderAvatarView: View {
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
|
||||
@ScaledMetric private var avatarSize = 26
|
||||
@ScaledMetric private var avatarSize = 32
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .center) {
|
||||
@@ -30,14 +30,15 @@ struct TimelineSenderAvatarView: View {
|
||||
.scaledToFill()
|
||||
.overlay(Circle().stroke(Color.element.accent))
|
||||
} else {
|
||||
PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId)
|
||||
PlaceholderAvatarImage(text: timelineItem.senderDisplayName ?? timelineItem.senderId,
|
||||
contentId: timelineItem.senderId)
|
||||
}
|
||||
}
|
||||
.clipShape(Circle())
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.element.background, lineWidth: 2)
|
||||
.stroke(Color.element.background, lineWidth: 3)
|
||||
)
|
||||
|
||||
.animation(.elementDefault, value: timelineItem.senderAvatar)
|
||||
|
||||
@@ -27,12 +27,34 @@ enum SessionVerificationViewModelAction {
|
||||
struct SessionVerificationViewState: BindableState {
|
||||
var verificationState: SessionVerificationStateMachine.State = .initial
|
||||
|
||||
var shouldDisableDismissButton: Bool {
|
||||
verificationState != .verified
|
||||
var title: String? {
|
||||
switch verificationState {
|
||||
case .showingChallenge:
|
||||
return ElementL10n.sessionVerificationScreenEmojisTitle
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var shouldDisableCancelButton: Bool {
|
||||
verificationState == .verified
|
||||
var message: String {
|
||||
switch verificationState {
|
||||
case .initial:
|
||||
return ElementL10n.verificationOpenOtherToVerify
|
||||
case .requestingVerification:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .acceptingChallenge:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .decliningChallenge:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .cancelling:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .showingChallenge:
|
||||
return ElementL10n.sessionVerificationScreenEmojisMessage
|
||||
case .verified:
|
||||
return ElementL10n.verificationConclusionOkSelfNotice
|
||||
case .cancelled:
|
||||
return ElementL10n.verificationCancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +63,5 @@ enum SessionVerificationViewAction {
|
||||
case restart
|
||||
case accept
|
||||
case decline
|
||||
case dismiss
|
||||
case cancel
|
||||
case close
|
||||
}
|
||||
|
||||
@@ -66,22 +66,10 @@ class SessionVerificationStateMachine {
|
||||
stateMachine.state
|
||||
}
|
||||
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
init() {
|
||||
stateMachine = StateMachine(state: .initial) { machine in
|
||||
machine.addRoutes(event: .requestVerification, transitions: [.initial => .requestingVerification])
|
||||
machine.addRoutes(event: .didFail, transitions: [.requestingVerification => .initial])
|
||||
|
||||
machine.addRoutes(event: .cancel, transitions: [.requestingVerification => .cancelling])
|
||||
machine.addRoutes(event: .didCancel, transitions: [.requestingVerification => .cancelled])
|
||||
|
||||
// Cancellation request from the other party should either take us from `.cancelling`
|
||||
// to `.cancelled` or keep us in `.cancelled` if already there. There is more `.didCancel`
|
||||
// handling in `addRouteMapping` for states containing associated values
|
||||
machine.addRoutes(event: .didCancel, transitions: [.cancelling => .cancelled])
|
||||
machine.addRoutes(event: .didCancel, transitions: [.cancelled => .cancelled])
|
||||
machine.addRoutes(event: .didFail, transitions: [.cancelled => .cancelled])
|
||||
|
||||
machine.addRoutes(event: .restart, transitions: [.cancelled => .initial])
|
||||
|
||||
// Transitions with associated values need to be handled through `addRouteMapping`
|
||||
@@ -103,18 +91,9 @@ class SessionVerificationStateMachine {
|
||||
case (.didFail, .decliningChallenge(let emojis)):
|
||||
return .showingChallenge(emojis: emojis)
|
||||
|
||||
case (.cancel, .showingChallenge):
|
||||
case (.cancel, _):
|
||||
return .cancelling
|
||||
case (.cancel, .acceptingChallenge):
|
||||
return .cancelling
|
||||
case (.cancel, .decliningChallenge):
|
||||
return .cancelling
|
||||
|
||||
case (.didCancel, .showingChallenge):
|
||||
return .cancelled
|
||||
case (.didCancel, .acceptingChallenge):
|
||||
return .cancelled
|
||||
case (.didCancel, .decliningChallenge):
|
||||
case (.didCancel, _):
|
||||
return .cancelled
|
||||
|
||||
default:
|
||||
@@ -123,8 +102,6 @@ class SessionVerificationStateMachine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity
|
||||
|
||||
/// Attempt to move the state machine to another state through an event
|
||||
/// It will either invoke the `transitionHandler` or the `errorHandler` depending on its current state
|
||||
|
||||
@@ -74,9 +74,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer
|
||||
stateMachine.processEvent(.requestVerification)
|
||||
case .restart:
|
||||
stateMachine.processEvent(.restart)
|
||||
case .dismiss:
|
||||
callback?(.finished)
|
||||
case .cancel:
|
||||
case .close:
|
||||
guard stateMachine.state == .initial ||
|
||||
stateMachine.state == .verified ||
|
||||
stateMachine.state == .cancelled else {
|
||||
@@ -115,7 +113,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
MXLog.error("Failed transition with context: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+116
-109
@@ -14,7 +14,6 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import MatrixRustSDK
|
||||
import SwiftUI
|
||||
|
||||
struct SessionVerificationScreen: View {
|
||||
@@ -24,127 +23,132 @@ struct SessionVerificationScreen: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 32.0) {
|
||||
Text(heading)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
switch context.viewState.verificationState {
|
||||
case .initial:
|
||||
StateIcon(systemName: "lock.shield")
|
||||
Button(ElementL10n.startVerification) {
|
||||
context.send(viewAction: .start)
|
||||
}
|
||||
.buttonStyle(.elementAction(.regular))
|
||||
.accessibilityIdentifier("startButton")
|
||||
|
||||
case .cancelled:
|
||||
StateIcon(systemName: "xmark.shield")
|
||||
.accessibilityIdentifier("sessionVerificationFailedIcon")
|
||||
|
||||
Button(ElementL10n.globalRetry) {
|
||||
context.send(viewAction: .restart)
|
||||
}
|
||||
.buttonStyle(.elementAction(.regular))
|
||||
.accessibilityIdentifier("restartButton")
|
||||
|
||||
case .requestingVerification:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("requestingVerificationProgressView")
|
||||
case .cancelling:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("cancellingVerificationProgressView")
|
||||
case .acceptingChallenge:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("acceptingChallengeProgressView")
|
||||
case .decliningChallenge:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("decliningChallengeProgressView")
|
||||
|
||||
case .showingChallenge(let emojis):
|
||||
HStack(spacing: 8.0) {
|
||||
ForEach(emojis.prefix(4), id: \.self) { emoji in
|
||||
EmojiView(emoji: emoji)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8.0) {
|
||||
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
|
||||
EmojiView(emoji: emoji)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: 32.0) {
|
||||
if let title = context.viewState.title {
|
||||
Text(title)
|
||||
.font(.element.headlineBold)
|
||||
.foregroundColor(.element.systemPrimaryLabel)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
actionButtons
|
||||
case .verified:
|
||||
StateIcon(systemName: "checkmark.shield")
|
||||
.accessibilityIdentifier("sessionVerificationSucceededIcon")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.padding(.top, 64)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(ElementL10n.verificationVerifyDevice)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(ElementL10n.done) {
|
||||
context.send(viewAction: .dismiss)
|
||||
}
|
||||
.disabled(context.viewState.shouldDisableDismissButton)
|
||||
.accessibilityIdentifier("dismissButton")
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(ElementL10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
.disabled(context.viewState.shouldDisableCancelButton)
|
||||
.accessibilityIdentifier("cancelButton")
|
||||
Text(context.viewState.message)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.systemPrimaryLabel)
|
||||
.accessibilityIdentifier("titleLabel")
|
||||
|
||||
mainContent
|
||||
}
|
||||
.padding()
|
||||
.padding(.top, 64)
|
||||
.frame(maxWidth: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(ElementL10n.verificationProfileVerify)
|
||||
.toolbar { toolbarContent }
|
||||
}
|
||||
.background(Color.element.systemSecondaryBackground)
|
||||
.safeAreaInset(edge: .bottom) { actionButtons.padding() }
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var heading: String {
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
switch context.viewState.verificationState {
|
||||
case .initial:
|
||||
return ElementL10n.verificationOpenOtherToVerify
|
||||
case .requestingVerification:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .acceptingChallenge:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .decliningChallenge:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .cancelling:
|
||||
return ElementL10n.verificationRequestWaiting
|
||||
case .showingChallenge:
|
||||
return ElementL10n.verificationEmojiNotice
|
||||
case .verified:
|
||||
return ElementL10n.verificationConclusionOkSelfNotice
|
||||
StateIcon(systemName: "lock.shield")
|
||||
|
||||
case .cancelled:
|
||||
return ElementL10n.verificationCancelled
|
||||
StateIcon(systemName: "xmark.shield")
|
||||
.accessibilityIdentifier("sessionVerificationFailedIcon")
|
||||
|
||||
case .requestingVerification:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("requestingVerificationProgressView")
|
||||
case .cancelling:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("cancellingVerificationProgressView")
|
||||
case .acceptingChallenge:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("acceptingChallengeProgressView")
|
||||
case .decliningChallenge:
|
||||
ProgressView()
|
||||
.accessibilityIdentifier("decliningChallengeProgressView")
|
||||
|
||||
case .showingChallenge(let emojis):
|
||||
HStack(spacing: 16) {
|
||||
ForEach(emojis.prefix(4), id: \.self) { emoji in
|
||||
EmojiView(emoji: emoji)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
ForEach(emojis.suffix(from: 4), id: \.self) { emoji in
|
||||
EmojiView(emoji: emoji)
|
||||
}
|
||||
}
|
||||
case .verified:
|
||||
StateIcon(systemName: "checkmark.shield")
|
||||
.accessibilityIdentifier("sessionVerificationSucceededIcon")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 16.0) {
|
||||
Button(ElementL10n.verificationSasDoNotMatch) {
|
||||
context.send(viewAction: .decline)
|
||||
switch context.viewState.verificationState {
|
||||
case .initial:
|
||||
Button(ElementL10n.startVerification) {
|
||||
context.send(viewAction: .start)
|
||||
}
|
||||
.buttonStyle(.elementAction(.regular, color: .red))
|
||||
.accessibilityLabel("challengeDeclineButton")
|
||||
|
||||
Button(ElementL10n.verificationSasMatch) {
|
||||
context.send(viewAction: .accept)
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.accessibilityIdentifier("startButton")
|
||||
|
||||
case .cancelled:
|
||||
Button(ElementL10n.globalRetry) {
|
||||
context.send(viewAction: .restart)
|
||||
}
|
||||
.buttonStyle(.elementAction(.regular))
|
||||
.accessibilityLabel("challengeAcceptButton")
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.accessibilityIdentifier("restartButton")
|
||||
|
||||
case .showingChallenge:
|
||||
VStack(spacing: 30) {
|
||||
Button { context.send(viewAction: .accept) } label: {
|
||||
Label(ElementL10n.actionMatch, systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.accessibilityLabel("challengeAcceptButton")
|
||||
|
||||
Button(ElementL10n.no) {
|
||||
context.send(viewAction: .decline)
|
||||
}
|
||||
.font(.element.bodyBold)
|
||||
.accessibilityLabel("challengeDeclineButton")
|
||||
}
|
||||
|
||||
case .verified:
|
||||
Button(ElementL10n.finish) {
|
||||
context.send(viewAction: .close)
|
||||
}
|
||||
.buttonStyle(.elementAction(.xLarge))
|
||||
.accessibilityIdentifier("finishButton")
|
||||
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button { context.send(viewAction: .close) } label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.font(.element.bodyBold)
|
||||
.foregroundColor(.element.systemSecondaryLabel)
|
||||
.accessibilityIdentifier("closeButton")
|
||||
}
|
||||
.padding(32.0)
|
||||
}
|
||||
|
||||
struct EmojiView: View {
|
||||
@@ -153,9 +157,10 @@ struct SessionVerificationScreen: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16.0) {
|
||||
Text(emoji.symbol)
|
||||
.font(.largeTitle)
|
||||
.font(.element.largeTitleBold)
|
||||
Text(emoji.description)
|
||||
.font(.body)
|
||||
.font(.element.caption2)
|
||||
.foregroundColor(.element.systemSecondaryLabel)
|
||||
}
|
||||
.padding(8.0)
|
||||
}
|
||||
@@ -167,6 +172,7 @@ struct SessionVerificationScreen: View {
|
||||
var body: some View {
|
||||
Image(systemName: systemName)
|
||||
.resizable()
|
||||
.font(.element.body.weight(.light))
|
||||
.scaledToFit()
|
||||
.foregroundColor(.element.accent)
|
||||
.frame(width: 100, height: 100)
|
||||
@@ -181,23 +187,24 @@ struct SessionVerification_Previews: PreviewProvider {
|
||||
body.preferredColorScheme(.light)
|
||||
body.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
static var body: some View {
|
||||
Group {
|
||||
sessionVerificationScreen(state: .initial)
|
||||
sessionVerificationScreen(state: .requestingVerification)
|
||||
sessionVerificationScreen(state: .cancelled)
|
||||
|
||||
|
||||
sessionVerificationScreen(state: .showingChallenge(emojis: MockSessionVerificationControllerProxy.emojis))
|
||||
sessionVerificationScreen(state: .verified)
|
||||
}
|
||||
.tint(Color.element.accent)
|
||||
}
|
||||
|
||||
|
||||
static func sessionVerificationScreen(state: SessionVerificationStateMachine.State) -> some View {
|
||||
let viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy(),
|
||||
initialState: SessionVerificationViewState(verificationState: state))
|
||||
|
||||
|
||||
return SessionVerificationScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ struct SettingsCoordinatorParameters {
|
||||
}
|
||||
|
||||
enum SettingsCoordinatorAction {
|
||||
case dismiss
|
||||
case logout
|
||||
}
|
||||
|
||||
@@ -38,6 +39,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var statusIndicator: UserIndicator?
|
||||
|
||||
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@@ -50,7 +53,7 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
init(parameters: SettingsCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let viewModel = SettingsViewModel()
|
||||
let viewModel = SettingsViewModel(withUserSession: parameters.userSession)
|
||||
let view = SettingsScreen(context: viewModel.context)
|
||||
settingsViewModel = viewModel
|
||||
settingsHostingController = UIHostingController(rootView: view)
|
||||
@@ -61,6 +64,8 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
guard let self = self else { return }
|
||||
MXLog.debug("SettingsViewModel did complete with result: \(result).")
|
||||
switch result {
|
||||
case .close:
|
||||
self.callback?(.dismiss)
|
||||
case .toggleAnalytics:
|
||||
self.toggleAnalytics()
|
||||
case .reportBug:
|
||||
@@ -68,7 +73,7 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
case .crash:
|
||||
self.parameters.bugReportService.crash()
|
||||
case .logout:
|
||||
self.callback?(.logout)
|
||||
self.confirmSignOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,12 +111,25 @@ final class SettingsCoordinator: Coordinator, Presentable {
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
parameters.navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
navigationRouter.push(coordinator, animated: true) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.remove(childCoordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmSignOut() {
|
||||
let alert = UIAlertController(title: ElementL10n.actionSignOut,
|
||||
message: ElementL10n.actionSignOutConfirmationSimple,
|
||||
preferredStyle: .alert)
|
||||
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { [weak self] _ in
|
||||
self?.callback?(.logout)
|
||||
})
|
||||
|
||||
navigationRouter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// Show an activity indicator whilst loading.
|
||||
/// - Parameters:
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum SettingsViewModelAction {
|
||||
case close
|
||||
case toggleAnalytics
|
||||
case reportBug
|
||||
case crash
|
||||
@@ -31,6 +33,9 @@ enum SettingsViewModelAction {
|
||||
|
||||
struct SettingsViewState: BindableState {
|
||||
var bindings: SettingsViewStateBindings
|
||||
var userID: String
|
||||
var userAvatar: UIImage?
|
||||
var userDisplayName: String?
|
||||
}
|
||||
|
||||
struct SettingsViewStateBindings {
|
||||
@@ -38,6 +43,7 @@ struct SettingsViewStateBindings {
|
||||
}
|
||||
|
||||
enum SettingsViewAction {
|
||||
case close
|
||||
case toggleAnalytics
|
||||
case reportBug
|
||||
case crash
|
||||
|
||||
@@ -25,21 +25,38 @@ class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let userSession: UserSessionProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((SettingsViewModelAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init() {
|
||||
init(withUserSession userSession: UserSessionProtocol) {
|
||||
self.userSession = userSession
|
||||
let bindings = SettingsViewStateBindings()
|
||||
super.init(initialViewState: .init(bindings: bindings))
|
||||
super.init(initialViewState: .init(bindings: bindings, userID: userSession.userID))
|
||||
|
||||
Task {
|
||||
if case let .success(userAvatarURLString) = await userSession.clientProxy.loadUserAvatarURLString() {
|
||||
if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURLString(userAvatarURLString, size: MediaProviderDefaultAvatarSize) {
|
||||
state.userAvatar = avatar
|
||||
}
|
||||
}
|
||||
|
||||
if case let .success(userDisplayName) = await self.userSession.clientProxy.loadUserDisplayName() {
|
||||
state.userDisplayName = userDisplayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: SettingsViewAction) async {
|
||||
switch viewAction {
|
||||
case .close:
|
||||
callback?(.close)
|
||||
case .toggleAnalytics:
|
||||
callback?(.toggleAnalytics)
|
||||
case .reportBug:
|
||||
|
||||
@@ -19,9 +19,12 @@ import SwiftUI
|
||||
struct SettingsScreen: View {
|
||||
// MARK: Private
|
||||
|
||||
@State private var showingLogoutConfirmation = false
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject private var settings = ElementSettings.shared
|
||||
|
||||
@ScaledMetric private var avatarSize = 60.0
|
||||
@ScaledMetric private var menuIconSize = 30.0
|
||||
private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@@ -31,6 +34,12 @@ struct SettingsScreen: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
userSection
|
||||
.listRowBackground(rowBackgroundColor)
|
||||
|
||||
appearanceSection
|
||||
.listRowBackground(rowBackgroundColor)
|
||||
|
||||
analyticsSection
|
||||
.listRowBackground(rowBackgroundColor)
|
||||
|
||||
@@ -44,7 +53,13 @@ struct SettingsScreen: View {
|
||||
tableView.backgroundColor = .clear
|
||||
}
|
||||
.navigationTitle(ElementL10n.settings)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(backgroundColor, ignoresSafeAreaEdges: .all)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
closeButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var versionText: some View {
|
||||
@@ -52,25 +67,85 @@ struct SettingsScreen: View {
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .light ? .element.system : .element.background
|
||||
.element.systemGray6
|
||||
}
|
||||
|
||||
private var rowBackgroundColor: Color {
|
||||
colorScheme == .light ? .element.background : .element.system
|
||||
}
|
||||
|
||||
private var userSection: some View {
|
||||
Section {
|
||||
HStack(spacing: 13) {
|
||||
userAvatar
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(context.viewState.userDisplayName ?? "")
|
||||
.font(.title3)
|
||||
Text(context.viewState.userID)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.listRowInsets(listRowInsets)
|
||||
}
|
||||
}
|
||||
|
||||
private var appearanceSection: some View {
|
||||
Section {
|
||||
Button(action: appearance) {
|
||||
HStack {
|
||||
Image(systemName: "paintpalette")
|
||||
.foregroundColor(.element.systemGray)
|
||||
.padding(4)
|
||||
.background(Color.element.systemGray6)
|
||||
.clipShape(Circle())
|
||||
.frame(width: menuIconSize, height: menuIconSize)
|
||||
Text(ElementL10n.settingsAppearance)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
}
|
||||
.listRowInsets(listRowInsets)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.accessibilityIdentifier("appearanceButton")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var userAvatar: some View {
|
||||
if let avatar = context.viewState.userAvatar {
|
||||
Image(uiImage: avatar)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
PlaceholderAvatarImage(text: context.viewState.userDisplayName ?? context.viewState.userID, contentId: context.viewState.userID)
|
||||
.clipShape(Circle())
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
}
|
||||
}
|
||||
|
||||
private var analyticsSection: some View {
|
||||
Section(ElementL10n.settingsAnalytics) {
|
||||
Section {
|
||||
Button { context.send(viewAction: .reportBug) } label: {
|
||||
Text(ElementL10n.sendBugReport)
|
||||
HStack {
|
||||
Text(ElementL10n.sendBugReport)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
}
|
||||
.listRowInsets(listRowInsets)
|
||||
.listRowSeparator(.hidden)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.accessibilityIdentifier("reportBugButton")
|
||||
|
||||
if BuildSettings.settingsCrashButtonVisible {
|
||||
Button("Crash the app",
|
||||
Button("Crash app",
|
||||
role: .destructive) { context.send(viewAction: .crash)
|
||||
}
|
||||
.listRowInsets(listRowInsets)
|
||||
.accessibilityIdentifier("crashButton")
|
||||
}
|
||||
}
|
||||
@@ -79,13 +154,14 @@ struct SettingsScreen: View {
|
||||
@ViewBuilder
|
||||
private var userInterfaceSection: some View {
|
||||
if BuildSettings.settingsShowTimelineStyle {
|
||||
Section(ElementL10n.settingsUserInterface) {
|
||||
Section {
|
||||
Picker(ElementL10n.settingsTimelineStyle, selection: $settings.timelineStyle) {
|
||||
ForEach(TimelineStyle.allCases, id: \.self) { style in
|
||||
Text(style.description)
|
||||
.tag(style)
|
||||
}
|
||||
}
|
||||
.listRowInsets(listRowInsets)
|
||||
.accessibilityIdentifier("timelineStylePicker")
|
||||
}
|
||||
}
|
||||
@@ -93,23 +169,52 @@ struct SettingsScreen: View {
|
||||
|
||||
private var logoutSection: some View {
|
||||
Section {
|
||||
Button { showingLogoutConfirmation = true } label: {
|
||||
Text(ElementL10n.actionSignOut)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.accessibilityIdentifier("logoutButton")
|
||||
.confirmationDialog(ElementL10n.actionSignOutConfirmationSimple,
|
||||
isPresented: $showingLogoutConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button(ElementL10n.actionSignOut,
|
||||
role: .destructive) { context.send(viewAction: .logout)
|
||||
Button(action: logout) {
|
||||
HStack {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.foregroundColor(.element.systemGray)
|
||||
.padding(4)
|
||||
.background(Color.element.systemGray6)
|
||||
.clipShape(Circle())
|
||||
.frame(width: menuIconSize, height: menuIconSize)
|
||||
Text(ElementL10n.actionSignOut)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
}
|
||||
}
|
||||
.listRowInsets(listRowInsets)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
.accessibilityIdentifier("logoutButton")
|
||||
} footer: {
|
||||
versionText
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: close) {
|
||||
HStack {
|
||||
Image(systemName: "xmark")
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("closeButton")
|
||||
}
|
||||
|
||||
private func appearance() {
|
||||
#warning("Not implemented")
|
||||
}
|
||||
|
||||
private func close() {
|
||||
context.send(viewAction: .close)
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
context.send(viewAction: .logout)
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineStyle: CustomStringConvertible {
|
||||
@@ -133,8 +238,13 @@ struct Settings_Previews: PreviewProvider {
|
||||
|
||||
@ViewBuilder
|
||||
static var body: some View {
|
||||
let viewModel = SettingsViewModel()
|
||||
SettingsScreen(context: viewModel.context)
|
||||
.previewInterfaceOrientation(.portrait)
|
||||
let userSession = MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@userid:example.com"),
|
||||
mediaProvider: MockMediaProvider())
|
||||
let viewModel = SettingsViewModel(withUserSession: userSession)
|
||||
|
||||
return NavigationView {
|
||||
SettingsScreen(context: viewModel.context)
|
||||
.previewInterfaceOrientation(.portrait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +108,10 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
|
||||
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
|
||||
|
||||
let loginTask: Task<Client, Error> = Task.detached {
|
||||
#warning("Use new api on next SDK release.")
|
||||
return try self.authenticationService.login(username: username,
|
||||
password: password)
|
||||
// try self.authenticationService.login(username: username,
|
||||
// password: password,
|
||||
// initialDeviceName: initialDeviceName,
|
||||
// deviceId: deviceId)
|
||||
try self.authenticationService.login(username: username,
|
||||
password: password,
|
||||
initialDeviceName: initialDeviceName,
|
||||
deviceId: deviceId)
|
||||
}
|
||||
|
||||
switch await loginTask.result {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import Foundation
|
||||
import GZIP
|
||||
import MatrixRustSDK
|
||||
import Sentry
|
||||
import UIKit
|
||||
|
||||
|
||||
@@ -19,64 +19,91 @@ import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
private class WeakClientProxyWrapper: ClientDelegate {
|
||||
private class WeakClientProxyWrapper: ClientDelegate, SlidingSyncObserver {
|
||||
private weak var clientProxy: ClientProxy?
|
||||
|
||||
init(clientProxy: ClientProxy) {
|
||||
self.clientProxy = clientProxy
|
||||
}
|
||||
|
||||
func didReceiveSyncUpdate() {
|
||||
clientProxy?.didReceiveSyncUpdate()
|
||||
}
|
||||
// MARK: - ClientDelegate
|
||||
|
||||
func didReceiveSyncUpdate() { }
|
||||
|
||||
func didReceiveAuthError(isSoftLogout: Bool) {
|
||||
clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout)
|
||||
Task {
|
||||
await clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout)
|
||||
}
|
||||
}
|
||||
|
||||
func didUpdateRestoreToken() {
|
||||
clientProxy?.didUpdateRestoreToken()
|
||||
Task {
|
||||
await clientProxy?.didUpdateRestoreToken()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SlidingSyncDelegate
|
||||
|
||||
func didReceiveSyncUpdate(summary: UpdateSummary) {
|
||||
Task {
|
||||
await self.clientProxy?.didReceiveSlidingSyncUpdate(summary: summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ClientProxy: ClientProxyProtocol {
|
||||
/// The maximum number of timeline events required during a sync request.
|
||||
static let syncLimit: UInt16 = 20
|
||||
static let syncLimit: UInt16 = 50
|
||||
|
||||
private let client: Client
|
||||
private let client: ClientProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
private var sessionVerificationControllerProxy: SessionVerificationControllerProxy?
|
||||
|
||||
private(set) var rooms: [RoomProxy] = [] {
|
||||
didSet {
|
||||
callbacks.send(.updatedRoomsList)
|
||||
}
|
||||
}
|
||||
private var slidingSyncObserverToken: StoppableSpawn?
|
||||
private let slidingSync: SlidingSync
|
||||
|
||||
private var roomProxies = [String: RoomProxyProtocol]()
|
||||
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
|
||||
deinit {
|
||||
// These need to be inlined instead of using stopSync()
|
||||
// as we can't call async methods safely from deinit
|
||||
client.setDelegate(delegate: nil)
|
||||
slidingSyncObserverToken?.cancel()
|
||||
slidingSync.setObserver(observer: nil)
|
||||
}
|
||||
|
||||
let callbacks = PassthroughSubject<ClientProxyCallback, Never>()
|
||||
|
||||
init(client: Client,
|
||||
init(client: ClientProtocol,
|
||||
backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
self.client = client
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
|
||||
do {
|
||||
let slidingSyncBuilder = try client.slidingSync().homeserver(url: BuildSettings.slidingSyncProxyBaseURL.absoluteString)
|
||||
|
||||
let slidingSyncView = try SlidingSyncViewBuilder()
|
||||
.timelineLimit(limit: 10)
|
||||
.requiredState(requiredState: [RequiredState(key: "m.room.avatar", value: "")])
|
||||
.name(name: "HomeScreenView")
|
||||
.syncMode(mode: .fullSync)
|
||||
.build()
|
||||
|
||||
slidingSync = try slidingSyncBuilder
|
||||
.addView(view: slidingSyncView)
|
||||
// .withCommonExtensions()
|
||||
.build()
|
||||
|
||||
roomSummaryProvider = RoomSummaryProvider(slidingSyncController: slidingSync,
|
||||
slidingSyncView: slidingSyncView,
|
||||
roomMessageFactory: RoomMessageFactory())
|
||||
} catch {
|
||||
fatalError("Failed configuring sliding sync")
|
||||
}
|
||||
|
||||
client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self))
|
||||
|
||||
#warning("Use isSoftLogout() api on next SDK release.")
|
||||
Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.")
|
||||
client.startSync(timelineLimit: ClientProxy.syncLimit)
|
||||
|
||||
Task { await updateRooms() }
|
||||
// if !client.isSoftLogout() {
|
||||
// Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.")
|
||||
// client.startSync(timelineLimit: ClientProxy.syncLimit)
|
||||
//
|
||||
// Task { await updateRooms() }
|
||||
// }
|
||||
}
|
||||
|
||||
var userIdentifier: String {
|
||||
@@ -89,9 +116,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
|
||||
var isSoftLogout: Bool {
|
||||
#warning("Use isSoftLogout() api on next SDK release.")
|
||||
return false
|
||||
// client.isSoftLogout()
|
||||
client.isSoftLogout()
|
||||
}
|
||||
|
||||
var deviceId: String? {
|
||||
@@ -116,6 +141,46 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func startSync() {
|
||||
guard !client.isSoftLogout() else {
|
||||
return
|
||||
}
|
||||
|
||||
slidingSync.setObserver(observer: WeakClientProxyWrapper(clientProxy: self))
|
||||
slidingSyncObserverToken = slidingSync.sync()
|
||||
}
|
||||
|
||||
func stopSync() {
|
||||
client.setDelegate(delegate: nil)
|
||||
|
||||
slidingSyncObserverToken?.cancel()
|
||||
slidingSync.setObserver(observer: nil)
|
||||
}
|
||||
|
||||
func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? {
|
||||
if let roomProxy = roomProxies[identifier] {
|
||||
return roomProxy
|
||||
}
|
||||
|
||||
do {
|
||||
guard let slidingSyncRoom = try slidingSync.getRoom(roomId: identifier),
|
||||
let room = slidingSyncRoom.fullRoom() else {
|
||||
MXLog.error("Failed retrieving room with identifier: \(identifier)")
|
||||
return nil
|
||||
}
|
||||
|
||||
let roomProxy = RoomProxy(slidingSyncRoom: slidingSyncRoom,
|
||||
room: room,
|
||||
backgroundTaskService: backgroundTaskService)
|
||||
roomProxies[identifier] = roomProxy
|
||||
|
||||
return roomProxy
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving room with identifier: \(identifier)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadUserDisplayName() async -> Result<String, ClientProxyError> {
|
||||
await Task.detached {
|
||||
do {
|
||||
@@ -152,9 +217,18 @@ class ClientProxy: ClientProxyProtocol {
|
||||
MatrixRustSDK.mediaSourceFromUrl(url: urlString)
|
||||
}
|
||||
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data {
|
||||
let bytes = try client.getMediaContent(source: source)
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data {
|
||||
try await Task.detached {
|
||||
let bytes = try self.client.getMediaContent(source: source)
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
}.value
|
||||
}
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data {
|
||||
try await Task.detached {
|
||||
let bytes = try self.client.getMediaThumbnail(source: source, width: UInt64(width), height: UInt64(height))
|
||||
return Data(bytes: bytes, count: bytes.count)
|
||||
}.value
|
||||
}
|
||||
|
||||
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError> {
|
||||
@@ -171,8 +245,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
|
||||
func logout() async {
|
||||
do {
|
||||
#warning("Use logout() api on next SDK release.")
|
||||
// try client.logout()
|
||||
try client.logout()
|
||||
} catch {
|
||||
MXLog.error("Failed logging out with error: \(error)")
|
||||
}
|
||||
@@ -180,16 +253,6 @@ class ClientProxy: ClientProxyProtocol {
|
||||
|
||||
// MARK: Private
|
||||
|
||||
fileprivate func didReceiveSyncUpdate() {
|
||||
Benchmark.logElapsedDurationForIdentifier("ClientSync", message: "Received sync update")
|
||||
|
||||
callbacks.send(.receivedSyncUpdate)
|
||||
|
||||
Task.detached {
|
||||
await self.updateRooms()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func didReceiveAuthError(isSoftLogout: Bool) {
|
||||
callbacks.send(.receivedAuthError(isSoftLogout: isSoftLogout))
|
||||
}
|
||||
@@ -198,32 +261,9 @@ class ClientProxy: ClientProxyProtocol {
|
||||
callbacks.send(.updatedRestoreToken)
|
||||
}
|
||||
|
||||
private func updateRooms() async {
|
||||
var currentRooms = rooms
|
||||
Benchmark.startTrackingForIdentifier("ClientRooms", message: "Fetching available rooms")
|
||||
let sdkRooms = client.rooms()
|
||||
Benchmark.endTrackingForIdentifier("ClientRooms", message: "Retrieved \(sdkRooms.count) rooms")
|
||||
fileprivate func didReceiveSlidingSyncUpdate(summary: UpdateSummary) {
|
||||
roomSummaryProvider.updateRoomsWithIdentifiers(summary.rooms)
|
||||
|
||||
Benchmark.startTrackingForIdentifier("ProcessingRooms", message: "Started processing \(sdkRooms.count) rooms")
|
||||
let diff = sdkRooms.map { $0.id() }.difference(from: currentRooms.map(\.id))
|
||||
|
||||
for change in diff {
|
||||
switch change {
|
||||
case .insert(_, let id, _):
|
||||
guard let sdkRoom = sdkRooms.first(where: { $0.id() == id }) else {
|
||||
MXLog.error("Failed retrieving sdk room with id: \(id)")
|
||||
break
|
||||
}
|
||||
currentRooms.append(RoomProxy(room: sdkRoom,
|
||||
roomMessageFactory: RoomMessageFactory(),
|
||||
backgroundTaskService: backgroundTaskService))
|
||||
case .remove(_, let id, _):
|
||||
currentRooms.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
Benchmark.endTrackingForIdentifier("ProcessingRooms", message: "Finished processing \(sdkRooms.count) rooms")
|
||||
|
||||
rooms = currentRooms
|
||||
// callbacks.send(.receivedSyncUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
enum ClientProxyCallback {
|
||||
case updatedRoomsList
|
||||
case receivedSyncUpdate
|
||||
case receivedAuthError(isSoftLogout: Bool)
|
||||
case updatedRestoreToken
|
||||
@@ -34,6 +33,7 @@ enum ClientProxyError: Error {
|
||||
case failedLoadingMedia
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol ClientProxyProtocol {
|
||||
var callbacks: PassthroughSubject<ClientProxyCallback, Never> { get }
|
||||
|
||||
@@ -47,7 +47,13 @@ protocol ClientProxyProtocol {
|
||||
|
||||
var restoreToken: String? { get }
|
||||
|
||||
var rooms: [RoomProxy] { get }
|
||||
var roomSummaryProvider: RoomSummaryProviderProtocol { get }
|
||||
|
||||
func startSync()
|
||||
|
||||
func stopSync()
|
||||
|
||||
func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol?
|
||||
|
||||
func loadUserDisplayName() async -> Result<String, ClientProxyError>
|
||||
|
||||
@@ -59,7 +65,9 @@ protocol ClientProxyProtocol {
|
||||
|
||||
func mediaSourceForURLString(_ urlString: String) -> MatrixRustSDK.MediaSource
|
||||
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data
|
||||
|
||||
func sessionVerificationControllerProxy() async -> Result<SessionVerificationControllerProxyProtocol, ClientProxyError>
|
||||
|
||||
|
||||
@@ -26,10 +26,18 @@ struct MockClientProxy: ClientProxyProtocol {
|
||||
let homeserver = ""
|
||||
let restoreToken: String? = nil
|
||||
|
||||
let rooms = [RoomProxy]()
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol = MockRoomSummaryProvider()
|
||||
|
||||
func startSync() { }
|
||||
|
||||
func stopSync() { }
|
||||
|
||||
func roomForIdentifier(_ identifier: String) -> RoomProxyProtocol? {
|
||||
nil
|
||||
}
|
||||
|
||||
func loadUserDisplayName() async -> Result<String, ClientProxyError> {
|
||||
.failure(.failedRetrievingDisplayName)
|
||||
.success("User display name")
|
||||
}
|
||||
|
||||
func loadUserAvatarURLString() async -> Result<String, ClientProxyError> {
|
||||
@@ -48,7 +56,11 @@ struct MockClientProxy: ClientProxyProtocol {
|
||||
MatrixRustSDK.mediaSourceFromUrl(url: urlString)
|
||||
}
|
||||
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data {
|
||||
func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) async throws -> Data {
|
||||
throw ClientProxyError.failedLoadingMedia
|
||||
}
|
||||
|
||||
func loadMediaThumbnailForSource(_ source: MatrixRustSDK.MediaSource, width: UInt, height: UInt) async throws -> Data {
|
||||
throw ClientProxyError.failedLoadingMedia
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let imageCache: Kingfisher.ImageCache
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
private let processingQueue: DispatchQueue
|
||||
|
||||
init(clientProxy: ClientProxyProtocol,
|
||||
imageCache: Kingfisher.ImageCache,
|
||||
@@ -29,31 +28,30 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
self.clientProxy = clientProxy
|
||||
self.imageCache = imageCache
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
processingQueue = DispatchQueue(label: "MediaProviderProcessingQueue", attributes: .concurrent)
|
||||
}
|
||||
|
||||
func imageFromSource(_ source: MediaSource?) -> UIImage? {
|
||||
func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? {
|
||||
guard let source = source else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return imageCache.retrieveImageInMemoryCache(forKey: source.underlyingSource.url(), options: nil)
|
||||
let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), size: size)
|
||||
return imageCache.retrieveImageInMemoryCache(forKey: cacheKey, options: nil)
|
||||
}
|
||||
|
||||
func imageFromURLString(_ urlString: String?) -> UIImage? {
|
||||
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? {
|
||||
guard let urlString = urlString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)))
|
||||
return imageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size)
|
||||
}
|
||||
|
||||
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
|
||||
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)))
|
||||
func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
await loadImageFromSource(MediaSource(source: clientProxy.mediaSourceForURLString(urlString)), size: size)
|
||||
}
|
||||
|
||||
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
|
||||
if let image = imageFromSource(source) {
|
||||
func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
if let image = imageFromSource(source, size: size) {
|
||||
return .success(image)
|
||||
}
|
||||
|
||||
@@ -62,21 +60,30 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
loadImageBgTask?.stop()
|
||||
}
|
||||
|
||||
let cacheKey = cacheKeyForURLString(source.underlyingSource.url(), size: size)
|
||||
|
||||
return await Task.detached { () -> Result<UIImage, MediaProviderError> in
|
||||
if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: source.underlyingSource.url()),
|
||||
if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: cacheKey),
|
||||
let image = cacheResult.image {
|
||||
return .success(image)
|
||||
}
|
||||
|
||||
do {
|
||||
let imageData = try clientProxy.loadMediaContentForSource(source.underlyingSource)
|
||||
let imageData = try await Task.detached { () -> Data in
|
||||
if let size = size {
|
||||
return try await clientProxy.loadMediaThumbnailForSource(source.underlyingSource, width: UInt(size.width), height: UInt(size.height))
|
||||
} else {
|
||||
return try await clientProxy.loadMediaContentForSource(source.underlyingSource)
|
||||
}
|
||||
|
||||
}.value
|
||||
|
||||
guard let image = UIImage(data: imageData) else {
|
||||
MXLog.error("Invalid image data")
|
||||
return .failure(.invalidImageData)
|
||||
}
|
||||
|
||||
imageCache.store(image, forKey: source.underlyingSource.url())
|
||||
imageCache.store(image, forKey: cacheKey)
|
||||
|
||||
return .success(image)
|
||||
} catch {
|
||||
@@ -86,6 +93,16 @@ struct MediaProvider: MediaProviderProtocol {
|
||||
}
|
||||
.value
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func cacheKeyForURLString(_ urlString: String, size: CGSize?) -> String {
|
||||
if let size = size {
|
||||
return "\(urlString){\(size.width),\(size.height)}"
|
||||
} else {
|
||||
return urlString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ImageCache {
|
||||
|
||||
@@ -22,13 +22,33 @@ enum MediaProviderError: Error {
|
||||
case invalidImageData
|
||||
}
|
||||
|
||||
let MediaProviderDefaultAvatarSize = CGSize(width: 44.0, height: 44.0)
|
||||
|
||||
@MainActor
|
||||
protocol MediaProviderProtocol {
|
||||
func imageFromSource(_ source: MediaSource?) -> UIImage?
|
||||
func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage?
|
||||
|
||||
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError>
|
||||
@discardableResult func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError>
|
||||
|
||||
func imageFromURLString(_ urlString: String?) -> UIImage?
|
||||
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage?
|
||||
|
||||
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError>
|
||||
@discardableResult func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError>
|
||||
}
|
||||
|
||||
extension MediaProviderProtocol {
|
||||
func imageFromSource(_ source: MediaSource?) -> UIImage? {
|
||||
imageFromSource(source, size: nil)
|
||||
}
|
||||
|
||||
@discardableResult func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
|
||||
await loadImageFromSource(source, size: nil)
|
||||
}
|
||||
|
||||
func imageFromURLString(_ urlString: String?) -> UIImage? {
|
||||
imageFromURLString(urlString, size: nil)
|
||||
}
|
||||
|
||||
@discardableResult func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
|
||||
await loadImageFromURLString(urlString, size: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,23 @@ import Foundation
|
||||
import UIKit
|
||||
|
||||
struct MockMediaProvider: MediaProviderProtocol {
|
||||
func imageFromSource(_ source: MediaSource?) -> UIImage? {
|
||||
func imageFromSource(_ source: MediaSource?, size: CGSize?) -> UIImage? {
|
||||
nil
|
||||
}
|
||||
|
||||
func loadImageFromSource(_ source: MediaSource) async -> Result<UIImage, MediaProviderError> {
|
||||
func loadImageFromSource(_ source: MediaSource, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
.failure(.failedRetrievingImage)
|
||||
}
|
||||
|
||||
func imageFromURLString(_ urlString: String?) -> UIImage? {
|
||||
nil
|
||||
func imageFromURLString(_ urlString: String?, size: CGSize?) -> UIImage? {
|
||||
if urlString != nil {
|
||||
return UIImage(systemName: "photo")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadImageFromURLString(_ urlString: String) async -> Result<UIImage, MediaProviderError> {
|
||||
func loadImageFromURLString(_ urlString: String, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
|
||||
.failure(.failedRetrievingImage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
class MemberDetailProviderManager {
|
||||
private var memberDetailProviders: [String: MemberDetailProviderProtocol] = [:]
|
||||
|
||||
func memberDetailProviderForRoomProxy(_ roomProxy: RoomProxyProtocol) -> MemberDetailProviderProtocol {
|
||||
if let memberDetailProvider = memberDetailProviders[roomProxy.id] {
|
||||
return memberDetailProvider
|
||||
}
|
||||
|
||||
let memberDetailProvider = MemberDetailProvider(roomProxy: roomProxy)
|
||||
memberDetailProviders[roomProxy.id] = memberDetailProvider
|
||||
return memberDetailProvider
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class MemberDetailProvider: MemberDetailProviderProtocol {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private var memberAvatars = [String: String]()
|
||||
private var memberDisplayNames = [String: String]()
|
||||
|
||||
init(roomProxy: RoomProxyProtocol) {
|
||||
self.roomProxy = roomProxy
|
||||
}
|
||||
|
||||
func avatarURLStringForUserId(_ userId: String) -> String? {
|
||||
memberAvatars[userId]
|
||||
}
|
||||
|
||||
func loadAvatarURLStringForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError> {
|
||||
if let avatarURL = avatarURLStringForUserId(userId) {
|
||||
return .success(avatarURL)
|
||||
}
|
||||
|
||||
switch await roomProxy.loadAvatarURLForUserId(userId) {
|
||||
case .success(let avatarURL):
|
||||
memberAvatars[userId] = avatarURL
|
||||
return .success(avatarURL)
|
||||
case .failure:
|
||||
return .failure(.failedRetrievingUserAvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func displayNameForUserId(_ userId: String) -> String? {
|
||||
memberDisplayNames[userId]
|
||||
}
|
||||
|
||||
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError> {
|
||||
if let displayName = displayNameForUserId(userId) {
|
||||
return .success(displayName)
|
||||
}
|
||||
|
||||
switch await roomProxy.loadDisplayNameForUserId(userId) {
|
||||
case .success(let displayName):
|
||||
memberDisplayNames[userId] = displayName
|
||||
return .success(displayName)
|
||||
case .failure:
|
||||
return .failure(.failedRetrievingUserDisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MemberDetailProviderError: Error {
|
||||
case invalidRoomProxy
|
||||
case failedRetrievingUserAvatarURL
|
||||
case failedRetrievingUserDisplayName
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol MemberDetailProviderProtocol {
|
||||
func avatarURLStringForUserId(_ userId: String) -> String?
|
||||
func loadAvatarURLStringForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError>
|
||||
|
||||
func displayNameForUserId(_ userId: String) -> String?
|
||||
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, MemberDetailProviderError>
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
struct EmoteRoomMessage: RoomMessageProtocol {
|
||||
private let message: MatrixRustSDK.EmoteMessage
|
||||
|
||||
init(message: MatrixRustSDK.EmoteMessage) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var id: String {
|
||||
message.baseMessage().id()
|
||||
}
|
||||
|
||||
var body: String {
|
||||
message.baseMessage().body()
|
||||
}
|
||||
|
||||
var htmlBody: String? {
|
||||
message.htmlBody()
|
||||
}
|
||||
|
||||
var sender: String {
|
||||
message.baseMessage().sender()
|
||||
}
|
||||
|
||||
var originServerTs: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
struct ImageRoomMessage: RoomMessageProtocol {
|
||||
private let message: MatrixRustSDK.ImageMessage
|
||||
|
||||
init(message: MatrixRustSDK.ImageMessage) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var id: String {
|
||||
message.baseMessage().id()
|
||||
}
|
||||
|
||||
var body: String {
|
||||
message.baseMessage().body()
|
||||
}
|
||||
|
||||
var sender: String {
|
||||
message.baseMessage().sender()
|
||||
}
|
||||
|
||||
var originServerTs: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
|
||||
}
|
||||
|
||||
var source: MediaSource? {
|
||||
MediaSource(source: message.source())
|
||||
}
|
||||
|
||||
var width: CGFloat? {
|
||||
guard let width = message.width() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CGFloat(width)
|
||||
}
|
||||
|
||||
var height: CGFloat? {
|
||||
guard let height = message.height() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CGFloat(height)
|
||||
}
|
||||
|
||||
var blurhash: String? {
|
||||
message.blurhash()
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
struct TextRoomMessage: RoomMessageProtocol {
|
||||
private let message: MatrixRustSDK.TextMessage
|
||||
|
||||
init(message: MatrixRustSDK.TextMessage) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var id: String {
|
||||
message.baseMessage().id()
|
||||
}
|
||||
|
||||
var body: String {
|
||||
message.baseMessage().body()
|
||||
}
|
||||
|
||||
var htmlBody: String? {
|
||||
message.htmlBody()
|
||||
}
|
||||
|
||||
var sender: String {
|
||||
message.baseMessage().sender()
|
||||
}
|
||||
|
||||
var originServerTs: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
import MatrixRustSDK
|
||||
|
||||
struct MockRoomProxy: RoomProxyProtocol {
|
||||
let id = UUID().uuidString
|
||||
@@ -24,7 +23,6 @@ struct MockRoomProxy: RoomProxyProtocol {
|
||||
let displayName: String?
|
||||
|
||||
let topic: String? = nil
|
||||
let messages: [RoomMessageProtocol] = []
|
||||
|
||||
let avatarURL: String? = nil
|
||||
|
||||
@@ -34,24 +32,34 @@ struct MockRoomProxy: RoomProxyProtocol {
|
||||
let isEncrypted = Bool.random()
|
||||
let isTombstoned = Bool.random()
|
||||
|
||||
var callbacks = PassthroughSubject<RoomProxyCallback, Never>()
|
||||
let timelineProvider: RoomTimelineProviderProtocol = MockRoomTimelineProvider()
|
||||
|
||||
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
|
||||
.failure(.failedRetrievingMemberDisplayName)
|
||||
}
|
||||
|
||||
func avatarURLStringForUserId(_ userId: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
|
||||
.failure(.failedRetrievingMemberAvatarURL)
|
||||
}
|
||||
|
||||
func displayNameForUserId(_ userId: String) -> String? {
|
||||
nil
|
||||
}
|
||||
|
||||
func loadDisplayName() async -> Result<String, RoomProxyError> {
|
||||
.failure(.failedRetrievingDisplayName)
|
||||
}
|
||||
|
||||
func startLiveEventListener() { }
|
||||
|
||||
func addTimelineListener(listener: TimelineListener) { }
|
||||
|
||||
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
|
||||
.failure(.backwardStreamNotAvailable)
|
||||
.failure(.failedPaginatingBackwards)
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
|
||||
|
||||
@@ -17,18 +17,15 @@
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
struct SomeRoomMessage: RoomMessageProtocol {
|
||||
let id: String
|
||||
let body: String
|
||||
let sender: String
|
||||
let originServerTs: Date
|
||||
}
|
||||
|
||||
struct RoomMessageFactory: RoomMessageFactoryProtocol {
|
||||
func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol {
|
||||
if let textMessage = message.textMessage() {
|
||||
return TextRoomMessage(message: textMessage)
|
||||
} else if let imageMessage = message.imageMessage() {
|
||||
return ImageRoomMessage(message: imageMessage)
|
||||
} else if let noticeMessage = message.noticeMessage() {
|
||||
return NoticeRoomMessage(message: noticeMessage)
|
||||
} else if let emoteMessage = message.emoteMessage() {
|
||||
return EmoteRoomMessage(message: emoteMessage)
|
||||
} else {
|
||||
fatalError("One of these must exist")
|
||||
}
|
||||
func buildRoomMessageFrom(_ eventItem: EventTimelineItem) -> RoomMessageProtocol {
|
||||
SomeRoomMessage(id: eventItem.id, body: eventItem.body ?? "", sender: eventItem.sender, originServerTs: eventItem.originServerTs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,5 @@ import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
protocol RoomMessageFactoryProtocol {
|
||||
func buildRoomMessageFrom(_ message: AnyMessage) -> RoomMessageProtocol
|
||||
func buildRoomMessageFrom(_ message: EventTimelineItem) -> RoomMessageProtocol
|
||||
}
|
||||
|
||||
@@ -20,55 +20,43 @@ import UIKit
|
||||
|
||||
import MatrixRustSDK
|
||||
|
||||
private class WeakRoomProxyWrapper: RoomDelegate {
|
||||
private weak var roomProxy: RoomProxy?
|
||||
|
||||
init(roomProxy: RoomProxy) {
|
||||
self.roomProxy = roomProxy
|
||||
}
|
||||
|
||||
// MARK: - RoomDelegate
|
||||
|
||||
func didReceiveMessage(message: AnyMessage) {
|
||||
roomProxy?.appendMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
class RoomProxy: RoomProxyProtocol {
|
||||
private let room: Room
|
||||
private let roomMessageFactory: RoomMessageFactoryProtocol
|
||||
private let slidingSyncRoom: SlidingSyncRoomProtocol
|
||||
private let room: RoomProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
|
||||
private var backwardStream: BackwardsStreamProtocol?
|
||||
private let concurrentDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy", attributes: .concurrent)
|
||||
|
||||
private var sendMessageBgTask: BackgroundTaskProtocol?
|
||||
|
||||
private var memberAvatars = [String: String]()
|
||||
private var memberDisplayNames = [String: String]()
|
||||
|
||||
private(set) var displayName: String?
|
||||
|
||||
let callbacks = PassthroughSubject<RoomProxyCallback, Never>()
|
||||
|
||||
private(set) var messages: [RoomMessageProtocol]
|
||||
|
||||
init(room: Room,
|
||||
roomMessageFactory: RoomMessageFactoryProtocol,
|
||||
backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
self.room = room
|
||||
self.roomMessageFactory = roomMessageFactory
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
messages = []
|
||||
|
||||
room.setDelegate(delegate: WeakRoomProxyWrapper(roomProxy: self))
|
||||
|
||||
backwardStream = room.startLiveEventListener()
|
||||
}
|
||||
private var backPaginationOutcome: PaginationOutcome?
|
||||
private(set) lazy var timelineProvider: RoomTimelineProviderProtocol = {
|
||||
let provider = RoomTimelineProvider(roomProxy: self)
|
||||
addTimelineListener(listener: WeakRoomTimelineProviderWrapper(timelineProvider: provider))
|
||||
return provider
|
||||
}()
|
||||
|
||||
deinit {
|
||||
room.setDelegate(delegate: nil)
|
||||
#warning("Should any timeline listeners be removed??")
|
||||
}
|
||||
|
||||
init(slidingSyncRoom: SlidingSyncRoomProtocol,
|
||||
room: RoomProtocol,
|
||||
backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||
self.slidingSyncRoom = slidingSyncRoom
|
||||
self.room = room
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
}
|
||||
|
||||
lazy var id: String = room.id()
|
||||
|
||||
var name: String? {
|
||||
room.name()
|
||||
slidingSyncRoom.name()
|
||||
}
|
||||
|
||||
var topic: String? {
|
||||
@@ -112,10 +100,15 @@ class RoomProxy: RoomProxyProtocol {
|
||||
return Asset.Images.encryptionTrusted.image
|
||||
}
|
||||
|
||||
func avatarURLStringForUserId(_ userId: String) -> String? {
|
||||
memberAvatars[userId]
|
||||
}
|
||||
|
||||
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
|
||||
await Task.detached { () -> Result<String?, RoomProxyError> in
|
||||
do {
|
||||
let avatarURL = try self.room.memberAvatarUrl(userId: userId)
|
||||
await self.update(avatarURL: avatarURL, forUserId: userId)
|
||||
return .success(avatarURL)
|
||||
} catch {
|
||||
return .failure(.failedRetrievingMemberAvatarURL)
|
||||
@@ -124,10 +117,15 @@ class RoomProxy: RoomProxyProtocol {
|
||||
.value
|
||||
}
|
||||
|
||||
func displayNameForUserId(_ userId: String) -> String? {
|
||||
memberDisplayNames[userId]
|
||||
}
|
||||
|
||||
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
|
||||
await Task.detached { () -> Result<String?, RoomProxyError> in
|
||||
do {
|
||||
let displayName = try self.room.memberDisplayName(userId: userId)
|
||||
await self.update(displayName: displayName, forUserId: userId)
|
||||
return .success(displayName)
|
||||
} catch {
|
||||
return .failure(.failedRetrievingMemberDisplayName)
|
||||
@@ -138,14 +136,13 @@ class RoomProxy: RoomProxyProtocol {
|
||||
|
||||
func loadDisplayName() async -> Result<String, RoomProxyError> {
|
||||
await Task.detached { () -> Result<String, RoomProxyError> in
|
||||
if let displayName = self.displayName {
|
||||
if let displayName = await self.displayName {
|
||||
return .success(displayName)
|
||||
}
|
||||
|
||||
do {
|
||||
let displayName = try self.room.displayName()
|
||||
self.displayName = displayName
|
||||
|
||||
await self.update(displayName: displayName)
|
||||
return .success(displayName)
|
||||
} catch {
|
||||
return .failure(.failedRetrievingDisplayName)
|
||||
@@ -154,23 +151,27 @@ class RoomProxy: RoomProxyProtocol {
|
||||
.value
|
||||
}
|
||||
|
||||
private func addTimelineListener(listener: TimelineListener) {
|
||||
room.addTimelineListener(listener: listener)
|
||||
}
|
||||
|
||||
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> {
|
||||
await Task.detached { () -> Result<Void, RoomProxyError> in
|
||||
guard let backwardStream = self.backwardStream else {
|
||||
return .failure(RoomProxyError.backwardStreamNotAvailable)
|
||||
guard backPaginationOutcome?.moreMessages != false else {
|
||||
return .failure(.noMoreMessagesToBackPaginate)
|
||||
}
|
||||
|
||||
MXLog.debug("BackPagination")
|
||||
return await Task.detached {
|
||||
do {
|
||||
let id = await self.id
|
||||
|
||||
Benchmark.startTrackingForIdentifier("BackPagination \(id)", message: "Backpaginating \(count) message(s) in room \(id)")
|
||||
await self.update(backPaginationOutcome: try self.room.paginateBackwards(limit: UInt16(count)))
|
||||
Benchmark.endTrackingForIdentifier("BackPagination \(id)", message: "Finished backpaginating \(count) message(s) in room \(id)")
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(.failedPaginatingBackwards)
|
||||
}
|
||||
|
||||
Benchmark.startTrackingForIdentifier("BackPagination \(self.id)", message: "Backpaginating \(count) message(s) in room \(self.id)")
|
||||
let sdkMessages = backwardStream.paginateBackwards(count: UInt64(count))
|
||||
Benchmark.endTrackingForIdentifier("BackPagination \(self.id)", message: "Finished backpaginating \(count) message(s) in room \(self.id)")
|
||||
|
||||
let messages = sdkMessages.map { message in
|
||||
self.roomMessageFactory.buildRoomMessageFrom(message)
|
||||
}.reversed()
|
||||
|
||||
self.messages.insert(contentsOf: messages, at: 0)
|
||||
|
||||
return .success(())
|
||||
}
|
||||
.value
|
||||
}
|
||||
@@ -180,48 +181,52 @@ class RoomProxy: RoomProxyProtocol {
|
||||
defer {
|
||||
sendMessageBgTask?.stop()
|
||||
}
|
||||
|
||||
|
||||
let transactionId = genTransactionId()
|
||||
|
||||
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in
|
||||
return await Task.dispatched(on: concurrentDispatchQueue, operation: {
|
||||
do {
|
||||
// Disabled until available in Rust
|
||||
// if let inReplyToEventId = inReplyToEventId {
|
||||
// #warning("Markdown support when available in Ruma")
|
||||
// try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId)
|
||||
// } else {
|
||||
let messageContent = messageEventContentFromMarkdown(md: message)
|
||||
try self.room.send(msg: messageContent, txnId: transactionId)
|
||||
// }
|
||||
if let inReplyToEventId = inReplyToEventId {
|
||||
try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId)
|
||||
} else {
|
||||
let messageContent = messageEventContentFromMarkdown(md: message)
|
||||
try self.room.send(msg: messageContent, txnId: transactionId)
|
||||
}
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(.failedSendingMessage)
|
||||
}
|
||||
})
|
||||
.value
|
||||
}
|
||||
|
||||
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
|
||||
let transactionID = genTransactionId()
|
||||
|
||||
return await Task {
|
||||
do {
|
||||
try room.redact(eventId: eventID, reason: nil, txnId: transactionID)
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(.failedRedactingEvent)
|
||||
}
|
||||
}
|
||||
.value
|
||||
}
|
||||
|
||||
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
|
||||
#warning("Redactions to be enabled on next SDK release.")
|
||||
return .failure(.failedRedactingEvent)
|
||||
// let transactionID = genTransactionId()
|
||||
//
|
||||
// return await Task {
|
||||
// do {
|
||||
// try room.redact(eventId: eventID, reason: nil, txnId: transactionID)
|
||||
// return .success(())
|
||||
// } catch {
|
||||
// return .failure(.failedRedactingEvent)
|
||||
// }
|
||||
// }
|
||||
// .value
|
||||
func update(avatarURL: String?, forUserId userId: String) {
|
||||
memberAvatars[userId] = avatarURL
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
func update(displayName: String?, forUserId userId: String) {
|
||||
memberDisplayNames[userId] = displayName
|
||||
}
|
||||
|
||||
fileprivate func appendMessage(_ message: AnyMessage) {
|
||||
let message = roomMessageFactory.buildRoomMessageFrom(message)
|
||||
messages.append(message)
|
||||
callbacks.send(.updatedMessages)
|
||||
func update(displayName: String) {
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
func update(backPaginationOutcome: PaginationOutcome) {
|
||||
self.backPaginationOutcome = backPaginationOutcome
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,22 +15,20 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
import MatrixRustSDK
|
||||
|
||||
enum RoomProxyError: Error {
|
||||
case failedRetrievingDisplayName
|
||||
case failedRetrievingAvatar
|
||||
case backwardStreamNotAvailable
|
||||
case noMoreMessagesToBackPaginate
|
||||
case failedPaginatingBackwards
|
||||
case failedRetrievingMemberAvatarURL
|
||||
case failedRetrievingMemberDisplayName
|
||||
case failedSendingMessage
|
||||
case failedRedactingEvent
|
||||
}
|
||||
|
||||
enum RoomProxyCallback {
|
||||
case updatedMessages
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol RoomProxyProtocol {
|
||||
var id: String { get }
|
||||
var isDirect: Bool { get }
|
||||
@@ -43,12 +41,17 @@ protocol RoomProxyProtocol {
|
||||
var displayName: String? { get }
|
||||
|
||||
var topic: String? { get }
|
||||
var messages: [RoomMessageProtocol] { get }
|
||||
|
||||
var avatarURL: String? { get }
|
||||
|
||||
var timelineProvider: RoomTimelineProviderProtocol { get }
|
||||
|
||||
func avatarURLStringForUserId(_ userId: String) -> String?
|
||||
|
||||
func loadAvatarURLForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
|
||||
|
||||
func displayNameForUserId(_ userId: String) -> String?
|
||||
|
||||
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
|
||||
|
||||
func loadDisplayName() async -> Result<String, RoomProxyError>
|
||||
@@ -58,8 +61,6 @@ protocol RoomProxyProtocol {
|
||||
func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result<Void, RoomProxyError>
|
||||
|
||||
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
|
||||
|
||||
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
|
||||
}
|
||||
|
||||
extension RoomProxyProtocol {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct EventBrief {
|
||||
let eventId: String
|
||||
let senderId: String
|
||||
let senderDisplayName: String?
|
||||
let body: String
|
||||
let htmlBody: String?
|
||||
let date: Date
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct EventBriefFactory: EventBriefFactoryProtocol {
|
||||
private let memberDetailProvider: MemberDetailProviderProtocol
|
||||
|
||||
init(memberDetailProvider: MemberDetailProviderProtocol) {
|
||||
self.memberDetailProvider = memberDetailProvider
|
||||
}
|
||||
|
||||
func buildEventBriefFor(message: RoomMessageProtocol?) async -> EventBrief? {
|
||||
guard let message = message else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch message {
|
||||
case is ImageRoomMessage:
|
||||
return nil
|
||||
case let message as TextRoomMessage:
|
||||
return await buildEventBrief(message: message, htmlBody: message.htmlBody)
|
||||
case let message as NoticeRoomMessage:
|
||||
return await buildEventBrief(message: message, htmlBody: message.htmlBody)
|
||||
case let message as EmoteRoomMessage:
|
||||
return await buildEventBrief(message: message, htmlBody: message.htmlBody)
|
||||
default:
|
||||
fatalError("Unknown room message.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func buildEventBrief(message: RoomMessageProtocol, htmlBody: String?) async -> EventBrief? {
|
||||
switch await memberDetailProvider.loadDisplayNameForUserId(message.sender) {
|
||||
case .success(let displayName):
|
||||
return EventBrief(eventId: message.id,
|
||||
senderId: message.sender,
|
||||
senderDisplayName: displayName,
|
||||
body: message.body,
|
||||
htmlBody: htmlBody,
|
||||
date: message.originServerTs)
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed fetching sender display name with error: \(error)")
|
||||
|
||||
return EventBrief(eventId: message.id,
|
||||
senderId: message.sender,
|
||||
senderDisplayName: nil,
|
||||
body: message.body,
|
||||
htmlBody: htmlBody,
|
||||
date: message.originServerTs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
struct MockRoomSummary: RoomSummaryProtocol {
|
||||
var id: String = UUID().uuidString
|
||||
|
||||
var name: String?
|
||||
|
||||
var displayName: String?
|
||||
|
||||
var topic: String?
|
||||
|
||||
var isDirect = false
|
||||
|
||||
var isEncrypted = false
|
||||
|
||||
var isSpace = false
|
||||
|
||||
var isTombstoned = false
|
||||
|
||||
var lastMessage: EventBrief?
|
||||
|
||||
var avatar: UIImage?
|
||||
|
||||
func loadDetails() async { }
|
||||
|
||||
var callbacks = PassthroughSubject<RoomSummaryCallback, Never>()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
class MockRoomSummaryProvider: RoomSummaryProviderProtocol {
|
||||
var callbacks = PassthroughSubject<RoomSummaryProviderCallback, Never>()
|
||||
var stateUpdatePublisher = CurrentValueSubject<RoomSummaryProviderState, Never>(.cold)
|
||||
var countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
|
||||
|
||||
var roomSummaries: [RoomSummary] = [
|
||||
RoomSummary(id: "1", name: "First room",
|
||||
isDirect: true,
|
||||
avatarURLString: nil,
|
||||
lastMessage: AttributedString("Prosciutto beef ribs pancetta filet mignon kevin hamburger, chuck ham venison picanha. Beef ribs chislic turkey biltong tenderloin."),
|
||||
lastMessageTimestamp: .now,
|
||||
unreadNotificationCount: 4),
|
||||
RoomSummary(id: "2",
|
||||
name: "Second room",
|
||||
isDirect: true,
|
||||
avatarURLString: "mockImageURLString",
|
||||
lastMessage: nil,
|
||||
lastMessageTimestamp: nil,
|
||||
unreadNotificationCount: 1),
|
||||
RoomSummary(id: "3",
|
||||
name: "Third room",
|
||||
isDirect: true,
|
||||
avatarURLString: nil,
|
||||
lastMessage: try? AttributedString(markdown: "**@mock:client.com**: T-bone beef ribs bacon"),
|
||||
lastMessageTimestamp: .now,
|
||||
unreadNotificationCount: 0)
|
||||
]
|
||||
|
||||
func updateRoomsWithIdentifiers(_ identifiers: [String]) { }
|
||||
}
|
||||
@@ -14,147 +14,14 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class RoomSummary: RoomSummaryProtocol {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let eventBriefFactory: EventBriefFactoryProtocol
|
||||
|
||||
private var hasLoadedData = false
|
||||
private var roomUpdateListeners = Set<AnyCancellable>()
|
||||
|
||||
var id: String {
|
||||
roomProxy.id
|
||||
}
|
||||
|
||||
var name: String? {
|
||||
roomProxy.name
|
||||
}
|
||||
|
||||
var topic: String? {
|
||||
roomProxy.topic
|
||||
}
|
||||
|
||||
var isDirect: Bool {
|
||||
roomProxy.isDirect
|
||||
}
|
||||
|
||||
var isEncrypted: Bool {
|
||||
roomProxy.isEncrypted
|
||||
}
|
||||
|
||||
var isSpace: Bool {
|
||||
roomProxy.isSpace
|
||||
}
|
||||
|
||||
var isTombstoned: Bool {
|
||||
roomProxy.isTombstoned
|
||||
}
|
||||
|
||||
private(set) var avatar: UIImage? {
|
||||
didSet {
|
||||
callbacks.send(.updatedAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var displayName: String? {
|
||||
didSet {
|
||||
callbacks.send(.updatedDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var lastMessage: EventBrief? {
|
||||
didSet {
|
||||
callbacks.send(.updatedLastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
let callbacks = PassthroughSubject<RoomSummaryCallback, Never>()
|
||||
|
||||
init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol, eventBriefFactory: EventBriefFactoryProtocol) {
|
||||
self.roomProxy = roomProxy
|
||||
self.mediaProvider = mediaProvider
|
||||
self.eventBriefFactory = eventBriefFactory
|
||||
|
||||
Task {
|
||||
lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last)
|
||||
}
|
||||
|
||||
roomProxy.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch callback {
|
||||
case .updatedMessages:
|
||||
Task {
|
||||
self.lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &roomUpdateListeners)
|
||||
}
|
||||
|
||||
func loadDetails() async {
|
||||
if hasLoadedData {
|
||||
return
|
||||
}
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await self.loadDisplayName()
|
||||
}
|
||||
group.addTask {
|
||||
await self.loadAvatar()
|
||||
}
|
||||
group.addTask {
|
||||
await self.loadLastMessage()
|
||||
}
|
||||
}
|
||||
|
||||
hasLoadedData = true
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadDisplayName() async {
|
||||
switch await roomProxy.loadDisplayName() {
|
||||
case .success(let displayName):
|
||||
self.displayName = displayName
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed fetching room display name with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAvatar() async {
|
||||
guard let avatarURLString = roomProxy.avatarURL else {
|
||||
return
|
||||
}
|
||||
|
||||
switch await mediaProvider.loadImageFromURLString(avatarURLString) {
|
||||
case .success(let avatar):
|
||||
self.avatar = avatar
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed fetching room avatar with error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLastMessage() async {
|
||||
guard roomProxy.messages.last == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-fill the room with some messages and use the last message in the response.
|
||||
switch await roomProxy.paginateBackwards(count: UInt(ClientProxy.syncLimit)) {
|
||||
case .success:
|
||||
lastMessage = await eventBriefFactory.buildEventBriefFor(message: roomProxy.messages.last)
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed back paginating with error: \(error)")
|
||||
}
|
||||
}
|
||||
struct RoomSummary {
|
||||
let id: String
|
||||
let name: String
|
||||
let isDirect: Bool
|
||||
let avatarURLString: String?
|
||||
let lastMessage: AttributedString?
|
||||
let lastMessageTimestamp: Date?
|
||||
let unreadNotificationCount: UInt
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
enum RoomSummaryCallback {
|
||||
case updatedAvatar
|
||||
case updatedDisplayName
|
||||
case updatedLastMessage
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol RoomSummaryProtocol {
|
||||
var id: String { get }
|
||||
var name: String? { get }
|
||||
var topic: String? { get }
|
||||
var isDirect: Bool { get }
|
||||
var isEncrypted: Bool { get }
|
||||
var isSpace: Bool { get }
|
||||
var isTombstoned: Bool { get }
|
||||
|
||||
var displayName: String? { get }
|
||||
var lastMessage: EventBrief? { get }
|
||||
var avatar: UIImage? { get }
|
||||
|
||||
var callbacks: PassthroughSubject<RoomSummaryCallback, Never> { get }
|
||||
|
||||
func loadDetails() async
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
private class WeakRoomSummaryProviderWrapper: SlidingSyncViewRoomListObserver, SlidingSyncViewStateObserver, SlidingSyncViewRoomsCountObserver {
|
||||
private weak var roomSummaryProvider: RoomSummaryProvider?
|
||||
|
||||
/// Publishes room list diffs as they come in through sliding sync
|
||||
let roomListDiffPublisher = PassthroughSubject<SlidingSyncViewRoomsListDiff, Never>()
|
||||
|
||||
/// Publishes the current state of sliding sync, such as whether its catching up or live.
|
||||
let stateUpdatePublisher = CurrentValueSubject<SlidingSyncState, Never>(.cold)
|
||||
|
||||
/// Publishes the number of available rooms
|
||||
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
|
||||
|
||||
init(roomSummaryProvider: RoomSummaryProvider) {
|
||||
self.roomSummaryProvider = roomSummaryProvider
|
||||
}
|
||||
|
||||
// MARK: - SlidingSyncViewRoomListObserver
|
||||
|
||||
func didReceiveUpdate(diff: SlidingSyncViewRoomsListDiff) {
|
||||
roomListDiffPublisher.send(diff)
|
||||
}
|
||||
|
||||
// MARK: - SlidingSyncViewStateObserver
|
||||
|
||||
func didReceiveUpdate(newState: SlidingSyncState) {
|
||||
stateUpdatePublisher.send(newState)
|
||||
}
|
||||
|
||||
// MARK: - SlidingSyncViewRoomsCountObserver
|
||||
|
||||
func didReceiveUpdate(count: UInt32) {
|
||||
countUpdatePublisher.send(UInt(count))
|
||||
}
|
||||
}
|
||||
|
||||
class RoomSummaryProvider: RoomSummaryProviderProtocol {
|
||||
private let slidingSyncController: SlidingSyncProtocol
|
||||
private let slidingSyncView: SlidingSyncViewProtocol
|
||||
private let roomMessageFactory: RoomMessageFactoryProtocol
|
||||
|
||||
private var listUpdateObserverToken: StoppableSpawn?
|
||||
private var stateUpdateObserverToken: StoppableSpawn?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
let callbacks = PassthroughSubject<RoomSummaryProviderCallback, Never>()
|
||||
let stateUpdatePublisher = CurrentValueSubject<RoomSummaryProviderState, Never>(.cold)
|
||||
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
|
||||
|
||||
private(set) var roomSummaries: [RoomSummary] = [] {
|
||||
didSet {
|
||||
callbacks.send(.updatedRoomSummaries)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
listUpdateObserverToken?.cancel()
|
||||
stateUpdateObserverToken?.cancel()
|
||||
}
|
||||
|
||||
init(slidingSyncController: SlidingSyncProtocol, slidingSyncView: SlidingSyncViewProtocol, roomMessageFactory: RoomMessageFactoryProtocol) {
|
||||
self.slidingSyncView = slidingSyncView
|
||||
self.slidingSyncController = slidingSyncController
|
||||
self.roomMessageFactory = roomMessageFactory
|
||||
|
||||
let weakProvider = WeakRoomSummaryProviderWrapper(roomSummaryProvider: self)
|
||||
|
||||
weakProvider.stateUpdatePublisher
|
||||
.map(RoomSummaryProviderState.init)
|
||||
.subscribe(stateUpdatePublisher)
|
||||
.store(in: &cancellables)
|
||||
|
||||
weakProvider.countUpdatePublisher
|
||||
.subscribe(countUpdatePublisher)
|
||||
.store(in: &cancellables)
|
||||
|
||||
weakProvider.roomListDiffPublisher
|
||||
.collect(.byTime(DispatchQueue.global(qos: .background), 0.5))
|
||||
.sink { self.updateRoomsWithDiffs($0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
listUpdateObserverToken = slidingSyncView.observeRoomList(observer: weakProvider)
|
||||
stateUpdateObserverToken = slidingSyncView.observeState(observer: weakProvider)
|
||||
}
|
||||
|
||||
func updateRoomsWithIdentifiers(_ identifiers: [String]) {
|
||||
Task.detached {
|
||||
guard self.stateUpdatePublisher.value == .live else {
|
||||
return
|
||||
}
|
||||
|
||||
var changes = [CollectionDifference<RoomSummary>.Change]()
|
||||
for identifier in identifiers {
|
||||
guard let oldSummary = self.roomSummaries.first(where: { $0.id == identifier }),
|
||||
let index = self.roomSummaries.firstIndex(where: { $0.id == identifier }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let newSummary = self.buildRoomSummaryForIdentifier(identifier)
|
||||
|
||||
changes.append(.remove(offset: index, element: oldSummary, associatedWith: nil))
|
||||
changes.append(.insert(offset: index, element: newSummary, associatedWith: nil))
|
||||
}
|
||||
|
||||
guard let diff = CollectionDifference(changes) else {
|
||||
MXLog.error("Failed creating diff from changes: \(changes)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let newSummaries = self.roomSummaries.applying(diff) else {
|
||||
MXLog.error("Failed applying diff: \(diff)")
|
||||
return
|
||||
}
|
||||
|
||||
self.roomSummaries = newSummaries
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
fileprivate func updateRoomsWithDiffs(_ diffs: [SlidingSyncViewRoomsListDiff]) {
|
||||
roomSummaries = diffs
|
||||
.compactMap(buildDiff)
|
||||
.reduce(roomSummaries) { $0.applying($1) ?? $0 }
|
||||
}
|
||||
|
||||
private func buildEmptyRoomSummary(forIdentifier identifier: String = UUID().uuidString) -> RoomSummary {
|
||||
RoomSummary(id: identifier,
|
||||
name: "",
|
||||
isDirect: false,
|
||||
avatarURLString: nil,
|
||||
lastMessage: nil,
|
||||
lastMessageTimestamp: nil,
|
||||
unreadNotificationCount: 0)
|
||||
}
|
||||
|
||||
private func buildRoomSummaryForIdentifier(_ identifier: String) -> RoomSummary {
|
||||
guard let room = try? slidingSyncController.getRoom(roomId: identifier) else {
|
||||
MXLog.error("Failed finding room with id: \(identifier)")
|
||||
return buildEmptyRoomSummary(forIdentifier: identifier)
|
||||
}
|
||||
|
||||
var attributedLastMessage: AttributedString?
|
||||
var lastMessageTimestamp: Date?
|
||||
if let latestRoomMessage = room.latestRoomMessage() {
|
||||
let lastMessage = roomMessageFactory.buildRoomMessageFrom(EventTimelineItem(item: latestRoomMessage))
|
||||
if let lastMessageSender = try? AttributedString(markdown: "**\(lastMessage.sender)**") {
|
||||
// Don't include the message body in the markdown otherwise it makes tappable links.
|
||||
attributedLastMessage = lastMessageSender + ": " + AttributedString(lastMessage.body)
|
||||
}
|
||||
lastMessageTimestamp = lastMessage.originServerTs
|
||||
}
|
||||
|
||||
return RoomSummary(id: room.roomId(),
|
||||
name: room.name() ?? room.roomId(),
|
||||
isDirect: room.isDm() ?? false,
|
||||
avatarURLString: room.fullRoom()?.avatarUrl(),
|
||||
lastMessage: attributedLastMessage,
|
||||
lastMessageTimestamp: lastMessageTimestamp,
|
||||
unreadNotificationCount: UInt(room.unreadNotifications().notificationCount()))
|
||||
}
|
||||
|
||||
private func buildSummaryForRoomListEntry(_ entry: RoomListEntry) -> RoomSummary {
|
||||
switch entry {
|
||||
case .empty:
|
||||
return buildEmptyRoomSummary()
|
||||
case .filled(let roomId):
|
||||
return buildRoomSummaryForIdentifier(roomId)
|
||||
case .invalidated(let roomId):
|
||||
return buildRoomSummaryForIdentifier(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildDiff(from diff: SlidingSyncViewRoomsListDiff) -> CollectionDifference<RoomSummary>? {
|
||||
// Invalidations are a no-op for the moment
|
||||
if diff.isInvalidation {
|
||||
return nil
|
||||
}
|
||||
|
||||
var changes = [CollectionDifference<RoomSummary>.Change]()
|
||||
|
||||
switch diff {
|
||||
case .push(value: let value):
|
||||
let summary = buildSummaryForRoomListEntry(value)
|
||||
changes.append(.insert(offset: Int(roomSummaries.count), element: summary, associatedWith: nil))
|
||||
case .updateAt(let index, let value):
|
||||
let summary = buildSummaryForRoomListEntry(value)
|
||||
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
|
||||
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
|
||||
case .insertAt(let index, let value):
|
||||
let summary = buildSummaryForRoomListEntry(value)
|
||||
changes.append(.insert(offset: Int(index), element: summary, associatedWith: nil))
|
||||
case .move(let oldIndex, let newIndex):
|
||||
let summary = roomSummaries[Int(oldIndex)]
|
||||
changes.append(.remove(offset: Int(oldIndex), element: summary, associatedWith: nil))
|
||||
changes.append(.insert(offset: Int(newIndex), element: summary, associatedWith: nil))
|
||||
case .removeAt(let index):
|
||||
let summary = roomSummaries[Int(index)]
|
||||
changes.append(.remove(offset: Int(index), element: summary, associatedWith: nil))
|
||||
case .replace(let values):
|
||||
for (index, summary) in roomSummaries.enumerated() {
|
||||
changes.append(.remove(offset: index, element: summary, associatedWith: nil))
|
||||
}
|
||||
|
||||
values
|
||||
.reversed()
|
||||
.map { buildSummaryForRoomListEntry($0) }
|
||||
.forEach { summary in
|
||||
changes.append(.insert(offset: 0, element: summary, associatedWith: nil))
|
||||
}
|
||||
}
|
||||
|
||||
return CollectionDifference(changes)
|
||||
}
|
||||
}
|
||||
|
||||
extension SlidingSyncViewRoomsListDiff {
|
||||
var isInvalidation: Bool {
|
||||
switch self {
|
||||
case .push(let value), .updateAt(_, let value), .insertAt(_, let value):
|
||||
switch value {
|
||||
case .invalidated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomSummaryProviderState {
|
||||
init(slidingSyncState: SlidingSyncState) {
|
||||
switch slidingSyncState {
|
||||
case .catchingUp:
|
||||
self = .live
|
||||
case .live:
|
||||
self = .live
|
||||
default:
|
||||
self = .cold
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum RoomSummaryProviderCallback {
|
||||
case updatedRoomSummaries
|
||||
}
|
||||
|
||||
enum RoomSummaryProviderState {
|
||||
case cold
|
||||
case live
|
||||
}
|
||||
|
||||
protocol RoomSummaryProviderProtocol {
|
||||
var callbacks: PassthroughSubject<RoomSummaryProviderCallback, Never> { get }
|
||||
|
||||
/// Publishes the current state the summary provider is finding itself in
|
||||
var stateUpdatePublisher: CurrentValueSubject<RoomSummaryProviderState, Never> { get }
|
||||
|
||||
/// Publishes the total number of rooms
|
||||
var countUpdatePublisher: CurrentValueSubject<UInt, Never> { get }
|
||||
|
||||
/// The current list of rooms currently provided by the sliding sync view
|
||||
var roomSummaries: [RoomSummary] { get }
|
||||
|
||||
/// Invoked by the sliding sync controller whenever certain rooms have updated
|
||||
/// without necessarily changing their position in the list
|
||||
/// - Parameter identifiers: the identifiers for the rooms that have changed
|
||||
func updateRoomsWithIdentifiers(_ identifiers: [String])
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import Combine
|
||||
struct MockUserSession: UserSessionProtocol {
|
||||
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
|
||||
let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil
|
||||
|
||||
var userID: String { clientProxy.userIdentifier }
|
||||
var isSoftLogout: Bool { clientProxy.isSoftLogout }
|
||||
var deviceId: String? { clientProxy.deviceId }
|
||||
|
||||
@@ -76,7 +76,7 @@ class UserSession: UserSessionProtocol {
|
||||
}.store(in: &cancellables)
|
||||
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
|
||||
MXLog.info("Failed getting session verification controller with error: \(error). Will retry on the next sync update.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ enum UserSessionCallback {
|
||||
case updateRestoreTokenNeeded
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol UserSessionProtocol {
|
||||
var userID: String { get }
|
||||
var isSoftLogout: Bool { get }
|
||||
|
||||
@@ -22,39 +22,74 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
|
||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||
|
||||
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "Yesterday"),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "You rock!",
|
||||
timestamp: "10:10 AM",
|
||||
shouldShowSenderDetails: true,
|
||||
isOutgoing: false,
|
||||
senderId: "",
|
||||
senderDisplayName: "Some user with a really long long long long long display name",
|
||||
properties: RoomTimelineItemProperties(isEdited: true)),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "You also rule!",
|
||||
timestamp: "10:11 AM",
|
||||
shouldShowSenderDetails: false,
|
||||
isOutgoing: false,
|
||||
senderId: "",
|
||||
senderDisplayName: "Alice",
|
||||
properties: RoomTimelineItemProperties(reactions: [
|
||||
AggregatedReaction(key: "🙌", count: 1, isHighlighted: true)
|
||||
])),
|
||||
SeparatorRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "Today"),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "You too!",
|
||||
timestamp: "5 PM",
|
||||
shouldShowSenderDetails: false,
|
||||
isOutgoing: true,
|
||||
senderId: "",
|
||||
senderDisplayName: "Bob",
|
||||
properties: RoomTimelineItemProperties(reactions: [
|
||||
AggregatedReaction(key: "🙏", count: 1, isHighlighted: false),
|
||||
AggregatedReaction(key: "😁", count: 3, isHighlighted: false)
|
||||
]))]
|
||||
var timelineItems: [RoomTimelineItemProtocol] = [
|
||||
SeparatorRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "Yesterday"),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "That looks so good!",
|
||||
timestamp: "10:10 AM",
|
||||
shouldShowSenderDetails: true,
|
||||
inGroupState: .single,
|
||||
isOutgoing: false,
|
||||
senderId: "",
|
||||
senderDisplayName: "Jacob",
|
||||
properties: RoomTimelineItemProperties(isEdited: true)),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗",
|
||||
timestamp: "10:11 AM",
|
||||
shouldShowSenderDetails: true,
|
||||
inGroupState: .beginning,
|
||||
isOutgoing: false,
|
||||
senderId: "",
|
||||
senderDisplayName: "Helena",
|
||||
properties: RoomTimelineItemProperties(reactions: [
|
||||
AggregatedReaction(key: "🙌", count: 1, isHighlighted: true)
|
||||
])),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/",
|
||||
timestamp: "10:11 AM",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .middle,
|
||||
isOutgoing: false,
|
||||
senderId: "",
|
||||
senderDisplayName: "Helena",
|
||||
properties: RoomTimelineItemProperties(reactions: [
|
||||
AggregatedReaction(key: "🙏", count: 1, isHighlighted: false),
|
||||
AggregatedReaction(key: "🙌", count: 2, isHighlighted: true)
|
||||
])),
|
||||
SeparatorRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "Today"),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!",
|
||||
timestamp: "5 PM",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .end,
|
||||
isOutgoing: false,
|
||||
senderId: "",
|
||||
senderDisplayName: "Helena",
|
||||
properties: RoomTimelineItemProperties()),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "And John's speech was amazing!",
|
||||
timestamp: "5 PM",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .single,
|
||||
isOutgoing: true,
|
||||
senderId: "",
|
||||
senderDisplayName: "Bob",
|
||||
properties: RoomTimelineItemProperties()),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "New home office set up!",
|
||||
timestamp: "5 PM",
|
||||
shouldShowSenderDetails: false,
|
||||
inGroupState: .single,
|
||||
isOutgoing: true,
|
||||
senderId: "",
|
||||
senderDisplayName: "Bob",
|
||||
properties: RoomTimelineItemProperties(reactions: [
|
||||
AggregatedReaction(key: "🙏", count: 1, isHighlighted: false),
|
||||
AggregatedReaction(key: "😁", count: 3, isHighlighted: false)
|
||||
]))
|
||||
]
|
||||
|
||||
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineControllerError> {
|
||||
.failure(.generic)
|
||||
|
||||
+10
-23
@@ -14,34 +14,21 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
struct NoticeRoomMessage: RoomMessageProtocol {
|
||||
private let message: MatrixRustSDK.NoticeMessage
|
||||
struct MockRoomTimelineProvider: RoomTimelineProviderProtocol {
|
||||
var callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
|
||||
var items = [RoomTimelineProviderItem]()
|
||||
|
||||
init(message: MatrixRustSDK.NoticeMessage) {
|
||||
self.message = message
|
||||
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
|
||||
.failure(.failedPaginatingBackwards)
|
||||
}
|
||||
|
||||
var id: String {
|
||||
message.baseMessage().id()
|
||||
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
|
||||
.failure(.failedSendingMessage)
|
||||
}
|
||||
|
||||
var body: String {
|
||||
message.baseMessage().body()
|
||||
}
|
||||
|
||||
var htmlBody: String? {
|
||||
message.htmlBody()
|
||||
}
|
||||
|
||||
var sender: String {
|
||||
message.baseMessage().sender()
|
||||
}
|
||||
|
||||
var originServerTs: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
|
||||
func redact(_ eventID: String) async -> Result<Void, RoomTimelineProviderError> {
|
||||
.failure(.failedRedactingItem)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
private let timelineProvider: RoomTimelineProviderProtocol
|
||||
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let memberDetailProvider: MemberDetailProviderProtocol
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var timelineItemsUpdateTask: Task<Void, Never>? {
|
||||
@@ -42,13 +42,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
timelineProvider: RoomTimelineProviderProtocol,
|
||||
timelineItemFactory: RoomTimelineItemFactoryProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
memberDetailProvider: MemberDetailProviderProtocol) {
|
||||
roomProxy: RoomProxyProtocol) {
|
||||
self.userId = userId
|
||||
self.roomId = roomId
|
||||
self.timelineProvider = timelineProvider
|
||||
self.timelineItemFactory = timelineItemFactory
|
||||
self.mediaProvider = mediaProvider
|
||||
self.memberDetailProvider = memberDetailProvider
|
||||
self.roomProxy = roomProxy
|
||||
|
||||
self.timelineProvider
|
||||
.callbacks
|
||||
@@ -133,29 +133,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
|
||||
private func asyncUpdateTimelineItems() async {
|
||||
var newTimelineItems = [RoomTimelineItemProtocol]()
|
||||
|
||||
var previousMessage: RoomMessageProtocol?
|
||||
for message in timelineProvider.messages {
|
||||
|
||||
for (index, item) in timelineProvider.items.enumerated() {
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
let previousItem = timelineProvider.items[safe: index - 1]
|
||||
let nextItem = timelineProvider.items[safe: index + 1]
|
||||
|
||||
let inGroupState = inGroupState(for: item, previousItem: previousItem, nextItem: nextItem)
|
||||
|
||||
let areMessagesFromTheSameDay = haveSameDay(lhs: previousMessage, rhs: message)
|
||||
let shouldAddSectionHeader = !areMessagesFromTheSameDay
|
||||
|
||||
if shouldAddSectionHeader {
|
||||
newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
|
||||
text: message.originServerTs.formatted(date: .complete, time: .omitted)))
|
||||
switch item {
|
||||
case .event(let eventItem):
|
||||
guard eventItem.isMessage else { break } // To be handled in the future
|
||||
|
||||
newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(eventItem: eventItem,
|
||||
showSenderDetails: inGroupState.shouldShowSenderDetails,
|
||||
inGroupState: inGroupState))
|
||||
case .virtual:
|
||||
// case .virtual(let virtualItem):
|
||||
// newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
|
||||
// text: message.originServerTs.formatted(date: .complete, time: .omitted)))
|
||||
#warning("Fix the UUID or \"bad things will happen\"")
|
||||
newTimelineItems.append(SeparatorRoomTimelineItem(id: UUID().uuidString,
|
||||
text: "The day before"))
|
||||
case .other:
|
||||
break
|
||||
}
|
||||
|
||||
let areMessagesFromTheSameSender = (previousMessage?.sender == message.sender)
|
||||
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
|
||||
|
||||
newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(message: message,
|
||||
isOutgoing: message.sender == userId,
|
||||
showSenderDetails: shouldShowSenderDetails))
|
||||
|
||||
previousMessage = message
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
@@ -166,13 +171,50 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
|
||||
callbacks.send(.updatedTimelineItems)
|
||||
}
|
||||
|
||||
private func haveSameDay(lhs: RoomMessageProtocol?, rhs: RoomMessageProtocol?) -> Bool {
|
||||
guard let lhs = lhs, let rhs = rhs else {
|
||||
return false
|
||||
|
||||
private func inGroupState(for item: RoomTimelineProviderItem,
|
||||
previousItem: RoomTimelineProviderItem?,
|
||||
nextItem: RoomTimelineProviderItem?) -> TimelineItemInGroupState {
|
||||
guard let previousItem = previousItem else {
|
||||
// no previous item, check next item
|
||||
guard let nextItem = nextItem else {
|
||||
// no next item neither, this is single
|
||||
return .single
|
||||
}
|
||||
guard nextItem.canBeGrouped(with: item) else {
|
||||
// there is a next item but can't be grouped, this is single
|
||||
return .single
|
||||
}
|
||||
// next will be grouped with this one, this is the start
|
||||
return .beginning
|
||||
}
|
||||
|
||||
return Calendar.current.isDate(lhs.originServerTs, inSameDayAs: rhs.originServerTs)
|
||||
|
||||
guard let nextItem = nextItem else {
|
||||
// no next item
|
||||
guard item.canBeGrouped(with: previousItem) else {
|
||||
// there is a previous item but can't be grouped, this is single
|
||||
return .single
|
||||
}
|
||||
// will be grouped with previous, this is the end
|
||||
return .end
|
||||
}
|
||||
|
||||
guard item.canBeGrouped(with: previousItem) else {
|
||||
guard nextItem.canBeGrouped(with: item) else {
|
||||
// there is a next item but can't be grouped, this is single
|
||||
return .single
|
||||
}
|
||||
// next will be grouped with this one, this is the start
|
||||
return .beginning
|
||||
}
|
||||
|
||||
guard nextItem.canBeGrouped(with: item) else {
|
||||
// there is a next item but can't be grouped, this is the end
|
||||
return .end
|
||||
}
|
||||
|
||||
// next will be grouped with this one, this is the start
|
||||
return .middle
|
||||
}
|
||||
|
||||
private func loadImageForTimelineItem(_ timelineItem: ImageRoomTimelineItem) async {
|
||||
@@ -204,13 +246,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
return
|
||||
}
|
||||
|
||||
switch await memberDetailProvider.loadAvatarURLStringForUserId(timelineItem.senderId) {
|
||||
switch await roomProxy.loadAvatarURLForUserId(timelineItem.senderId) {
|
||||
case .success(let avatarURLString):
|
||||
guard let avatarURLString = avatarURLString else {
|
||||
return
|
||||
}
|
||||
|
||||
switch await mediaProvider.loadImageFromURLString(avatarURLString) {
|
||||
switch await mediaProvider.loadImageFromURLString(avatarURLString, size: MediaProviderDefaultAvatarSize) {
|
||||
case .success(let avatar):
|
||||
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
|
||||
var item = timelineItems[index] as? EventBasedTimelineItemProtocol else {
|
||||
@@ -234,7 +276,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||
return
|
||||
}
|
||||
|
||||
switch await memberDetailProvider.loadDisplayNameForUserId(timelineItem.senderId) {
|
||||
switch await roomProxy.loadDisplayNameForUserId(timelineItem.senderId) {
|
||||
case .success(let displayName):
|
||||
guard let displayName = displayName,
|
||||
let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
|
||||
|
||||
@@ -15,7 +15,20 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
#warning("Rename to RoomTimelineListener???")
|
||||
class WeakRoomTimelineProviderWrapper: TimelineListener {
|
||||
private weak var timelineProvider: RoomTimelineProvider?
|
||||
|
||||
init(timelineProvider: RoomTimelineProvider) {
|
||||
self.timelineProvider = timelineProvider
|
||||
}
|
||||
|
||||
func onUpdate(update: TimelineDiff) {
|
||||
timelineProvider?.onUpdate(update: update)
|
||||
}
|
||||
}
|
||||
|
||||
class RoomTimelineProvider: RoomTimelineProviderProtocol {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
@@ -23,31 +36,20 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
|
||||
|
||||
let callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
|
||||
|
||||
private(set) var items: [RoomTimelineProviderItem]
|
||||
|
||||
init(roomProxy: RoomProxyProtocol) {
|
||||
self.roomProxy = roomProxy
|
||||
|
||||
self.roomProxy.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch callback {
|
||||
case .updatedMessages:
|
||||
self.callbacks.send(.updatedMessages)
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
var messages: [RoomMessageProtocol] {
|
||||
roomProxy.messages
|
||||
items = []
|
||||
}
|
||||
|
||||
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
|
||||
switch await roomProxy.paginateBackwards(count: count) {
|
||||
case .success:
|
||||
return .success(())
|
||||
case .failure:
|
||||
return .failure(.generic)
|
||||
case .failure(let error):
|
||||
if error == .noMoreMessagesToBackPaginate { return .failure(.noMoreMessagesToBackPaginate) }
|
||||
return .failure(.failedPaginatingBackwards)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,3 +71,73 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimelineListener
|
||||
|
||||
private extension RoomTimelineProvider {
|
||||
func onUpdate(update: TimelineDiff) {
|
||||
let change = update.change()
|
||||
MXLog.verbose("Change: \(change)")
|
||||
|
||||
switch change {
|
||||
case .replace:
|
||||
replaceItems(update.replace())
|
||||
case .insertAt:
|
||||
insertItem(update.insertAt())
|
||||
case .updateAt:
|
||||
updateItem(update.updateAt())
|
||||
case .removeAt:
|
||||
removeItem(at: update.removeAt())
|
||||
case .move:
|
||||
moveItem(update.move())
|
||||
case .push:
|
||||
pushItem(update.push())
|
||||
case .pop:
|
||||
popItem()
|
||||
case .clear:
|
||||
clearAllItems()
|
||||
}
|
||||
|
||||
callbacks.send(.updatedMessages)
|
||||
}
|
||||
|
||||
private func replaceItems(_ items: [MatrixRustSDK.TimelineItem]?) {
|
||||
guard let items = items else { return }
|
||||
self.items = items.map(RoomTimelineProviderItem.init)
|
||||
}
|
||||
|
||||
private func insertItem(_ data: InsertAtData?) {
|
||||
guard let data = data else { return }
|
||||
let item = RoomTimelineProviderItem(item: data.item)
|
||||
items.insert(item, at: Int(data.index))
|
||||
}
|
||||
|
||||
private func updateItem(_ data: UpdateAtData?) {
|
||||
guard let data = data else { return }
|
||||
let item = RoomTimelineProviderItem(item: data.item)
|
||||
items[Int(data.index)] = item
|
||||
}
|
||||
|
||||
private func removeItem(at index: UInt32?) {
|
||||
guard let index = index else { return }
|
||||
items.remove(at: Int(index))
|
||||
}
|
||||
|
||||
private func moveItem(_ data: MoveData?) {
|
||||
guard let data = data else { return }
|
||||
items.move(fromOffsets: IndexSet(integer: Int(data.oldIndex)), toOffset: Int(data.newIndex))
|
||||
}
|
||||
|
||||
private func pushItem(_ item: MatrixRustSDK.TimelineItem?) {
|
||||
guard let item = item else { return }
|
||||
items.append(RoomTimelineProviderItem(item: item))
|
||||
}
|
||||
|
||||
private func popItem() {
|
||||
items.removeLast()
|
||||
}
|
||||
|
||||
private func clearAllItems() {
|
||||
items.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import MatrixRustSDK
|
||||
|
||||
/// A light wrapper around timeline items returned from Rust for use in `RoomTimelineProvider`.
|
||||
enum RoomTimelineProviderItem {
|
||||
case event(EventTimelineItem)
|
||||
case virtual(MatrixRustSDK.VirtualTimelineItem)
|
||||
case other(MatrixRustSDK.TimelineItem)
|
||||
|
||||
init(item: MatrixRustSDK.TimelineItem) {
|
||||
if let eventItem = item.asEvent() {
|
||||
self = .event(EventTimelineItem(item: eventItem))
|
||||
} else if let virtualItem = item.asVirtual() {
|
||||
self = .virtual(virtualItem)
|
||||
} else {
|
||||
self = .other(item)
|
||||
}
|
||||
}
|
||||
|
||||
func canBeGrouped(with prevItem: RoomTimelineProviderItem) -> Bool {
|
||||
guard case let .event(selfEventItem) = self, case let .event(prevEventItem) = prevItem else {
|
||||
return false
|
||||
}
|
||||
// can be improved by adding a date threshold
|
||||
return prevEventItem.reactions.isEmpty && selfEventItem.sender == prevEventItem.sender
|
||||
}
|
||||
}
|
||||
|
||||
/// A light wrapper around event timeline items returned from Rust, used in `RoomTimelineProviderItem`.
|
||||
struct EventTimelineItem {
|
||||
let item: MatrixRustSDK.EventTimelineItem
|
||||
|
||||
init(item: MatrixRustSDK.EventTimelineItem) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
var id: String {
|
||||
#warning("Handle txid in a better way")
|
||||
switch item.key() {
|
||||
case .transactionId(let txnID):
|
||||
return txnID
|
||||
case .eventId(let eventID):
|
||||
return eventID
|
||||
}
|
||||
}
|
||||
|
||||
var body: String? {
|
||||
content.asMessage()?.body()
|
||||
}
|
||||
|
||||
var isMessage: Bool {
|
||||
content.asMessage() != nil
|
||||
}
|
||||
|
||||
var content: TimelineItemContent {
|
||||
item.content()
|
||||
}
|
||||
|
||||
var sender: String {
|
||||
item.sender()
|
||||
}
|
||||
|
||||
var reactions: [Reaction] {
|
||||
item.reactions()
|
||||
}
|
||||
|
||||
var originServerTs: Date {
|
||||
if let timestamp = item.originServerTs() {
|
||||
return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
|
||||
} else {
|
||||
return .now
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,16 +22,17 @@ enum RoomTimelineProviderCallback {
|
||||
}
|
||||
|
||||
enum RoomTimelineProviderError: Error {
|
||||
case noMoreMessagesToBackPaginate
|
||||
case failedPaginatingBackwards
|
||||
case failedSendingMessage
|
||||
case failedRedactingItem
|
||||
case generic
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol RoomTimelineProviderProtocol {
|
||||
var callbacks: PassthroughSubject<RoomTimelineProviderCallback, Never> { get }
|
||||
|
||||
var messages: [RoomMessageProtocol] { get }
|
||||
var items: [RoomTimelineProviderItem] { get }
|
||||
|
||||
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
/// A protocol that contains the base `m.room.message` event content properties.
|
||||
protocol MessageContentProtocol: RoomMessageEventContentProtocol {
|
||||
var body: String { get }
|
||||
}
|
||||
|
||||
/// A timeline item that represents an `m.room.message` event.
|
||||
struct MessageTimelineItem<Content: MessageContentProtocol> {
|
||||
let item: MatrixRustSDK.EventTimelineItem
|
||||
let content: Content
|
||||
|
||||
var id: String {
|
||||
#warning("Handle txid properly")
|
||||
switch item.key() {
|
||||
case .transactionId(let txnID):
|
||||
return txnID
|
||||
case .eventId(let eventID):
|
||||
return eventID
|
||||
}
|
||||
}
|
||||
|
||||
var body: String {
|
||||
content.body
|
||||
}
|
||||
|
||||
var isEdited: Bool {
|
||||
item.content().asMessage()?.isEdited() == true
|
||||
}
|
||||
|
||||
var inReplyTo: String? {
|
||||
item.content().asMessage()?.inReplyTo()
|
||||
}
|
||||
|
||||
var reactions: [Reaction] {
|
||||
item.reactions()
|
||||
}
|
||||
|
||||
var sender: String {
|
||||
item.sender()
|
||||
}
|
||||
|
||||
var originServerTs: Date {
|
||||
if let timestamp = item.originServerTs() {
|
||||
return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
|
||||
} else {
|
||||
return .now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Formatted Text
|
||||
|
||||
/// A protocol that contains the expected event content properties for a formatted message.
|
||||
protocol FormattedMessageContentProtocol: MessageContentProtocol {
|
||||
var formatted: FormattedBody? { get }
|
||||
}
|
||||
|
||||
extension MatrixRustSDK.TextMessageContent: FormattedMessageContentProtocol { }
|
||||
extension MatrixRustSDK.EmoteMessageContent: FormattedMessageContentProtocol { }
|
||||
extension MatrixRustSDK.NoticeMessageContent: FormattedMessageContentProtocol { }
|
||||
|
||||
/// A timeline item that represents an `m.room.message` event where
|
||||
/// the `msgtype` would likely contain a formatted body.
|
||||
extension MessageTimelineItem where Content: FormattedMessageContentProtocol {
|
||||
var htmlBody: String? {
|
||||
#warning("Should this check the formatted type?")
|
||||
return content.formatted?.body
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Media
|
||||
|
||||
extension MatrixRustSDK.ImageMessageContent: MessageContentProtocol { }
|
||||
|
||||
/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.image`.
|
||||
extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent {
|
||||
var source: MediaSource {
|
||||
MediaSource(source: content.source)
|
||||
}
|
||||
|
||||
var width: CGFloat? {
|
||||
content.info?.width.map(CGFloat.init)
|
||||
}
|
||||
|
||||
var height: CGFloat? {
|
||||
content.info?.height.map(CGFloat.init)
|
||||
}
|
||||
|
||||
var blurhash: String? {
|
||||
content.info?.blurhash
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,40 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum TimelineItemInGroupState {
|
||||
case single
|
||||
case beginning
|
||||
case middle
|
||||
case end
|
||||
|
||||
var roundedCorners: UIRectCorner {
|
||||
switch self {
|
||||
case .single:
|
||||
return .allCorners
|
||||
case .beginning:
|
||||
return [.topLeft, .topRight]
|
||||
case .middle:
|
||||
return []
|
||||
case .end:
|
||||
return [.bottomLeft, .bottomRight]
|
||||
}
|
||||
}
|
||||
|
||||
var shouldShowSenderDetails: Bool {
|
||||
switch self {
|
||||
case .single, .beginning:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
|
||||
var text: String { get }
|
||||
var timestamp: String { get }
|
||||
var shouldShowSenderDetails: Bool { get }
|
||||
var inGroupState: TimelineItemInGroupState { get }
|
||||
var isOutgoing: Bool { get }
|
||||
|
||||
var senderId: String { get }
|
||||
|
||||
@@ -23,6 +23,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
|
||||
var attributedComponents: [AttributedStringBuilderComponent]?
|
||||
let timestamp: String
|
||||
let shouldShowSenderDetails: Bool
|
||||
let inGroupState: TimelineItemInGroupState
|
||||
let isOutgoing: Bool
|
||||
|
||||
let senderId: String
|
||||
|
||||
@@ -22,6 +22,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa
|
||||
let text: String
|
||||
let timestamp: String
|
||||
let shouldShowSenderDetails: Bool
|
||||
let inGroupState: TimelineItemInGroupState
|
||||
let isOutgoing: Bool
|
||||
|
||||
let senderId: String
|
||||
|
||||
@@ -23,6 +23,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equ
|
||||
var attributedComponents: [AttributedStringBuilderComponent]?
|
||||
let timestamp: String
|
||||
let shouldShowSenderDetails: Bool
|
||||
let inGroupState: TimelineItemInGroupState
|
||||
let isOutgoing: Bool
|
||||
|
||||
let senderId: String
|
||||
|
||||
@@ -23,6 +23,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equat
|
||||
var attributedComponents: [AttributedStringBuilderComponent]?
|
||||
let timestamp: String
|
||||
let shouldShowSenderDetails: Bool
|
||||
let inGroupState: TimelineItemInGroupState
|
||||
let isOutgoing: Bool
|
||||
|
||||
let senderId: String
|
||||
|
||||
@@ -14,46 +14,85 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let memberDetailProvider: MemberDetailProviderProtocol
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||
|
||||
init(mediaProvider: MediaProviderProtocol,
|
||||
memberDetailProvider: MemberDetailProviderProtocol,
|
||||
/// The Matrix ID of the current user.
|
||||
private let userID: String
|
||||
|
||||
init(userID: String,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
roomProxy: RoomProxyProtocol,
|
||||
attributedStringBuilder: AttributedStringBuilderProtocol) {
|
||||
self.userID = userID
|
||||
self.mediaProvider = mediaProvider
|
||||
self.memberDetailProvider = memberDetailProvider
|
||||
self.roomProxy = roomProxy
|
||||
self.attributedStringBuilder = attributedStringBuilder
|
||||
}
|
||||
|
||||
func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) async -> RoomTimelineItemProtocol {
|
||||
let displayName = memberDetailProvider.displayNameForUserId(message.sender)
|
||||
let avatarURL = memberDetailProvider.avatarURLStringForUserId(message.sender)
|
||||
let avatarImage = mediaProvider.imageFromURLString(avatarURL)
|
||||
func buildTimelineItemFor(eventItem: EventTimelineItem,
|
||||
showSenderDetails: Bool,
|
||||
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol {
|
||||
guard let messageContent = eventItem.content.asMessage() else { fatalError("Must be a message for now.") }
|
||||
let displayName = roomProxy.displayNameForUserId(eventItem.sender)
|
||||
let avatarURL = roomProxy.avatarURLStringForUserId(eventItem.sender)
|
||||
let avatarImage = mediaProvider.imageFromURLString(avatarURL, size: MediaProviderDefaultAvatarSize)
|
||||
let isOutgoing = eventItem.sender == userID
|
||||
|
||||
switch message {
|
||||
case let message as TextRoomMessage:
|
||||
return await buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
|
||||
case let message as ImageRoomMessage:
|
||||
return await buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
|
||||
case let message as NoticeRoomMessage:
|
||||
return await buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
|
||||
case let message as EmoteRoomMessage:
|
||||
return await buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, displayName, avatarImage)
|
||||
default:
|
||||
fatalError("Unknown room message.")
|
||||
switch messageContent.msgtype() {
|
||||
case .text(content: let content):
|
||||
let message = MessageTimelineItem(item: eventItem.item, content: content)
|
||||
return await buildTextTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
|
||||
case .image(content: let content):
|
||||
let message = MessageTimelineItem(item: eventItem.item, content: content)
|
||||
return await buildImageTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
|
||||
case .notice(content: let content):
|
||||
let message = MessageTimelineItem(item: eventItem.item, content: content)
|
||||
return await buildNoticeTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
|
||||
case .emote(content: let content):
|
||||
let message = MessageTimelineItem(item: eventItem.item, content: content)
|
||||
return await buildEmoteTimelineItemFromMessage(message, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
|
||||
case .none:
|
||||
return await buildFallbackTimelineItem(eventItem, isOutgoing, showSenderDetails, inGroupState, displayName, avatarImage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func buildTextTimelineItemFromMessage(_ message: TextRoomMessage,
|
||||
// swiftformat:disable function_parameter_count
|
||||
// swiftlint:disable function_parameter_count
|
||||
private func buildFallbackTimelineItem(_ item: EventTimelineItem,
|
||||
_ isOutgoing: Bool,
|
||||
_ showSenderDetails: Bool,
|
||||
_ inGroupState: TimelineItemInGroupState,
|
||||
_ displayName: String?,
|
||||
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
|
||||
let attributedText = await attributedStringBuilder.fromPlain(item.body)
|
||||
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
|
||||
|
||||
return TextRoomTimelineItem(id: item.id,
|
||||
text: item.body ?? "",
|
||||
attributedComponents: attributedComponents,
|
||||
timestamp: item.originServerTs.formatted(date: .omitted, time: .shortened),
|
||||
shouldShowSenderDetails: showSenderDetails,
|
||||
inGroupState: inGroupState,
|
||||
isOutgoing: isOutgoing,
|
||||
senderId: item.sender,
|
||||
senderDisplayName: displayName,
|
||||
senderAvatar: avatarImage,
|
||||
properties: RoomTimelineItemProperties(isEdited: item.content.asMessage()?.isEdited() ?? false,
|
||||
reactions: aggregateReactions(item.reactions)))
|
||||
}
|
||||
|
||||
private func buildTextTimelineItemFromMessage(_ message: MessageTimelineItem<TextMessageContent>,
|
||||
_ isOutgoing: Bool,
|
||||
_ showSenderDetails: Bool,
|
||||
_ inGroupState: TimelineItemInGroupState,
|
||||
_ displayName: String?,
|
||||
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
|
||||
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
|
||||
@@ -64,15 +103,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
attributedComponents: attributedComponents,
|
||||
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
|
||||
shouldShowSenderDetails: showSenderDetails,
|
||||
inGroupState: inGroupState,
|
||||
isOutgoing: isOutgoing,
|
||||
senderId: message.sender,
|
||||
senderDisplayName: displayName,
|
||||
senderAvatar: avatarImage)
|
||||
senderAvatar: avatarImage,
|
||||
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
|
||||
reactions: aggregateReactions(message.reactions)))
|
||||
}
|
||||
|
||||
private func buildImageTimelineItemFromMessage(_ message: ImageRoomMessage,
|
||||
private func buildImageTimelineItemFromMessage(_ message: MessageTimelineItem<ImageMessageContent>,
|
||||
_ isOutgoing: Bool,
|
||||
_ showSenderDetails: Bool,
|
||||
_ inGroupState: TimelineItemInGroupState,
|
||||
_ displayName: String?,
|
||||
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
|
||||
var aspectRatio: CGFloat?
|
||||
@@ -85,6 +128,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
text: message.body,
|
||||
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
|
||||
shouldShowSenderDetails: showSenderDetails,
|
||||
inGroupState: inGroupState,
|
||||
isOutgoing: isOutgoing,
|
||||
senderId: message.sender,
|
||||
senderDisplayName: displayName,
|
||||
@@ -94,12 +138,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
width: message.width,
|
||||
height: message.height,
|
||||
aspectRatio: aspectRatio,
|
||||
blurhash: message.blurhash)
|
||||
blurhash: message.blurhash,
|
||||
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
|
||||
reactions: aggregateReactions(message.reactions)))
|
||||
}
|
||||
|
||||
private func buildNoticeTimelineItemFromMessage(_ message: NoticeRoomMessage,
|
||||
private func buildNoticeTimelineItemFromMessage(_ message: MessageTimelineItem<NoticeMessageContent>,
|
||||
_ isOutgoing: Bool,
|
||||
_ showSenderDetails: Bool,
|
||||
_ inGroupState: TimelineItemInGroupState,
|
||||
_ displayName: String?,
|
||||
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
|
||||
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
|
||||
@@ -110,15 +157,19 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
attributedComponents: attributedComponents,
|
||||
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
|
||||
shouldShowSenderDetails: showSenderDetails,
|
||||
inGroupState: inGroupState,
|
||||
isOutgoing: isOutgoing,
|
||||
senderId: message.sender,
|
||||
senderDisplayName: displayName,
|
||||
senderAvatar: avatarImage)
|
||||
senderAvatar: avatarImage,
|
||||
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
|
||||
reactions: aggregateReactions(message.reactions)))
|
||||
}
|
||||
|
||||
private func buildEmoteTimelineItemFromMessage(_ message: EmoteRoomMessage,
|
||||
private func buildEmoteTimelineItemFromMessage(_ message: MessageTimelineItem<EmoteMessageContent>,
|
||||
_ isOutgoing: Bool,
|
||||
_ showSenderDetails: Bool,
|
||||
_ inGroupState: TimelineItemInGroupState,
|
||||
_ displayName: String?,
|
||||
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
|
||||
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
|
||||
@@ -129,9 +180,22 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
attributedComponents: attributedComponents,
|
||||
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
|
||||
shouldShowSenderDetails: showSenderDetails,
|
||||
inGroupState: inGroupState,
|
||||
isOutgoing: isOutgoing,
|
||||
senderId: message.sender,
|
||||
senderDisplayName: displayName,
|
||||
senderAvatar: avatarImage)
|
||||
senderAvatar: avatarImage,
|
||||
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
|
||||
reactions: aggregateReactions(message.reactions)))
|
||||
}
|
||||
|
||||
// swiftlint:enable function_parameter_count
|
||||
// swiftformat:enable function_parameter_count
|
||||
|
||||
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
|
||||
reactions.map { reaction in
|
||||
let isHighlighted = false // reaction.details.contains(where: { $0.sender == userID })
|
||||
return AggregatedReaction(key: reaction.key, count: Int(reaction.count), isHighlighted: isHighlighted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -18,5 +18,7 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol RoomTimelineItemFactoryProtocol {
|
||||
func buildTimelineItemFor(message: RoomMessageProtocol, isOutgoing: Bool, showSenderDetails: Bool) async -> RoomTimelineItemProtocol
|
||||
func buildTimelineItemFor(eventItem: EventTimelineItem,
|
||||
showSenderDetails: Bool,
|
||||
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol
|
||||
}
|
||||
|
||||
@@ -110,14 +110,12 @@ class MockScreen: Identifiable {
|
||||
case .roomPlainNoAvatar:
|
||||
let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
|
||||
roomName: "Some room name",
|
||||
roomAvatar: nil,
|
||||
roomEncryptionBadge: nil)
|
||||
roomAvatar: nil)
|
||||
return RoomScreenCoordinator(parameters: parameters)
|
||||
case .roomEncryptedWithAvatar:
|
||||
let parameters = RoomScreenCoordinatorParameters(timelineController: MockRoomTimelineController(),
|
||||
roomName: "Some room name",
|
||||
roomAvatar: Asset.Images.appLogo.image,
|
||||
roomEncryptionBadge: Asset.Images.encryptionTrusted.image)
|
||||
roomAvatar: Asset.Images.appLogo.image)
|
||||
return RoomScreenCoordinator(parameters: parameters)
|
||||
case .sessionVerification:
|
||||
let parameters = SessionVerificationCoordinatorParameters(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy())
|
||||
|
||||
Reference in New Issue
Block a user