From 0da2d4917b4a0e47ed4eb91c5bf995030e05497f Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 26 Jul 2024 20:41:00 +0200 Subject: [PATCH] Pin/Unpin Logic (#3084) --- ElementX.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../en.lproj/Localizable.strings | 9 +- ElementX/Sources/Generated/Strings.swift | 31 ++- .../Mocks/Generated/GeneratedMocks.swift | 227 ++++++++++++++++++ .../Mocks/Generated/SDKGeneratedMocks.swift | 225 +++++++++++++++++ ElementX/Sources/Mocks/RoomProxyMock.swift | 3 + ElementX/Sources/Other/Extensions/Task.swift | 11 + .../RoomScreenInteractionHandler.swift | 7 +- .../Screens/RoomScreen/RoomScreenModels.swift | 60 +++-- .../RoomScreen/RoomScreenViewModel.swift | 28 ++- .../ItemMenu/TimelineItemMenuAction.swift | 5 +- .../TimelineItemMenuActionProvider.swift | 6 +- .../PinnedItemsBannerView.swift | 20 +- .../Screens/RoomScreen/View/RoomScreen.swift | 7 +- .../Style/TimelineItemBubbledStylerView.swift | 1 + .../Sources/Services/Room/RoomProxy.swift | 18 +- .../Services/Room/RoomProxyProtocol.swift | 2 + .../MockRoomTimelineController.swift | 4 + .../RoomTimelineController.swift | 30 +++ .../RoomTimelineControllerProtocol.swift | 4 + .../Services/Timeline/TimelineProxy.swift | 18 ++ .../Timeline/TimelineProxyProtocol.swift | 4 + project.yml | 2 +- 24 files changed, 659 insertions(+), 69 deletions(-) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index be57beaa94..ea63ed8776 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -7501,7 +7501,7 @@ repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift"; requirement = { kind = exactVersion; - version = 1.0.28; + version = 1.0.29; }; }; 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index af9059fec7..b452f42d23 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -149,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "1a1cbc9d9d43a188d9b07fe00a141d02f7c43c7c", - "version" : "1.0.28" + "revision" : "d0226f669526e908d45bf9b5682f372d84cf9ffe", + "version" : "1.0.29" } }, { diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 74caa9af29..1cdb9970d1 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -70,6 +70,7 @@ "action_ok" = "OK"; "action_open_settings" = "Settings"; "action_open_with" = "Open with"; +"action_pin" = "Pin"; "action_quick_reply" = "Quick reply"; "action_quote" = "Quote"; "action_react" = "React"; @@ -99,10 +100,10 @@ "action_take_photo" = "Take photo"; "action_tap_for_options" = "Tap for options"; "action_try_again" = "Try again"; +"action_unpin" = "Unpin"; "action_view_source" = "View source"; "action_yes" = "Yes"; "action.load_more" = "Load more"; -"action.pin" = "Pin"; "common_about" = "About"; "common_acceptable_use_policy" = "Acceptable use policy"; "common_advanced_settings" = "Advanced settings"; @@ -313,9 +314,9 @@ "screen_advanced_settings_element_call_base_url_description" = "Set a custom base URL for Element Call."; "screen_advanced_settings_element_call_base_url_validation_error" = "Invalid URL, please make sure you include the protocol (http/https) and the correct address."; "screen_room_mentions_at_room_subtitle" = "Notify the whole room"; -"screen.room.pinned_banner_indicator" = "%1$@ of %2$@"; -"screen.room.pinned_banner_indicator_description" = "%1$@ Pinned messages"; -"screen.room.pinned_banner_view_all_button_title" = "View All"; +"screen_room_pinned_banner_indicator" = "%1$@ of %2$@"; +"screen_room_pinned_banner_indicator_description" = "%1$@ Pinned messages"; +"screen_room_pinned_banner_view_all_button_title" = "View All"; "screen_account_provider_change" = "Change account provider"; "screen_account_provider_form_hint" = "Homeserver address"; "screen_account_provider_form_notice" = "Enter a search term or a domain address."; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 670faabe46..708ea61ac5 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -174,6 +174,8 @@ internal enum L10n { internal static var actionOpenSettings: String { return L10n.tr("Localizable", "action_open_settings") } /// Open with internal static var actionOpenWith: String { return L10n.tr("Localizable", "action_open_with") } + /// Pin + internal static var actionPin: String { return L10n.tr("Localizable", "action_pin") } /// Quick reply internal static var actionQuickReply: String { return L10n.tr("Localizable", "action_quick_reply") } /// Quote @@ -232,6 +234,8 @@ internal enum L10n { internal static var actionTapForOptions: String { return L10n.tr("Localizable", "action_tap_for_options") } /// Try again internal static var actionTryAgain: String { return L10n.tr("Localizable", "action_try_again") } + /// Unpin + internal static var actionUnpin: String { return L10n.tr("Localizable", "action_unpin") } /// View source internal static var actionViewSource: String { return L10n.tr("Localizable", "action_view_source") } /// Yes @@ -1629,6 +1633,16 @@ internal enum L10n { internal static var screenRoomNotificationSettingsModeMentionsAndKeywords: String { return L10n.tr("Localizable", "screen_room_notification_settings_mode_mentions_and_keywords") } /// In this room, notify me for internal static var screenRoomNotificationSettingsRoomCustomSettingsTitle: String { return L10n.tr("Localizable", "screen_room_notification_settings_room_custom_settings_title") } + /// %1$@ of %2$@ + internal static func screenRoomPinnedBannerIndicator(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "screen_room_pinned_banner_indicator", String(describing: p1), String(describing: p2)) + } + /// %1$@ Pinned messages + internal static func screenRoomPinnedBannerIndicatorDescription(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_room_pinned_banner_indicator_description", String(describing: p1)) + } + /// View All + internal static var screenRoomPinnedBannerViewAllButtonTitle: String { return L10n.tr("Localizable", "screen_room_pinned_banner_view_all_button_title") } /// Send again internal static var screenRoomRetrySendMenuSendAgainAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_send_again_action") } /// Your message failed to send @@ -2229,8 +2243,6 @@ internal enum L10n { internal enum Action { /// Load more internal static var loadMore: String { return L10n.tr("Localizable", "action.load_more") } - /// Pin - internal static var pin: String { return L10n.tr("Localizable", "action.pin") } } internal enum Common { @@ -2241,21 +2253,6 @@ internal enum L10n { /// Send to internal static var sendTo: String { return L10n.tr("Localizable", "common.send_to") } } - - internal enum Screen { - internal enum Room { - /// %1$@ of %2$@ - internal static func pinnedBannerIndicator(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "screen.room.pinned_banner_indicator", String(describing: p1), String(describing: p2)) - } - /// %1$@ Pinned messages - internal static func pinnedBannerIndicatorDescription(_ p1: Any) -> String { - return L10n.tr("Localizable", "screen.room.pinned_banner_indicator_description", String(describing: p1)) - } - /// View All - internal static var pinnedBannerViewAllButtonTitle: String { return L10n.tr("Localizable", "screen.room.pinned_banner_view_all_button_title") } - } - } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index f86fcb2999..f989f22f70 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -8251,6 +8251,23 @@ class RoomProxyMock: RoomProxyProtocol { } var underlyingIsFavourite: Bool! var isFavouriteClosure: (() async -> Bool)? + var pinnedEventIDsCallsCount = 0 + var pinnedEventIDsCalled: Bool { + return pinnedEventIDsCallsCount > 0 + } + + var pinnedEventIDs: [String] { + get async { + pinnedEventIDsCallsCount += 1 + if let pinnedEventIDsClosure = pinnedEventIDsClosure { + return await pinnedEventIDsClosure() + } else { + return underlyingPinnedEventIDs + } + } + } + var underlyingPinnedEventIDs: [String]! + var pinnedEventIDsClosure: (() async -> [String])? var membership: Membership { get { return underlyingMembership } set(value) { underlyingMembership = value } @@ -10406,6 +10423,76 @@ class RoomProxyMock: RoomProxyProtocol { return canUserTriggerRoomNotificationUserIDReturnValue } } + //MARK: - canUserPinOrUnpin + + var canUserPinOrUnpinUserIDUnderlyingCallsCount = 0 + var canUserPinOrUnpinUserIDCallsCount: Int { + get { + if Thread.isMainThread { + return canUserPinOrUnpinUserIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = canUserPinOrUnpinUserIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canUserPinOrUnpinUserIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + canUserPinOrUnpinUserIDUnderlyingCallsCount = newValue + } + } + } + } + var canUserPinOrUnpinUserIDCalled: Bool { + return canUserPinOrUnpinUserIDCallsCount > 0 + } + var canUserPinOrUnpinUserIDReceivedUserID: String? + var canUserPinOrUnpinUserIDReceivedInvocations: [String] = [] + + var canUserPinOrUnpinUserIDUnderlyingReturnValue: Result! + var canUserPinOrUnpinUserIDReturnValue: Result! { + get { + if Thread.isMainThread { + return canUserPinOrUnpinUserIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = canUserPinOrUnpinUserIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canUserPinOrUnpinUserIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + canUserPinOrUnpinUserIDUnderlyingReturnValue = newValue + } + } + } + } + var canUserPinOrUnpinUserIDClosure: ((String) async -> Result)? + + func canUserPinOrUnpin(userID: String) async -> Result { + canUserPinOrUnpinUserIDCallsCount += 1 + canUserPinOrUnpinUserIDReceivedUserID = userID + DispatchQueue.main.async { + self.canUserPinOrUnpinUserIDReceivedInvocations.append(userID) + } + if let canUserPinOrUnpinUserIDClosure = canUserPinOrUnpinUserIDClosure { + return await canUserPinOrUnpinUserIDClosure(userID) + } else { + return canUserPinOrUnpinUserIDReturnValue + } + } //MARK: - kickUser var kickUserUnderlyingCallsCount = 0 @@ -12527,6 +12614,146 @@ class TimelineProxyMock: TimelineProxyProtocol { return redactReasonReturnValue } } + //MARK: - pin + + var pinEventIDUnderlyingCallsCount = 0 + var pinEventIDCallsCount: Int { + get { + if Thread.isMainThread { + return pinEventIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = pinEventIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinEventIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + pinEventIDUnderlyingCallsCount = newValue + } + } + } + } + var pinEventIDCalled: Bool { + return pinEventIDCallsCount > 0 + } + var pinEventIDReceivedEventID: String? + var pinEventIDReceivedInvocations: [String] = [] + + var pinEventIDUnderlyingReturnValue: Result! + var pinEventIDReturnValue: Result! { + get { + if Thread.isMainThread { + return pinEventIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = pinEventIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinEventIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + pinEventIDUnderlyingReturnValue = newValue + } + } + } + } + var pinEventIDClosure: ((String) async -> Result)? + + func pin(eventID: String) async -> Result { + pinEventIDCallsCount += 1 + pinEventIDReceivedEventID = eventID + DispatchQueue.main.async { + self.pinEventIDReceivedInvocations.append(eventID) + } + if let pinEventIDClosure = pinEventIDClosure { + return await pinEventIDClosure(eventID) + } else { + return pinEventIDReturnValue + } + } + //MARK: - unpin + + var unpinEventIDUnderlyingCallsCount = 0 + var unpinEventIDCallsCount: Int { + get { + if Thread.isMainThread { + return unpinEventIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = unpinEventIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + unpinEventIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + unpinEventIDUnderlyingCallsCount = newValue + } + } + } + } + var unpinEventIDCalled: Bool { + return unpinEventIDCallsCount > 0 + } + var unpinEventIDReceivedEventID: String? + var unpinEventIDReceivedInvocations: [String] = [] + + var unpinEventIDUnderlyingReturnValue: Result! + var unpinEventIDReturnValue: Result! { + get { + if Thread.isMainThread { + return unpinEventIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = unpinEventIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + unpinEventIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + unpinEventIDUnderlyingReturnValue = newValue + } + } + } + } + var unpinEventIDClosure: ((String) async -> Result)? + + func unpin(eventID: String) async -> Result { + unpinEventIDCallsCount += 1 + unpinEventIDReceivedEventID = eventID + DispatchQueue.main.async { + self.unpinEventIDReceivedInvocations.append(eventID) + } + if let unpinEventIDClosure = unpinEventIDClosure { + return await unpinEventIDClosure(eventID) + } else { + return unpinEventIDReturnValue + } + } //MARK: - sendAudio var sendAudioUrlAudioInfoProgressSubjectRequestHandleUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index c96019a8bd..a37d07a20a 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -10243,6 +10243,81 @@ open class RoomSDKMock: MatrixRustSDK.Room { } } + //MARK: - canUserPinUnpin + + open var canUserPinUnpinUserIdThrowableError: Error? + var canUserPinUnpinUserIdUnderlyingCallsCount = 0 + open var canUserPinUnpinUserIdCallsCount: Int { + get { + if Thread.isMainThread { + return canUserPinUnpinUserIdUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = canUserPinUnpinUserIdUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canUserPinUnpinUserIdUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + canUserPinUnpinUserIdUnderlyingCallsCount = newValue + } + } + } + } + open var canUserPinUnpinUserIdCalled: Bool { + return canUserPinUnpinUserIdCallsCount > 0 + } + open var canUserPinUnpinUserIdReceivedUserId: String? + open var canUserPinUnpinUserIdReceivedInvocations: [String] = [] + + var canUserPinUnpinUserIdUnderlyingReturnValue: Bool! + open var canUserPinUnpinUserIdReturnValue: Bool! { + get { + if Thread.isMainThread { + return canUserPinUnpinUserIdUnderlyingReturnValue + } else { + var returnValue: Bool? = nil + DispatchQueue.main.sync { + returnValue = canUserPinUnpinUserIdUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canUserPinUnpinUserIdUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + canUserPinUnpinUserIdUnderlyingReturnValue = newValue + } + } + } + } + open var canUserPinUnpinUserIdClosure: ((String) async throws -> Bool)? + + open override func canUserPinUnpin(userId: String) async throws -> Bool { + if let error = canUserPinUnpinUserIdThrowableError { + throw error + } + canUserPinUnpinUserIdCallsCount += 1 + canUserPinUnpinUserIdReceivedUserId = userId + DispatchQueue.main.async { + self.canUserPinUnpinUserIdReceivedInvocations.append(userId) + } + if let canUserPinUnpinUserIdClosure = canUserPinUnpinUserIdClosure { + return try await canUserPinUnpinUserIdClosure(userId) + } else { + return canUserPinUnpinUserIdReturnValue + } + } + //MARK: - canUserRedactOther open var canUserRedactOtherUserIdThrowableError: Error? @@ -18491,6 +18566,81 @@ open class TimelineSDKMock: MatrixRustSDK.Timeline { } } + //MARK: - pinEvent + + open var pinEventEventIdThrowableError: Error? + var pinEventEventIdUnderlyingCallsCount = 0 + open var pinEventEventIdCallsCount: Int { + get { + if Thread.isMainThread { + return pinEventEventIdUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = pinEventEventIdUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinEventEventIdUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + pinEventEventIdUnderlyingCallsCount = newValue + } + } + } + } + open var pinEventEventIdCalled: Bool { + return pinEventEventIdCallsCount > 0 + } + open var pinEventEventIdReceivedEventId: String? + open var pinEventEventIdReceivedInvocations: [String] = [] + + var pinEventEventIdUnderlyingReturnValue: Bool! + open var pinEventEventIdReturnValue: Bool! { + get { + if Thread.isMainThread { + return pinEventEventIdUnderlyingReturnValue + } else { + var returnValue: Bool? = nil + DispatchQueue.main.sync { + returnValue = pinEventEventIdUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinEventEventIdUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + pinEventEventIdUnderlyingReturnValue = newValue + } + } + } + } + open var pinEventEventIdClosure: ((String) async throws -> Bool)? + + open override func pinEvent(eventId: String) async throws -> Bool { + if let error = pinEventEventIdThrowableError { + throw error + } + pinEventEventIdCallsCount += 1 + pinEventEventIdReceivedEventId = eventId + DispatchQueue.main.async { + self.pinEventEventIdReceivedInvocations.append(eventId) + } + if let pinEventEventIdClosure = pinEventEventIdClosure { + return try await pinEventEventIdClosure(eventId) + } else { + return pinEventEventIdReturnValue + } + } + //MARK: - redactEvent open var redactEventItemReasonThrowableError: Error? @@ -19338,6 +19488,81 @@ open class TimelineSDKMock: MatrixRustSDK.Timeline { } try await toggleReactionEventIdKeyClosure?(eventId, key) } + + //MARK: - unpinEvent + + open var unpinEventEventIdThrowableError: Error? + var unpinEventEventIdUnderlyingCallsCount = 0 + open var unpinEventEventIdCallsCount: Int { + get { + if Thread.isMainThread { + return unpinEventEventIdUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = unpinEventEventIdUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + unpinEventEventIdUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + unpinEventEventIdUnderlyingCallsCount = newValue + } + } + } + } + open var unpinEventEventIdCalled: Bool { + return unpinEventEventIdCallsCount > 0 + } + open var unpinEventEventIdReceivedEventId: String? + open var unpinEventEventIdReceivedInvocations: [String] = [] + + var unpinEventEventIdUnderlyingReturnValue: Bool! + open var unpinEventEventIdReturnValue: Bool! { + get { + if Thread.isMainThread { + return unpinEventEventIdUnderlyingReturnValue + } else { + var returnValue: Bool? = nil + DispatchQueue.main.sync { + returnValue = unpinEventEventIdUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + unpinEventEventIdUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + unpinEventEventIdUnderlyingReturnValue = newValue + } + } + } + } + open var unpinEventEventIdClosure: ((String) async throws -> Bool)? + + open override func unpinEvent(eventId: String) async throws -> Bool { + if let error = unpinEventEventIdThrowableError { + throw error + } + unpinEventEventIdCallsCount += 1 + unpinEventEventIdReceivedEventId = eventId + DispatchQueue.main.async { + self.unpinEventEventIdReceivedInvocations.append(eventId) + } + if let unpinEventEventIdClosure = unpinEventEventIdClosure { + return try await unpinEventEventIdClosure(eventId) + } else { + return unpinEventEventIdReturnValue + } + } } open class TimelineDiffSDKMock: MatrixRustSDK.TimelineDiff { init() { diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift index 30e755def5..85fc38e886 100644 --- a/ElementX/Sources/Mocks/RoomProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -29,6 +29,7 @@ struct RoomProxyMockConfiguration { var isEncrypted = true var hasOngoingCall = true var canonicalAlias: String? + var pinnedEventIDs: [String] = [] var timelineStartReached = false @@ -63,6 +64,8 @@ extension RoomProxyMock { hasOngoingCall = configuration.hasOngoingCall canonicalAlias = configuration.canonicalAlias + underlyingPinnedEventIDs = configuration.pinnedEventIDs + let timeline = TimelineProxyMock() timeline.sendMessageEventContentReturnValue = .success(()) timeline.paginateBackwardsRequestSizeReturnValue = .success(()) diff --git a/ElementX/Sources/Other/Extensions/Task.swift b/ElementX/Sources/Other/Extensions/Task.swift index 99f73bbe21..a58731bccc 100644 --- a/ElementX/Sources/Other/Extensions/Task.swift +++ b/ElementX/Sources/Other/Extensions/Task.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import Foundation public extension Task where Success == Never, Failure == Never { @@ -61,3 +62,13 @@ public extension Task where Success == Never, Failure == Never { } } } + +extension Task { + func store(in cancellables: inout Set) { + asCancellable().store(in: &cancellables) + } + + func asCancellable() -> AnyCancellable { + AnyCancellable(cancel) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift index 1bc317e3ff..452407077c 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift @@ -172,8 +172,11 @@ class RoomScreenInteractionHandler { case .endPoll(let pollStartID): endPoll(pollStartID: pollStartID) case .pin: - // TODO: Implement the pin action - break + guard let eventID = itemID.eventID else { return } + Task { await timelineController.pin(eventID: eventID) } + case .unpin: + guard let eventID = itemID.eventID else { return } + Task { await timelineController.unpin(eventID: eventID) } } if action.switchToDefaultComposer { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index d29010455a..1897d8f8d5 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -15,9 +15,8 @@ // import Combine -import SwiftUI - import OrderedCollections +import SwiftUI enum RoomScreenViewModelAction { case displayRoomDetails @@ -140,7 +139,7 @@ enum RoomScreenViewAction { case hasSwitchedTimeline case hasScrolled(direction: ScrollDirection) - case nextPin + case tappedPinBanner case viewAllPins } @@ -172,20 +171,11 @@ struct RoomScreenViewState: BindableState { var isPinningEnabled = false var lastScrollDirection: ScrollDirection? - // These are just mocked items used for testing, their types might change - let pinnedItems = [ - "Hello 1", - "How are you 2", - "I am fine 3", - "Thank you 4" - ] - var currentPinIndex = 0 - var shouldShowPinBanner: Bool { - isPinningEnabled && !pinnedItems.isEmpty && lastScrollDirection != .top - } - var selectedPinContent: AttributedString { - .init(pinnedItems[currentPinIndex]) + var pinnedEventsState = PinnedEventsState() + + var shouldShowPinBanner: Bool { + isPinningEnabled && !pinnedEventsState.pinnedEventIDs.isEmpty && lastScrollDirection != .top } var canJoinCall = false @@ -304,3 +294,41 @@ enum ScrollDirection: Equatable { case top case bottom } + +struct PinnedEventsState: Equatable { + // For now these will only contain and show the event IDs, but in the future they will also contain the content + var pinnedEventIDs: OrderedSet = [] { + didSet { + if selectedPinEventID == nil, !pinnedEventIDs.isEmpty { + selectedPinEventID = pinnedEventIDs.first + } else if pinnedEventIDs.isEmpty { + selectedPinEventID = nil + } else if let selectedPinEventID, !pinnedEventIDs.contains(selectedPinEventID) { + self.selectedPinEventID = pinnedEventIDs.first + } + } + } + + var selectedPinEventID: String? + + var selectedPinIndex: Int { + guard let selectedPinEventID else { + return 0 + } + return pinnedEventIDs.firstIndex(of: selectedPinEventID) ?? 0 + } + + // For now we show the event ID as the content, but is just until we have a way to get the real content + var selectedPinContent: AttributedString { + .init(selectedPinEventID ?? "") + } + + mutating func nextPin() { + guard !pinnedEventIDs.isEmpty else { + return + } + let currentIndex = selectedPinIndex + let nextIndex = (currentIndex + 1) % pinnedEventIDs.count + selectedPinEventID = pinnedEventIDs[nextIndex] + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 945ccc997b..4d32e659a7 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -196,8 +196,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol Task { state.timelineViewState.isSwitchingTimelines = false } case let .hasScrolled(direction): state.lastScrollDirection = direction - case .nextPin: - state.currentPinIndex = (state.currentPinIndex + 1) % state.pinnedItems.count + case .tappedPinBanner: + if let eventID = state.pinnedEventsState.selectedPinEventID { + Task { await focusOnEvent(eventID: eventID) } + } + state.pinnedEventsState.nextPin() case .viewAllPins: // TODO: Implement break @@ -368,7 +371,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } if state.isPinningEnabled, - case let .success(value) = await roomProxy.canUser(userID: roomProxy.ownUserID, sendStateEvent: .roomPinnedEvents) { + case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) { state.canCurrentUserPin = value } else { state.canCurrentUserPin = false @@ -401,9 +404,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } .store(in: &cancellables) - roomProxy + let roomInfoSubscription = roomProxy .actionsPublisher .filter { $0 == .roomInfoUpdate } + + roomInfoSubscription .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self else { return } @@ -413,6 +418,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } .store(in: &cancellables) + Task { [weak self] in + guard let self else { + return + } + // If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update. + await state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs) + for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values { + guard !Task.isCancelled else { + return + } + await state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs) + } + } + .store(in: &cancellables) + appSettings.$sharePresence .weakAssign(to: \.state.showReadReceipts, on: self) .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuAction.swift b/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuAction.swift index cf4c9ed9ab..4fa340a75a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuAction.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuAction.swift @@ -62,6 +62,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case toggleReaction(key: String) case endPoll(pollStartID: String) case pin + case unpin var id: Self { self } @@ -135,7 +136,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case .endPoll: Label(L10n.actionEndPoll, icon: \.pollsEnd) case .pin: - Label(L10n.Action.pin, icon: \.pin) + Label(L10n.actionPin, icon: \.pin) + case .unpin: + Label(L10n.actionUnpin, icon: \.unpin) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuActionProvider.swift index 09ebd6d76c..45f5028a45 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -21,6 +21,7 @@ struct TimelineItemMenuActionProvider { let canCurrentUserRedactSelf: Bool let canCurrentUserRedactOthers: Bool let canCurrentUserPin: Bool + let pinnedEventIDs: Set let isDM: Bool let isViewSourceEnabled: Bool @@ -66,9 +67,8 @@ struct TimelineItemMenuActionProvider { actions.append(.forward(itemID: item.id)) } - if canCurrentUserPin { - // TODO: If the event is already pinned use the unpinned action - actions.append(.pin) + if canCurrentUserPin, let eventID = item.id.eventID { + actions.append(pinnedEventIDs.contains(eventID) ? .unpin : .pin) } if item.isEditable { diff --git a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift index 80bcb915e5..506ebdd9a3 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift @@ -18,18 +18,16 @@ import Compound import SwiftUI struct PinnedItemsBannerView: View { - let pinIndex: Int - let pinsCount: Int - let pinContent: AttributedString + let pinnedEventsState: PinnedEventsState let onMainButtonTap: () -> Void let onViewAllButtonTap: () -> Void private var bannerIndicatorDescription: AttributedString { - let index = pinIndex + 1 + let index = pinnedEventsState.selectedPinIndex + 1 let boldPlaceholder = "{bold}" - var finalString = AttributedString(L10n.Screen.Room.pinnedBannerIndicatorDescription(boldPlaceholder)) - var boldString = AttributedString(L10n.Screen.Room.pinnedBannerIndicator(index, pinsCount)) + var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder)) + var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, pinnedEventsState.pinnedEventIDs.count)) boldString.bold() finalString.replace(boldPlaceholder, with: boldString) return finalString @@ -50,7 +48,7 @@ struct PinnedItemsBannerView: View { Button { onMainButtonTap() } label: { HStack(spacing: 0) { HStack(spacing: 10) { - PinnedItemsIndicatorView(pinIndex: pinIndex, pinsCount: pinsCount) + PinnedItemsIndicatorView(pinIndex: pinnedEventsState.selectedPinIndex, pinsCount: pinnedEventsState.pinnedEventIDs.count) .accessibilityHidden(true) CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD) .foregroundColor(Color.compound.iconSecondaryAlpha) @@ -65,7 +63,7 @@ struct PinnedItemsBannerView: View { private var viewAllButton: some View { Button { onViewAllButtonTap() } label: { - Text(L10n.Screen.Room.pinnedBannerViewAllButtonTitle) + Text(L10n.screenRoomPinnedBannerViewAllButtonTitle) .font(.compound.bodyMDSemibold) .foregroundStyle(Color.compound.textPrimary) .padding(.horizontal, 16) @@ -79,7 +77,7 @@ struct PinnedItemsBannerView: View { .font(.compound.bodySM) .foregroundColor(.compound.textActionAccent) .lineLimit(1) - Text(pinContent) + Text(pinnedEventsState.selectedPinContent) .font(.compound.bodyMD) .foregroundColor(.compound.textPrimary) .lineLimit(1) @@ -89,9 +87,7 @@ struct PinnedItemsBannerView: View { struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview { static var previews: some View { - PinnedItemsBannerView(pinIndex: 0, - pinsCount: 3, - pinContent: .init(stringLiteral: "Content"), + PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventIDs: ["Content", "NotShown1", "NotShown2"], selectedPinEventID: "Content"), onMainButtonTap: { }, onViewAllButtonTap: { }) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index c62571c1c3..45ece1897a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -69,6 +69,7 @@ struct RoomScreen: View { canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf, canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers, canCurrentUserPin: context.viewState.canCurrentUserPin, + pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set, isDM: context.viewState.isEncryptedOneToOneRoom, isViewSourceEnabled: context.viewState.isViewSourceEnabled).makeActions() if let actions { @@ -109,10 +110,8 @@ struct RoomScreen: View { } private var pinnedItemsBanner: some View { - PinnedItemsBannerView(pinIndex: context.viewState.currentPinIndex, - pinsCount: context.viewState.pinnedItems.count, - pinContent: context.viewState.selectedPinContent, - onMainButtonTap: { context.send(viewAction: .nextPin) }, + PinnedItemsBannerView(pinnedEventsState: context.viewState.pinnedEventsState, + onMainButtonTap: { context.send(viewAction: .tappedPinBanner) }, onViewAllButtonTap: { context.send(viewAction: .viewAllPins) }) .transition(.move(edge: .top)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 84c5cad0ac..fff5b30f53 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -147,6 +147,7 @@ struct TimelineItemBubbledStylerView: View { canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf, canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers, canCurrentUserPin: context.viewState.canCurrentUserPin, + pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set, isDM: context.viewState.isEncryptedOneToOneRoom, isViewSourceEnabled: context.viewState.isViewSourceEnabled) TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 9a32bc181f..eb274047fe 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -16,9 +16,8 @@ import Combine import Foundation -import UIKit - import MatrixRustSDK +import UIKit class RoomProxy: RoomProxyProtocol { private let roomListItem: RoomListItemProtocol @@ -87,6 +86,12 @@ class RoomProxy: RoomProxyProtocol { } } + var pinnedEventIDs: [String] { + get async { + await (try? room.roomInfo().pinnedEventIds) ?? [] + } + } + var hasOngoingCall: Bool { room.hasActiveRoomCall() } @@ -493,6 +498,15 @@ class RoomProxy: RoomProxyProtocol { } } + func canUserPinOrUnpin(userID: String) async -> Result { + do { + return try await .success(room.canUserPinUnpin(userId: userID)) + } catch { + MXLog.error("Failed checking if the user can pin or unnpin: \(error)") + return .failure(.sdkError(error)) + } + } + // MARK: - Moderation func kickUser(_ userID: String) async -> Result { diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 6916f63cd0..1aa02c185d 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -38,6 +38,7 @@ protocol RoomProxyProtocol { var isSpace: Bool { get } var isEncrypted: Bool { get } var isFavourite: Bool { get async } + var pinnedEventIDs: [String] { get async } var membership: Membership { get } var hasOngoingCall: Bool { get } var canonicalAlias: String? { get } @@ -121,6 +122,7 @@ protocol RoomProxyProtocol { func canUserKick(userID: String) async -> Result func canUserBan(userID: String) async -> Result func canUserTriggerRoomNotification(userID: String) async -> Result + func canUserPinOrUnpin(userID: String) async -> Result // MARK: - Moderation diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 2eeaff01ee..1d452481fe 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -100,6 +100,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func redact(_ itemID: TimelineItemIdentifier) async { } + func pin(eventID: String) async { } + + func unpin(eventID: String) async { } + func messageEventContent(for itemID: TimelineItemIdentifier) -> RoomMessageEventContentWithoutRelation? { .init(noPointer: .init()) } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index d63345a24d..8db6d0c3bd 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -237,6 +237,36 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } + func pin(eventID: String) async { + MXLog.info("Pinning event \(eventID) in \(roomID)") + + switch await activeTimeline.pin(eventID: eventID) { + case .success(let value): + if value { + MXLog.info("Finished pinning event \(eventID)") + } else { + MXLog.error("Failed pinning event \(eventID) because is already pinned") + } + case .failure(let error): + MXLog.error("Failed pinning event \(eventID) with error: \(error)") + } + } + + func unpin(eventID: String) async { + MXLog.info("Unpinning event \(eventID) in \(roomID)") + + switch await activeTimeline.unpin(eventID: eventID) { + case .success(let value): + if value { + MXLog.info("Finished unpinning event \(eventID)") + } else { + MXLog.error("Failed unpinning event \(eventID) because is not pinned") + } + case .failure(let error): + MXLog.error("Failed unpinning event \(eventID) with error: \(error)") + } + } + func messageEventContent(for timelineItemID: TimelineItemIdentifier) async -> RoomMessageEventContentWithoutRelation? { await activeTimeline.messageEventContent(for: timelineItemID) } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 9e21465968..832b7e539e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -68,6 +68,10 @@ protocol RoomTimelineControllerProtocol { func redact(_ itemID: TimelineItemIdentifier) async + func pin(eventID: String) async + + func unpin(eventID: String) async + func messageEventContent(for itemID: TimelineItemIdentifier) async -> RoomMessageEventContentWithoutRelation? func debugInfo(for itemID: TimelineItemIdentifier) -> TimelineItemDebugInfo diff --git a/ElementX/Sources/Services/Timeline/TimelineProxy.swift b/ElementX/Sources/Services/Timeline/TimelineProxy.swift index eda53d0f5b..dce0dd1e08 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxy.swift @@ -202,6 +202,24 @@ final class TimelineProxy: TimelineProxyProtocol { } } + func pin(eventID: String) async -> Result { + do { + return try await .success(timeline.pinEvent(eventId: eventID)) + } catch { + MXLog.error("Failed to pin the event \(eventID) with error: \(error)") + return .failure(.sdkError(error)) + } + } + + func unpin(eventID: String) async -> Result { + do { + return try await .success(timeline.unpinEvent(eventId: eventID)) + } catch { + MXLog.error("Failed to unpin the event \(eventID) with error: \(error)") + return .failure(.sdkError(error)) + } + } + // MARK: - Sending func sendAudio(url: URL, diff --git a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift index 646024c198..511c494099 100644 --- a/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineProxyProtocol.swift @@ -46,6 +46,10 @@ protocol TimelineProxyProtocol { func redact(_ timelineItemID: TimelineItemIdentifier, reason: String?) async -> Result + func pin(eventID: String) async -> Result + + func unpin(eventID: String) async -> Result + // MARK: - Sending func sendAudio(url: URL, diff --git a/project.yml b/project.yml index 0044a0a868..419bae1a80 100644 --- a/project.yml +++ b/project.yml @@ -60,7 +60,7 @@ packages: # Element/Matrix dependencies MatrixRustSDK: url: https://github.com/element-hq/matrix-rust-components-swift - exactVersion: 1.0.28 + exactVersion: 1.0.29 # path: ../matrix-rust-sdk Compound: url: https://github.com/element-hq/compound-ios