From bbb462bd83cee2a659052e408e19bb60eac1644b Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:35:44 +0200 Subject: [PATCH] Pinned Events Timeline actions and differentiation (#3182) --- ElementX.xcodeproj/project.pbxproj | 4 + .../en.lproj/Localizable.strings | 1 + .../PinnedEventsTimelineFlowCoordinator.swift | 161 ++++++++++++++++++ .../RoomFlowCoordinator.swift | 148 +++++++++------- ElementX/Sources/Generated/Strings.swift | 2 + ...innedEventsTimelineScreenCoordinator.swift | 28 ++- .../View/PinnedEventsTimelineScreen.swift | 19 ++- .../View/RoomPollsHistoryScreen.swift | 3 +- .../RoomScreen/RoomScreenCoordinator.swift | 21 ++- .../Screens/RoomScreen/RoomScreenModels.swift | 76 ++++++++- .../RoomScreen/RoomScreenViewModel.swift | 26 ++- .../RoomScreenViewModelProtocol.swift | 1 + .../PinnedItemsBannerView.swift | 8 +- .../Screens/RoomScreen/View/RoomScreen.swift | 5 +- .../Timeline/TimelineInteractionHandler.swift | 5 + .../Screens/Timeline/TimelineModels.swift | 51 +----- .../Screens/Timeline/TimelineViewModel.swift | 32 +++- .../ItemMenu/TimelineItemMenuAction.swift | 12 ++ .../TimelineItemMenuActionProvider.swift | 8 +- .../Timeline/View/Polls/PollView.swift | 59 +++++-- .../ReadReceiptsSummaryView.swift | 1 + .../Style/TimelineItemBubbledStylerView.swift | 3 +- .../TimelineItemStatusView.swift | 5 +- .../TimelineReadReceiptsView.swift | 1 + .../HighlightedTimelineItemModifier.swift | 1 + .../PollRoomTimelineView.swift | 16 +- .../Screens/Timeline/View/TimelineView.swift | 1 + .../Services/Room/JoinedRoomProxy.swift | 2 +- .../Sources/Services/Timeline/GeoURI.swift | 4 + .../RoomTimelineController.swift | 5 +- .../RoomTimelineControllerFactory.swift | 2 + .../UITests/UITestsAppCoordinator.swift | 3 +- ...ollRoomTimelineView-iPad-en-GB.Preview.png | 3 + ...llRoomTimelineView-iPad-pseudo.Preview.png | 3 + ...omTimelineView-iPhone-15-en-GB.Preview.png | 3 + ...mTimelineView-iPhone-15-pseudo.Preview.png | 3 + .../test_pollView-iPad-en-GB.Preview.png | 3 + .../test_pollView-iPad-pseudo.Preview.png | 3 + .../test_pollView-iPhone-15-en-GB.Preview.png | 3 + ...test_pollView-iPhone-15-pseudo.Preview.png | 3 + UnitTests/Sources/PillContextTests.swift | 3 + .../PinnedEventsBannerStateTests.swift | 50 +++--- .../Sources/RoomScreenViewModelTests.swift | 75 +++++++- .../Sources/TimelineViewModelTests.swift | 5 + 44 files changed, 671 insertions(+), 200 deletions(-) create mode 100644 ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-en-GB.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-pseudo.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-en-GB.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-pseudo.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-en-GB.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-pseudo.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-en-GB.Preview.png create mode 100644 PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-pseudo.Preview.png diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 1f502a0c3..59bd47cc3 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -696,6 +696,7 @@ 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */; }; 9C4EC28A921486B1775D7F8C /* IdentityConfirmedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 307702DD66E7DDCDD9214784 /* IdentityConfirmedScreen.swift */; }; 9C55746D8F6A3E35CFCF4A7A /* AuthenticationStartLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 598F01EBD0C4CC550C644418 /* AuthenticationStartLogo.swift */; }; + 9CBB04365408F9D6F46BA3A7 /* PinnedEventsTimelineFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */; }; 9D2E03DB175A6AB14589076D /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3F7BCCB18C15B30CCA39A9 /* AnalyticsEvents */; }; 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */; }; 9D9690D2FD4CD26FF670620F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75EF87651B00A176AB08E97 /* AppDelegate.swift */; }; @@ -1869,6 +1870,7 @@ A436057DBEA1A23CA8CB1FD7 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = ""; }; A443FAE2EE820A5790C35C8D /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; A4A1003A0F7A1DFB47F4E2D0 /* TimelineItemMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMock.swift; sourceTree = ""; }; + A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineFlowCoordinator.swift; sourceTree = ""; }; A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = ""; }; A69869844D2B6F5BD9AABF85 /* OIDCConfigurationProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCConfigurationProxy.swift; sourceTree = ""; }; A6B19D10B102956066AF117B /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; @@ -3435,6 +3437,7 @@ A9B069D7772DDF6513E0F1B8 /* AuthenticationFlowCoordinator.swift */, 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */, C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */, + A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */, 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */, 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */, D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */, @@ -6503,6 +6506,7 @@ 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */, 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */, 7E2BB42805C59DB57E95610F /* PillView.swift in Sources */, + 9CBB04365408F9D6F46BA3A7 /* PinnedEventsTimelineFlowCoordinator.swift in Sources */, 5D52925FEB1B780C65B0529F /* PinnedEventsTimelineScreen.swift in Sources */, E184FFAD32342D3D6E2F89AA /* PinnedEventsTimelineScreenCoordinator.swift in Sources */, FEC03105D1BDE0F49BD7F243 /* PinnedEventsTimelineScreenModels.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 781009dae..035db99a4 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -102,6 +102,7 @@ "action_tap_for_options" = "Tap for options"; "action_try_again" = "Try again"; "action_unpin" = "Unpin"; +"action_view_in_timeline" = "View in timeline"; "action_view_source" = "View source"; "action_yes" = "Yes"; "action.load_more" = "Load more"; diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift new file mode 100644 index 000000000..96d037200 --- /dev/null +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -0,0 +1,161 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +enum PinnedEventsTimelineFlowCoordinatorAction { + case finished + case displayUser(userID: String) + case forwardedMessageToRoom(roomID: String) + case displayRoomScreenWithFocussedPin(eventID: String) +} + +class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { + private let navigationStackCoordinator: NavigationStackCoordinator + private let userSession: UserSessionProtocol + private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol + private let roomProxy: JoinedRoomProxyProtocol + private let userIndicatorController: UserIndicatorControllerProtocol + private let appMediator: AppMediatorProtocol + + private let actionsSubject: PassthroughSubject = .init() + var actionsPublisher: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + private var cancellables = Set() + + init(navigationStackCoordinator: NavigationStackCoordinator, + userSession: UserSessionProtocol, + roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, + roomProxy: JoinedRoomProxyProtocol, + userIndicatorController: UserIndicatorControllerProtocol, + appMediator: AppMediatorProtocol) { + self.navigationStackCoordinator = navigationStackCoordinator + self.userSession = userSession + self.roomTimelineControllerFactory = roomTimelineControllerFactory + self.roomProxy = roomProxy + self.userIndicatorController = userIndicatorController + self.appMediator = appMediator + } + + func start() { + Task { await presentPinnedEventsTimeline() } + } + + func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { + fatalError() + } + + func clearRoute(animated: Bool) { + fatalError() + } + + private func presentPinnedEventsTimeline() async { + let userID = userSession.clientProxy.userID + let timelineItemFactory = RoomTimelineItemFactory(userID: userID, + attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), + stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) + + guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory) else { + fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil") + } + + let coordinator = PinnedEventsTimelineScreenCoordinator(parameters: .init(roomProxy: roomProxy, + timelineController: timelineController, + mediaProvider: userSession.mediaProvider, + mediaPlayerProvider: MediaPlayerProvider(), + voiceMessageMediaManager: userSession.voiceMessageMediaManager, + appMediator: appMediator)) + + coordinator.actions + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .dismiss: + actionsSubject.send(.finished) + case .displayUser(let userID): + actionsSubject.send(.displayUser(userID: userID)) + case .presentLocationViewer(let geoURI, let description): + presentMapNavigator(geoURI: geoURI, description: description) + case .displayMessageForwarding(let forwardingItem): + presentMessageForwarding(with: forwardingItem) + case .displayRoomScreenWithFocussedPin(let eventID): + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setRootCoordinator(coordinator) + } + + private func presentMapNavigator(geoURI: GeoURI, description: String?) { + let stackCoordinator = NavigationStackCoordinator() + + let params = StaticLocationScreenCoordinatorParameters(interactionMode: .viewOnly(geoURI: geoURI, description: description), appMediator: appMediator) + let coordinator = StaticLocationScreenCoordinator(parameters: params) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .selectedLocation: + // We don't handle the sending/picker case in this flow + break + case .close: + self.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + stackCoordinator.setRootCoordinator(coordinator) + + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) + } + + private func presentMessageForwarding(with forwardingItem: MessageForwardingItem) { + guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider else { + fatalError() + } + + let stackCoordinator = NavigationStackCoordinator() + + let parameters = MessageForwardingScreenCoordinatorParameters(forwardingItem: forwardingItem, + clientProxy: userSession.clientProxy, + roomSummaryProvider: roomSummaryProvider, + mediaProvider: userSession.mediaProvider, + userIndicatorController: userIndicatorController) + let coordinator = MessageForwardingScreenCoordinator(parameters: parameters) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + + switch action { + case .dismiss: + navigationStackCoordinator.setSheetCoordinator(nil) + case .sent(let roomID): + navigationStackCoordinator.setSheetCoordinator(nil) + actionsSubject.send(.forwardedMessageToRoom(roomID: roomID)) + } + } + .store(in: &cancellables) + + stackCoordinator.setRootCoordinator(coordinator) + + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) + } +} diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 7f8ffab52..fe4add059 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -72,6 +72,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // periphery:ignore - used to avoid deallocation private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator? // periphery:ignore - used to avoid deallocation + private var pinnedEventsTimelineFlowCoordinator: PinnedEventsTimelineFlowCoordinator? + // periphery:ignore - used to avoid deallocation private var childRoomFlowCoordinator: RoomFlowCoordinator? private let stateMachine: StateMachine = .init(state: .initial) @@ -166,7 +168,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } else if roomID != roomProxy.id { stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) } else { - roomScreenCoordinator?.focusOnEvent(eventID: eventID) + roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) } case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias: break // These are converted to a room ID route one level above. @@ -194,7 +196,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch room { case .joined(let roomProxy): await storeAndSubscribeToRoomProxy(roomProxy) - stateMachine.tryEvent(.presentRoom(focussedEventID: focussedEventID), userInfo: EventUserInfo(animated: animated)) + let focussedEvent = focussedEventID.map { FocusEvent(eventID: $0, shouldSetPin: false) } + stateMachine.tryEvent(.presentRoom(focussedEvent: focussedEvent), userInfo: EventUserInfo(animated: animated)) default: stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated)) } @@ -270,16 +273,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .roomDetails(isRoot: false) case (.room, .presentRoomMemberDetails(userID: let userID)): - return .roomMemberDetails(userID: userID, fromRoomMembersList: false) + return .roomMemberDetails(userID: userID, previousState: .room) case (.roomMembersList, .presentRoomMemberDetails(userID: let userID)): - return .roomMemberDetails(userID: userID, fromRoomMembersList: true) - case (.roomMemberDetails(_, let fromRoomMembersList), .dismissRoomMemberDetails): - return fromRoomMembersList ? .roomMembersList : .room + return .roomMemberDetails(userID: userID, previousState: .roomMembersList) + case (.roomMemberDetails(_, let previousState), .dismissRoomMemberDetails): + return previousState - case (.roomMemberDetails(_, fromRoomMembersList: false), .presentUserProfile(let userID)): - return .userProfile(userID: userID) - case (.userProfile, .dismissUserProfile): - return .room + case (.roomMemberDetails(_, let previousState), .presentUserProfile(let userID)): + return .userProfile(userID: userID, previousState: previousState) + case (.userProfile(_, let previousState), .dismissUserProfile): + return previousState case (.roomDetails, .presentInviteUsersScreen): return .inviteUsersScreen(fromRoomMembersList: false) @@ -351,6 +354,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { return .rolesAndPermissions case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen): return .roomDetails(isRoot: false) + + case (.roomDetails, .presentRoomMemberDetails(let userID)): + return .roomMemberDetails(userID: userID, previousState: fromState) // Child flow @@ -375,8 +381,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case (_, .dismissJoinRoomScreen, .complete): dismissFlow(animated: animated) - case (_, .presentRoom(let focussedEventID), .room): - Task { await self.presentRoom(fromState: context.fromState, focussedEventID: focussedEventID, animated: animated) } + case (_, .presentRoom(let focussedEvent), .room): + Task { await self.presentRoom(fromState: context.fromState, focussedEvent: focussedEvent, animated: animated) } case (_, .dismissFlow, .complete): dismissFlow(animated: animated) @@ -470,7 +476,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { break case (.room, .presentPinnedEventsTimeline, .pinnedEventsTimeline): - Task { await self.presentPinnedEventsTimeline() } + presentPinnedEventsTimeline() case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .room): break @@ -480,7 +486,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { break case (.roomDetails, .presentPinnedEventsTimeline, .pinnedEventsTimeline): - Task { await self.presentPinnedEventsTimeline() } + presentPinnedEventsTimeline() case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .roomDetails): break @@ -493,6 +499,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { presentRolesAndPermissionsScreen() case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen, .roomDetails): rolesAndPermissionsFlowCoordinator = nil + + case (.roomDetails, .presentRoomMemberDetails(let userID), .roomMemberDetails): + presentRoomMemberDetails(userID: userID) + case (.roomMemberDetails, .dismissRoomMemberDetails, .roomDetails): + break + + case (.roomMemberDetails, .dismissUserProfile, .roomDetails): + break // Child flow case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild): @@ -525,9 +539,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { /// Updates the navigation stack so it displays the timeline for the given room /// - Parameters: /// - fromState: The state that asked for the room presentation. - /// - focussedEventID: An (optional) event ID that the timeline should be focussed around. + /// - focussedEvent: An (optional) struct that contains the event ID that the timeline should be focussed around, and a boolean telling if such event should update the pinned events banner /// - animated: whether it should animate the transition - private func presentRoom(fromState: State, focussedEventID: String? = nil, animated: Bool) async { + private func presentRoom(fromState: State, focussedEvent: FocusEvent?, animated: Bool) async { // If any sheets are presented dismiss them, rely on their dismissal callbacks to transition the state machine // through the correct states before presenting the room navigationStackCoordinator.setSheetCoordinator(nil) @@ -545,8 +559,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { // The room is already on the stack, no need to present it again // Check if we need to focus on an event - if let focussedEventID { - roomScreenCoordinator?.focusOnEvent(eventID: focussedEventID) + if let focussedEvent { + roomScreenCoordinator?.focusOnEvent(focussedEvent) } return @@ -565,7 +579,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, - initialFocussedEventID: focussedEventID, + initialFocussedEventID: focussedEvent?.eventID, timelineItemFactory: timelineItemFactory) self.timelineController = timelineController @@ -576,7 +590,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory) let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, - focussedEventID: focussedEventID, + focussedEvent: focussedEvent, timelineController: timelineController, mediaProvider: userSession.mediaProvider, mediaPlayerProvider: MediaPlayerProvider(), @@ -660,7 +674,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) { await storeAndSubscribeToRoomProxy(roomProxy) - stateMachine.tryEvent(.presentRoom(focussedEventID: nil), userInfo: EventUserInfo(animated: animated)) + stateMachine.tryEvent(.presentRoom(focussedEvent: nil), userInfo: EventUserInfo(animated: animated)) analytics.trackJoinedRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace, activeMemberCount: UInt(roomProxy.activeMembersCount)) } else { @@ -964,42 +978,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func presentPinnedEventsTimeline() async { - let userID = userSession.clientProxy.userID - let timelineItemFactory = RoomTimelineItemFactory(userID: userID, - attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), - stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) - - guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory) else { - fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil") - } - - let stackCoordinator = NavigationStackCoordinator() - let coordinator = PinnedEventsTimelineScreenCoordinator(parameters: .init(roomProxy: roomProxy, - timelineController: timelineController, - mediaProvider: userSession.mediaProvider, - mediaPlayerProvider: MediaPlayerProvider(), - voiceMessageMediaManager: userSession.voiceMessageMediaManager, - appMediator: appMediator)) - - coordinator.actions - .sink { [weak self] action in - guard let self else { return } - - switch action { - case .dismiss: - navigationStackCoordinator.setSheetCoordinator(nil) - } - } - .store(in: &cancellables) - - stackCoordinator.setRootCoordinator(coordinator) - - navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in - self?.stateMachine.tryEvent(.dismissPinnedEventsTimeline) - } - } - private func presentPollForm(mode: PollFormMode) { let stackCoordinator = NavigationStackCoordinator() let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode)) @@ -1345,6 +1323,43 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { coordinator.start() } + private func presentPinnedEventsTimeline() { + let stackCoordinator = NavigationStackCoordinator() + let coordinator = PinnedEventsTimelineFlowCoordinator(navigationStackCoordinator: stackCoordinator, + userSession: userSession, + roomTimelineControllerFactory: roomTimelineControllerFactory, + roomProxy: roomProxy, + userIndicatorController: userIndicatorController, + appMediator: appMediator) + + coordinator.actionsPublisher.sink { [weak self] action in + guard let self else { + return + } + + switch action { + case .finished: + navigationStackCoordinator.setSheetCoordinator(nil) + case .displayUser(let userID): + navigationStackCoordinator.setSheetCoordinator(nil) + stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID)) + case .forwardedMessageToRoom(let roomID): + navigationStackCoordinator.setSheetCoordinator(nil) + stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) + case .displayRoomScreenWithFocussedPin(let eventID): + navigationStackCoordinator.setSheetCoordinator(nil) + stateMachine.tryEvent(.presentRoom(focussedEvent: .init(eventID: eventID, shouldSetPin: true))) + } + } + .store(in: &cancellables) + + pinnedEventsTimelineFlowCoordinator = coordinator + navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissPinnedEventsTimeline) + } + coordinator.start() + } + // MARK: - Child Flow private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) async { @@ -1405,8 +1420,8 @@ private extension RoomFlowCoordinator { case notificationSettings case globalNotificationSettings case roomMembersList - case roomMemberDetails(userID: String, fromRoomMembersList: Bool) - case userProfile(userID: String) + case roomMemberDetails(userID: String, previousState: State) + case userProfile(userID: String, previousState: State) case inviteUsersScreen(fromRoomMembersList: Bool) case mediaUploadPicker(source: MediaPickerScreenSource) case mediaUploadPreview(fileURL: URL) @@ -1434,7 +1449,7 @@ private extension RoomFlowCoordinator { case presentJoinRoomScreen(via: [String]) case dismissJoinRoomScreen - case presentRoom(focussedEventID: String?) + case presentRoom(focussedEvent: FocusEvent?) case dismissFlow case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) @@ -1497,12 +1512,6 @@ private extension RoomFlowCoordinator { } } -private extension GeoURI { - var bodyMessage: String { - "Location was shared at \(string)" - } -} - private extension Result { var isFailure: Bool { switch self { @@ -1518,3 +1527,10 @@ private enum PinnedEventsTimelineSource: Hashable { case room case details(isRoot: Bool) } + +struct FocusEvent: Hashable { + /// The event ID that the timeline should be focussed around + let eventID: String + /// if the focus is coming from the pinned timeline, this should also update the pin banner + let shouldSetPin: Bool +} diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 26515de12..4bb5bb57f 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -238,6 +238,8 @@ internal enum L10n { 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 in timeline + internal static var actionViewInTimeline: String { return L10n.tr("Localizable", "action_view_in_timeline") } /// View source internal static var actionViewSource: String { return L10n.tr("Localizable", "action_view_source") } /// Yes diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift index 8df9cc79f..3257d8297 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift @@ -28,8 +28,10 @@ struct PinnedEventsTimelineScreenCoordinatorParameters { enum PinnedEventsTimelineScreenCoordinatorAction { case dismiss - - // Consider adding CustomStringConvertible conformance if the actions contain PII + case displayUser(userID: String) + case presentLocationViewer(geoURI: GeoURI, description: String?) + case displayMessageForwarding(forwardingItem: MessageForwardingItem) + case displayRoomScreenWithFocussedPin(eventID: String) } final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { @@ -50,6 +52,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { viewModel = PinnedEventsTimelineScreenViewModel() timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, timelineController: parameters.timelineController, + isPinnedEventsTimeline: true, mediaProvider: parameters.mediaProvider, mediaPlayerProvider: parameters.mediaPlayerProvider, voiceMessageMediaManager: parameters.voiceMessageMediaManager, @@ -70,6 +73,27 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { } } .store(in: &cancellables) + + timelineViewModel.actions.sink { [weak self] action in + MXLog.info("Coordinator: received timeline view model action: \(action)") + guard let self else { return } + + switch action { + case .tappedOnSenderDetails(let userID): + actionsSubject.send(.displayUser(userID: userID)) + case .displayMessageForwarding(let forwardingItem): + actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) + case .displayLocation(_, let geoURI, let description): + actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description)) + case .viewInRoomTimeline(let eventID): + actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID)) + // These other actions will not be handled in this view + case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, .composer, .hasScrolled: + // These actions are not handled in this coordinator + break + } + } + .store(in: &cancellables) } func toPresentable() -> AnyView { diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 22cedf93a..87db39bd8 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -35,8 +35,24 @@ struct PinnedEventsTimelineScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .background(.compound.bgCanvasDefault) - .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) .interactiveDismissDisabled() + .interactiveQuickLook(item: $timelineContext.mediaPreviewItem) + .sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) } + .sheet(item: $timelineContext.actionMenuInfo) { info in + let actions = TimelineItemMenuActionProvider(timelineItem: info.item, + canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf, + canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers, + canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, + pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, + isDM: timelineContext.viewState.isEncryptedOneToOneRoom, + isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, + isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline) + .makeActions() + if let actions { + TimelineItemMenu(item: info.item, actions: actions) + .environmentObject(timelineContext) + } + } } @ViewBuilder @@ -83,6 +99,7 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { timelineController.timelineItems = [] return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), timelineController: timelineController, + isPinnedEventsTimeline: true, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Screens/RoomPollsHistoryScreen/View/RoomPollsHistoryScreen.swift b/ElementX/Sources/Screens/RoomPollsHistoryScreen/View/RoomPollsHistoryScreen.swift index d92e55723..51f2c9b5b 100644 --- a/ElementX/Sources/Screens/RoomPollsHistoryScreen/View/RoomPollsHistoryScreen.swift +++ b/ElementX/Sources/Screens/RoomPollsHistoryScreen/View/RoomPollsHistoryScreen.swift @@ -67,7 +67,8 @@ struct RoomPollsHistoryScreen: View { Text(DateFormatter.pollTimestamp.string(from: pollTimelineItem.timestamp)) .font(.compound.bodySM) .foregroundColor(.compound.textSecondary) - PollView(poll: pollTimelineItem.item.poll, editable: pollTimelineItem.item.isEditable) { action in + PollView(poll: pollTimelineItem.item.poll, + state: .full(isEditable: pollTimelineItem.item.isEditable)) { action in switch action { case .selectOption(let optionID): guard let pollStartID = pollTimelineItem.item.id.eventID else { return } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 54a562fb0..c08229f62 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -21,7 +21,7 @@ import WysiwygComposer struct RoomScreenCoordinatorParameters { let roomProxy: JoinedRoomProxyProtocol - var focussedEventID: String? + var focussedEvent: FocusEvent? let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol @@ -63,7 +63,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol { } init(parameters: RoomScreenCoordinatorParameters) { + var selectedPinnedEventID: String? + if let focussedEvent = parameters.focussedEvent { + selectedPinnedEventID = focussedEvent.shouldSetPin ? focussedEvent.eventID : nil + } + roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, + initialSelectedPinnedEventID: selectedPinnedEventID, mediaProvider: parameters.mediaProvider, ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher, appMediator: parameters.appMediator, @@ -71,8 +77,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol { analyticsService: ServiceLocator.shared.analytics) timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, - focussedEventID: parameters.focussedEventID, + focussedEventID: parameters.focussedEvent?.eventID, timelineController: parameters.timelineController, + isPinnedEventsTimeline: false, mediaProvider: parameters.mediaProvider, mediaPlayerProvider: parameters.mediaPlayerProvider, voiceMessageMediaManager: parameters.voiceMessageMediaManager, @@ -133,6 +140,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { composerViewModel.process(timelineAction: action) case .hasScrolled(direction: let direction): roomViewModel.timelineHasScrolled(direction: direction) + case .viewInRoomTimeline: + fatalError("The action: \(action) should not be sent to this coordinator") } } .store(in: &cancellables) @@ -151,7 +160,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { switch actions { case .focusEvent(eventID: let eventID): - focusOnEvent(eventID: eventID) + focusOnEvent(FocusEvent(eventID: eventID, shouldSetPin: false)) case .displayPinnedEventsTimeline: actionsSubject.send(.presentPinnedEventsTimeline) case .displayRoomDetails: @@ -168,7 +177,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol { composerViewModel.loadDraft() } - func focusOnEvent(eventID: String) { + func focusOnEvent(_ focussedEvent: FocusEvent) { + let eventID = focussedEvent.eventID + if focussedEvent.shouldSetPin { + roomViewModel.setSelectedPinnedEventID(eventID) + } Task { await timelineViewModel.focusOnEvent(eventID: eventID) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 95a41b30c..0430752f1 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -75,10 +75,10 @@ enum PinnedEventsBannerState: Equatable { } } - var selectedPinEventID: String? { + var selectedPinnedEventID: String? { switch self { case .loaded(let state): - return state.selectedPinEventID + return state.selectedPinnedEventID default: return nil } @@ -93,10 +93,10 @@ enum PinnedEventsBannerState: Equatable { } } - var selectedPinIndex: Int { + var selectedPinnedIndex: Int { switch self { case .loaded(let state): - return state.selectedPinIndex + return state.selectedPinnedIndex case .loading(let numbersOfEvents): // We always want the index to be the last one when loading, since is the default one. return numbersOfEvents - 1 @@ -108,12 +108,12 @@ enum PinnedEventsBannerState: Equatable { case .loading: return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription) case .loaded(let state): - return state.selectedPinContent + return state.selectedPinnedContent } } var bannerIndicatorDescription: AttributedString { - let index = selectedPinIndex + 1 + let index = selectedPinnedIndex + 1 let boldPlaceholder = "{bold}" var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder)) var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count)) @@ -136,10 +136,72 @@ enum PinnedEventsBannerState: Equatable { switch self { case .loading: // The default selected event should always be the last one. - self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last)) + self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinnedEventID: pinnedEventContents.keys.last)) case .loaded(var state): state.pinnedEventContents = pinnedEventContents self = .loaded(state: state) } } + + // Note that if we are setting this value, this is definitely sent from the pinned events timeline + // so we can assume that the pinned events timeline is already loaded and we only need to set the + // selection for the loaded state + mutating func setSelectedPinnedEventID(_ eventID: String) { + switch self { + case .loaded(var state): + state.selectedPinnedEventID = eventID + self = .loaded(state: state) + case .loading: + break + } + } +} + +struct PinnedEventsState: Equatable { + var pinnedEventContents: OrderedDictionary = [:] { + didSet { + if selectedPinnedEventID == nil, !pinnedEventContents.keys.isEmpty { + // The default selected event should always be the last one. + selectedPinnedEventID = pinnedEventContents.keys.last + } else if pinnedEventContents.isEmpty { + selectedPinnedEventID = nil + } else if let selectedPinnedEventID, !pinnedEventContents.keys.set.contains(selectedPinnedEventID) { + self.selectedPinnedEventID = pinnedEventContents.keys.last + } + } + } + + var selectedPinnedEventID: String? + + var selectedPinnedIndex: Int { + let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1 + guard let selectedPinnedEventID else { + return defaultValue + } + return pinnedEventContents.keys.firstIndex(of: selectedPinnedEventID) ?? defaultValue + } + + var selectedPinnedContent: AttributedString { + var content = AttributedString(" ") + if let selectedPinnedEventID, + let pinnedEventContent = pinnedEventContents[selectedPinnedEventID] { + content = pinnedEventContent + } + content.font = .compound.bodyMD + content.link = nil + return content + } + + mutating func previousPin() { + guard !pinnedEventContents.isEmpty else { + return + } + let currentIndex = selectedPinnedIndex + let nextIndex = currentIndex - 1 + if nextIndex == -1 { + selectedPinnedEventID = pinnedEventContents.keys.last + } else { + selectedPinnedEventID = pinnedEventContents.keys[nextIndex % pinnedEventContents.count] + } + } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 9236dd861..b6083dce9 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -27,6 +27,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let appSettings: AppSettings private let analyticsService: AnalyticsService private let pinnedEventStringBuilder: RoomEventStringBuilder + private var initialSelectedPinnedEventID: String? private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { @@ -52,6 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } init(roomProxy: JoinedRoomProxyProtocol, + initialSelectedPinnedEventID: String?, mediaProvider: MediaProviderProtocol, ongoingCallRoomIDPublisher: CurrentValuePublisher, appMediator: AppMediatorProtocol, @@ -61,6 +63,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.appMediator = appMediator self.appSettings = appSettings self.analyticsService = analyticsService + self.initialSelectedPinnedEventID = initialSelectedPinnedEventID pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) super.init(initialViewState: .init(roomTitle: roomProxy.roomTitle, @@ -69,13 +72,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol bindings: .init()), mediaProvider: mediaProvider) + Task { + await handleRoomInfoUpdate() + } + setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher) } - + override func process(viewAction: RoomScreenViewAction) { switch viewAction { case .tappedPinnedEventsBanner: - if let eventID = state.pinnedEventsBannerState.selectedPinEventID { + if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID { actionsSubject.send(.focusEvent(eventID: eventID)) } state.pinnedEventsBannerState.previousPin() @@ -94,6 +101,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.lastScrollDirection = direction } + func setSelectedPinnedEventID(_ eventID: String) { + state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID) + } + private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher) { let roomInfoSubscription = roomProxy .actionsPublisher @@ -110,13 +121,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .store(in: &cancellables) Task { [weak self] in - // Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle. - // If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update. - await self?.handleRoomInfoUpdate() for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values { guard !Task.isCancelled else { return } + await self?.handleRoomInfoUpdate() } } @@ -159,6 +168,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents) + + // If it's the first time we are setting the pinned events, we should select the initial event if available. + if let initialSelectedPinnedEventID { + state.pinnedEventsBannerState.setSelectedPinnedEventID(initialSelectedPinnedEventID) + self.initialSelectedPinnedEventID = nil + } } private func handleRoomInfoUpdate() async { @@ -194,6 +209,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol extension RoomScreenViewModel { static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel { RoomScreenViewModel(roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, mediaProvider: MockMediaProvider(), ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift index 523d0ae7e..899da6c4c 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModelProtocol.swift @@ -22,4 +22,5 @@ protocol RoomScreenViewModelProtocol { var context: RoomScreenViewModel.Context { get } func timelineHasScrolled(direction: ScrollDirection) + func setSelectedPinnedEventID(_ eventID: String) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift index 8cb77c8b0..38c2bcd57 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/PinnedItemsBanner/PinnedItemsBannerView.swift @@ -38,7 +38,7 @@ struct PinnedItemsBannerView: View { Button { onMainButtonTap() } label: { HStack(spacing: 0) { HStack(spacing: 10) { - PinnedItemsIndicatorView(pinIndex: state.selectedPinIndex, pinsCount: state.count) + PinnedItemsIndicatorView(pinIndex: state.selectedPinnedIndex, pinsCount: state.count) .accessibilityHidden(true) CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD) .foregroundColor(Color.compound.iconSecondaryAlpha) @@ -98,16 +98,16 @@ struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview { PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Content", "2": "2", "3": "3"], - selectedPinEventID: "1")), + selectedPinnedEventID: "1")), onMainButtonTap: { }, onViewAllButtonTap: { }) PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Very very very very long content here", "2": "2"], - selectedPinEventID: "1")), + selectedPinnedEventID: "1")), onMainButtonTap: { }, onViewAllButtonTap: { }) PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": attributedContent], - selectedPinEventID: "1")), + selectedPinnedEventID: "1")), onMainButtonTap: { }, onViewAllButtonTap: { }) PinnedItemsBannerView(state: .loading(numbersOfEvents: 5), diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 68caa2a39..5c7305bcb 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -75,7 +75,9 @@ struct RoomScreen: View { canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, isDM: timelineContext.viewState.isEncryptedOneToOneRoom, - isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled).makeActions() + isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, + isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline) + .makeActions() if let actions { TimelineItemMenu(item: info.item, actions: actions) .environmentObject(timelineContext) @@ -218,6 +220,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index d74e00b0c..0a2595f65 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -31,6 +31,8 @@ enum TimelineInteractionHandlerAction { case displayAudioRecorderPermissionError case displayErrorToast(String) + + case viewInRoomTimeline(eventID: String) } @MainActor @@ -176,6 +178,9 @@ class TimelineInteractionHandler { case .unpin: guard let eventID = itemID.eventID else { return } Task { await timelineController.unpin(eventID: eventID) } + case .viewInRoomTimeline: + guard let eventID = itemID.eventID else { return } + actionsSubject.send(.viewInRoomTimeline(eventID: eventID)) } if action.switchToDefaultComposer { diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index f19a508e3..6af198a3c 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -32,6 +32,7 @@ enum TimelineViewModelAction { case displayLocation(body: String, geoURI: GeoURI, description: String?) case composer(action: TimelineComposerAction) case hasScrolled(direction: ScrollDirection) + case viewInRoomTimeline(eventID: String) } enum TimelineViewPollAction { @@ -89,6 +90,7 @@ enum TimelineComposerAction { } struct TimelineViewState: BindableState { + let isPinnedEventsTimeline: Bool var roomID: String var members: [String: RoomMemberState] = [:] var typingMembers: [String] = [] @@ -222,52 +224,3 @@ enum ScrollDirection: Equatable { case top case bottom } - -struct PinnedEventsState: Equatable { - var pinnedEventContents: OrderedDictionary = [:] { - didSet { - if selectedPinEventID == nil, !pinnedEventContents.keys.isEmpty { - // The default selected event should always be the last one. - selectedPinEventID = pinnedEventContents.keys.last - } else if pinnedEventContents.isEmpty { - selectedPinEventID = nil - } else if let selectedPinEventID, !pinnedEventContents.keys.set.contains(selectedPinEventID) { - self.selectedPinEventID = pinnedEventContents.keys.last - } - } - } - - private(set) var selectedPinEventID: String? - - var selectedPinIndex: Int { - let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1 - guard let selectedPinEventID else { - return defaultValue - } - return pinnedEventContents.keys.firstIndex(of: selectedPinEventID) ?? defaultValue - } - - var selectedPinContent: AttributedString { - var content = AttributedString(" ") - if let selectedPinEventID, - let pinnedEventContent = pinnedEventContents[selectedPinEventID] { - content = pinnedEventContent - } - content.font = .compound.bodyMD - content.link = nil - return content - } - - mutating func previousPin() { - guard !pinnedEventContents.isEmpty else { - return - } - let currentIndex = selectedPinIndex - let nextIndex = currentIndex - 1 - if nextIndex == -1 { - selectedPinEventID = pinnedEventContents.keys.last - } else { - selectedPinEventID = pinnedEventContents.keys[nextIndex % pinnedEventContents.count] - } - } -} diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 6d9117ed2..8de92a70e 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -52,6 +52,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { init(roomProxy: JoinedRoomProxyProtocol, focussedEventID: String? = nil, timelineController: RoomTimelineControllerProtocol, + isPinnedEventsTimeline: Bool, mediaProvider: MediaProviderProtocol, mediaPlayerProvider: MediaPlayerProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, @@ -80,7 +81,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { appSettings: appSettings, analyticsService: analyticsService) - super.init(initialViewState: TimelineViewState(roomID: roomProxy.id, + super.init(initialViewState: TimelineViewState(isPinnedEventsTimeline: isPinnedEventsTimeline, + roomID: roomProxy.id, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), ownUserID: roomProxy.ownUserID, @@ -93,6 +95,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { showFocusLoadingIndicator() } + Task { + await updatePinnedEventIDs() + } + setupSubscriptions() setupDirectRoomSubscriptionsIfNeeded() @@ -375,9 +381,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { .actionsPublisher .filter { $0 == .roomInfoUpdate } Task { [weak self] in - // Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle. - // If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update. - await self?.updatePinnedEventIDs() for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values { guard !Task.isCancelled else { return @@ -429,6 +432,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } case .showDebugInfo(let debugInfo): state.bindings.debugInfo = debugInfo + case .viewInRoomTimeline(let eventID): + actionsSubject.send(.viewInRoomTimeline(eventID: eventID)) } } .store(in: &cancellables) @@ -641,13 +646,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } else { for (index, item) in itemGroup.enumerated() { if index == 0 { - timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first), + timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .first), forKey: item.id.timelineID) } else if index == itemGroup.count - 1 { - timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .last), + timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .last), forKey: item.id.timelineID) } else { - timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle), + timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .middle), forKey: item.id.timelineID) } } @@ -822,6 +827,7 @@ extension TimelineViewModel { static let mock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), focussedEventID: nil, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -829,6 +835,18 @@ extension TimelineViewModel { appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, analyticsService: ServiceLocator.shared.analytics) + + static let pinnedEventsTimelineMock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), + focussedEventID: nil, + timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: true, + mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics) } private struct TimelineContextKey: EnvironmentKey { diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift index 4fa340a75..3d147e70d 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift @@ -63,6 +63,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case endPoll(pollStartID: String) case pin case unpin + case viewInRoomTimeline var id: Self { self } @@ -106,6 +107,15 @@ enum TimelineItemMenuAction: Identifiable, Hashable { } } + var canAppearInPinnedEventsTimeline: Bool { + switch self { + case .viewInRoomTimeline, .pin, .unpin, .forward, .redact: + return true + default: + return false + } + } + /// The action's label. @ViewBuilder var label: some View { @@ -139,6 +149,8 @@ enum TimelineItemMenuAction: Identifiable, Hashable { Label(L10n.actionPin, icon: \.pin) case .unpin: Label(L10n.actionUnpin, icon: \.unpin) + case .viewInRoomTimeline: + Label(L10n.actionViewInTimeline, icon: \.visibilityOn) } } } diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift index 45f5028a4..1a10273d4 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -24,6 +24,7 @@ struct TimelineItemMenuActionProvider { let pinnedEventIDs: Set let isDM: Bool let isViewSourceEnabled: Bool + let isPinnedEventsTimeline: Bool // swiftlint:disable:next cyclomatic_complexity func makeActions() -> TimelineItemMenuActions? { @@ -102,8 +103,13 @@ struct TimelineItemMenuActionProvider { if item.isRedacted { actions = actions.filter(\.canAppearInRedacted) } + + if isPinnedEventsTimeline { + actions.insert(.viewInRoomTimeline, at: 0) + actions = actions.filter(\.canAppearInPinnedEventsTimeline) + } - return .init(isReactable: item.isReactable, actions: actions, debugActions: debugActions) + return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable, actions: actions, debugActions: debugActions) } private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool { diff --git a/ElementX/Sources/Screens/Timeline/View/Polls/PollView.swift b/ElementX/Sources/Screens/Timeline/View/Polls/PollView.swift index 7bd44641c..41f4090bb 100644 --- a/ElementX/Sources/Screens/Timeline/View/Polls/PollView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Polls/PollView.swift @@ -23,21 +23,48 @@ enum PollViewAction { case end } +enum PollViewState { + case preview + case full(isEditable: Bool) + + var isPreview: Bool { + switch self { + case .preview: + return true + case .full: + return false + } + } + + var isEditable: Bool { + switch self { + case .preview: + return false + case .full(let isEditable): + return isEditable + } + } +} + struct PollView: View { private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) let poll: Poll - let editable: Bool + let state: PollViewState let actionHandler: (PollViewAction) -> Void var body: some View { - VStack(alignment: .leading, spacing: 16) { + if state.isPreview { questionView - optionsView - summaryView - toolbarView + } else { + VStack(alignment: .leading, spacing: 16) { + questionView + optionsView + summaryView + toolbarView + } + .frame(maxWidth: 450) } - .frame(maxWidth: 450) } // MARK: - Private @@ -88,7 +115,7 @@ struct PollView: View { Button { toolbarAction() } label: { - Text(editable ? L10n.actionEditPoll : L10n.actionEndPoll) + Text(state.isEditable ? L10n.actionEditPoll : L10n.actionEndPoll) .lineLimit(2, reservesSpace: false) .font(.compound.bodyLGSemibold) .foregroundColor(.compound.textOnSolidPrimary) @@ -105,7 +132,7 @@ struct PollView: View { } private func toolbarAction() { - if editable { + if state.isEditable { actionHandler(.edit) } else { actionHandler(.end) @@ -146,28 +173,32 @@ private extension Poll { struct PollView_Previews: PreviewProvider, TestablePreview { static var previews: some View { - PollView(poll: .disclosed(), editable: false) { _ in } + PollView(poll: .disclosed(), state: .full(isEditable: false)) { _ in } .padding() .previewDisplayName("Disclosed") - PollView(poll: .undisclosed(), editable: false) { _ in } + PollView(poll: .undisclosed(), state: .full(isEditable: false)) { _ in } .padding() .previewDisplayName("Undisclosed") - PollView(poll: .endedDisclosed, editable: false) { _ in } + PollView(poll: .endedDisclosed, state: .full(isEditable: false)) { _ in } .padding() .previewDisplayName("Ended, Disclosed") - PollView(poll: .endedUndisclosed, editable: false) { _ in } + PollView(poll: .endedUndisclosed, state: .full(isEditable: false)) { _ in } .padding() .previewDisplayName("Ended, Undisclosed") - PollView(poll: .disclosed(createdByAccountOwner: true), editable: true) { _ in } + PollView(poll: .disclosed(createdByAccountOwner: true), state: .full(isEditable: true)) { _ in } .padding() .previewDisplayName("Creator, disclosed") - PollView(poll: .emptyDisclosed, editable: true) { _ in } + PollView(poll: .emptyDisclosed, state: .full(isEditable: true)) { _ in } .padding() .previewDisplayName("Creator, no votes") + + PollView(poll: .emptyDisclosed, state: .preview) { _ in } + .padding() + .previewDisplayName("Preview") } } diff --git a/ElementX/Sources/Screens/Timeline/View/ReadReceipts/ReadReceiptsSummaryView.swift b/ElementX/Sources/Screens/Timeline/View/ReadReceipts/ReadReceiptsSummaryView.swift index ceb018162..4eda9a4d0 100644 --- a/ElementX/Sources/Screens/Timeline/View/ReadReceipts/ReadReceiptsSummaryView.swift +++ b/ElementX/Sources/Screens/Timeline/View/ReadReceipts/ReadReceiptsSummaryView.swift @@ -54,6 +54,7 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview { let roomProxyMock = JoinedRoomProxyMock(.init(name: "Room", members: members)) let mock = TimelineViewModel(roomProxy: roomProxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index 5312394f4..103e781ed 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -147,7 +147,8 @@ struct TimelineItemBubbledStylerView: View { canCurrentUserPin: context.viewState.canCurrentUserPin, pinnedEventIDs: context.viewState.pinnedEventIDs, isDM: context.viewState.isEncryptedOneToOneRoom, - isViewSourceEnabled: context.viewState.isViewSourceEnabled) + isViewSourceEnabled: context.viewState.isViewSourceEnabled, + isPinnedEventsTimeline: context.viewState.isPinnedEventsTimeline) TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action)) } diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift index 660ccf090..b8ee46abc 100644 --- a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineItemStatusView.swift @@ -32,7 +32,10 @@ struct TimelineItemStatusView: View { @ViewBuilder private var mainContent: some View { - if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty { + if context.viewState.isPinnedEventsTimeline { + // Do not display any status when is a pinned events timeline + EmptyView() + } else if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty { readReceipts } else { deliveryStatusBadge diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineReadReceiptsView.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineReadReceiptsView.swift index f6a842dd2..eb35840f8 100644 --- a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineReadReceiptsView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineReadReceiptsView.swift @@ -92,6 +92,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Test", members: members)), timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift index 321a1df9d..889bc89ca 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/HighlightedTimelineItemModifier.swift @@ -99,6 +99,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider { static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, focussedEventID: focussedEventID, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/PollRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/PollRoomTimelineView.swift index 056474531..c54298c0e 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/PollRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/PollRoomTimelineView.swift @@ -20,9 +20,18 @@ struct PollRoomTimelineView: View { let timelineItem: PollRoomTimelineItem @EnvironmentObject private var context: TimelineViewModel.Context + private var state: PollViewState { + if context.viewState.isPinnedEventsTimeline { + return .preview + } else { + return .full(isEditable: timelineItem.isEditable) + } + } + var body: some View { TimelineStyler(timelineItem: timelineItem) { - PollView(poll: poll, editable: timelineItem.isEditable) { action in + PollView(poll: poll, + state: state) { action in switch action { case .selectOption(let optionID): guard let eventID, let option = poll.options.first(where: { $0.id == optionID }), !option.isSelected else { return } @@ -51,6 +60,7 @@ struct PollRoomTimelineView: View { struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel.mock + static let pinnedEventsTimelineViewModel = TimelineViewModel.pinnedEventsTimelineMock static var previews: some View { PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false)) @@ -76,5 +86,9 @@ struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview { PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true)) .environmentObject(viewModel.context) .previewDisplayName("Creator, no votes, Bubble") + + PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isEditable: true)) + .environmentObject(pinnedEventsTimelineViewModel.context) + .previewDisplayName("Preview") } } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index 574c18f1f..169700b8b 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -84,6 +84,7 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview { static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index a32346cf0..2a6902256 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -44,7 +44,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } do { - let timeline = try await TimelineProxy(timeline: room.pinnedEventsTimeline(internalIdPrefix: nil, maxEventsToLoad: 100), isLive: false) + let timeline = try await TimelineProxy(timeline: room.pinnedEventsTimeline(internalIdPrefix: nil, maxEventsToLoad: 100), isLive: true) await timeline.subscribeForUpdates() innerPinnedEventsTimeline = timeline return timeline diff --git a/ElementX/Sources/Services/Timeline/GeoURI.swift b/ElementX/Sources/Services/Timeline/GeoURI.swift index e57e918f6..410a5fab6 100644 --- a/ElementX/Sources/Services/Timeline/GeoURI.swift +++ b/ElementX/Sources/Services/Timeline/GeoURI.swift @@ -24,6 +24,10 @@ struct GeoURI: Hashable { let latitude: Double let longitude: Double let uncertainty: Double? + + var bodyMessage: String { + "Location was shared at \(string)" + } // MARK: - Setup diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index b3a14923b..4c71f242d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -25,6 +25,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { private let timelineItemFactory: RoomTimelineItemFactoryProtocol private let appSettings: AppSettings private let serialDispatchQueue: DispatchQueue + private let shouldHideStart: Bool let callbacks = PassthroughSubject() @@ -44,12 +45,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol { init(roomProxy: JoinedRoomProxyProtocol, timelineProxy: TimelineProxyProtocol, initialFocussedEventID: String?, + shouldHideStart: Bool, timelineItemFactory: RoomTimelineItemFactoryProtocol, appSettings: AppSettings) { self.roomProxy = roomProxy liveTimelineProvider = timelineProxy.timelineProvider self.timelineItemFactory = timelineItemFactory self.appSettings = appSettings + self.shouldHideStart = shouldHideStart serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) activeTimeline = timelineProxy @@ -364,7 +367,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { // Check if we need to add anything to the top of the timeline. switch paginationState.backward { case .timelineEndReached: - if !roomProxy.isEncryptedOneToOneRoom { + if !shouldHideStart, !roomProxy.isEncryptedOneToOneRoom { let timelineStart = TimelineStartRoomTimelineItem(name: roomProxy.name) newTimelineItems.insert(timelineStart, at: 0) } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift index 456326001..a2d100077 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerFactory.swift @@ -23,6 +23,7 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { RoomTimelineController(roomProxy: roomProxy, timelineProxy: roomProxy.timeline, initialFocussedEventID: initialFocussedEventID, + shouldHideStart: false, timelineItemFactory: timelineItemFactory, appSettings: ServiceLocator.shared.settings) } @@ -35,6 +36,7 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol { return RoomTimelineController(roomProxy: roomProxy, timelineProxy: pinnedEventsTimeline, initialFocussedEventID: nil, + shouldHideStart: true, timelineItemFactory: timelineItemFactory, appSettings: ServiceLocator.shared.settings) } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 428bdcbf3..ec808c5c5 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -446,7 +446,7 @@ class MockScreen: Identifiable { let client = try UITestsSignalling.Client(mode: .app) client.signals.sink { [weak self] signal in guard case .timeline(.focusOnEvent(let eventID)) = signal else { return } - coordinator.focusOnEvent(eventID: eventID) + coordinator.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) try? client.send(.success) } .store(in: &cancellables) @@ -649,6 +649,7 @@ class MockScreen: Identifiable { let timelineController = RoomTimelineController(roomProxy: roomProxy, timelineProxy: roomProxy.timeline, initialFocussedEventID: nil, + shouldHideStart: false, timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org", attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")), diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-en-GB.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-en-GB.Preview.png new file mode 100644 index 000000000..84a833e2d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-en-GB.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:901024e6cd60483e2af6001b87f72cb98fc347cd53c82097f232f602994ea5f7 +size 79270 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-pseudo.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-pseudo.Preview.png new file mode 100644 index 000000000..84a833e2d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPad-pseudo.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:901024e6cd60483e2af6001b87f72cb98fc347cd53c82097f232f602994ea5f7 +size 79270 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-en-GB.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-en-GB.Preview.png new file mode 100644 index 000000000..87e5aa10d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-en-GB.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dd720bde5e4e8496a48d89e624017904e1c7861187698488ddd53e211963171 +size 39185 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-pseudo.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-pseudo.Preview.png new file mode 100644 index 000000000..87e5aa10d --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollRoomTimelineView-iPhone-15-pseudo.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dd720bde5e4e8496a48d89e624017904e1c7861187698488ddd53e211963171 +size 39185 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-en-GB.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-en-GB.Preview.png new file mode 100644 index 000000000..6b217663a --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-en-GB.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88c3bb5b185ac4c183744884d63de94a2554872e317e09b9454fc895d2aa2035 +size 74948 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-pseudo.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-pseudo.Preview.png new file mode 100644 index 000000000..6b217663a --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPad-pseudo.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88c3bb5b185ac4c183744884d63de94a2554872e317e09b9454fc895d2aa2035 +size 74948 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-en-GB.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-en-GB.Preview.png new file mode 100644 index 000000000..7a3d9905f --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-en-GB.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76c12d5338c4d8832169ac2c06d3ef37b91e66a02b5e57b61a14d6bd0b546def +size 33674 diff --git a/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-pseudo.Preview.png b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-pseudo.Preview.png new file mode 100644 index 000000000..7a3d9905f --- /dev/null +++ b/PreviewTests/__Snapshots__/PreviewTests/test_pollView-iPhone-15-pseudo.Preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76c12d5338c4d8832169ac2c06d3ef37b91e66a02b5e57b61a14d6bd0b546def +size 33674 diff --git a/UnitTests/Sources/PillContextTests.swift b/UnitTests/Sources/PillContextTests.swift index c5956ef0a..7c72ea1ff 100644 --- a/UnitTests/Sources/PillContextTests.swift +++ b/UnitTests/Sources/PillContextTests.swift @@ -28,6 +28,7 @@ class PillContextTests: XCTestCase { proxyMock.membersPublisher = subject.asCurrentValuePublisher() let mock = TimelineViewModel(roomProxy: proxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -56,6 +57,7 @@ class PillContextTests: XCTestCase { proxyMock.membersPublisher = subject.asCurrentValuePublisher() let mock = TimelineViewModel(roomProxy: proxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -77,6 +79,7 @@ class PillContextTests: XCTestCase { mockController.roomProxy = proxyMock let mock = TimelineViewModel(roomProxy: proxyMock, timelineController: mockController, + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), diff --git a/UnitTests/Sources/PinnedEventsBannerStateTests.swift b/UnitTests/Sources/PinnedEventsBannerStateTests.swift index 9e35c5664..c4d22d162 100644 --- a/UnitTests/Sources/PinnedEventsBannerStateTests.swift +++ b/UnitTests/Sources/PinnedEventsBannerStateTests.swift @@ -37,9 +37,9 @@ class PinnedEventsBannerStateTests: XCTestCase { XCTAssertTrue(state.isLoading) XCTAssertFalse(state.isEmpty) - XCTAssertNil(state.selectedPinEventID) + XCTAssertNil(state.selectedPinnedEventID) XCTAssertEqual(state.displayedMessage.string, L10n.screenRoomPinnedBannerLoadingDescription) - XCTAssertEqual(state.selectedPinIndex, 4) + XCTAssertEqual(state.selectedPinnedIndex, 4) XCTAssertEqual(state.count, 5) XCTAssertEqual(state.bannerIndicatorDescription.string, L10n.screenRoomPinnedBannerIndicatorDescription(L10n.screenRoomPinnedBannerIndicator(5, 5))) } @@ -48,42 +48,42 @@ class PinnedEventsBannerStateTests: XCTestCase { var state = PinnedEventsBannerState.loading(numbersOfEvents: 2) XCTAssertTrue(state.isLoading) state.setPinnedEventContents(["1": "test1", "2": "test2"]) - XCTAssertEqual(state, .loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2"], selectedPinEventID: "2"))) + XCTAssertEqual(state, .loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2"], selectedPinnedEventID: "2"))) XCTAssertFalse(state.isLoading) } func testLoaded() { - let state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2"], selectedPinEventID: "2")) + let state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2"], selectedPinnedEventID: "2")) XCTAssertFalse(state.isLoading) XCTAssertFalse(state.isEmpty) - XCTAssertEqual(state.selectedPinEventID, "2") + XCTAssertEqual(state.selectedPinnedEventID, "2") XCTAssertEqual(state.displayedMessage.string, "test2") - XCTAssertEqual(state.selectedPinIndex, 1) + XCTAssertEqual(state.selectedPinnedIndex, 1) XCTAssertEqual(state.count, 2) XCTAssertEqual(state.bannerIndicatorDescription.string, L10n.screenRoomPinnedBannerIndicatorDescription(L10n.screenRoomPinnedBannerIndicator(2, 2))) } func testPreviousPin() { - var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3"], selectedPinEventID: "1")) - XCTAssertEqual(state.selectedPinEventID, "1") - XCTAssertEqual(state.selectedPinIndex, 0) + var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3"], selectedPinnedEventID: "1")) + XCTAssertEqual(state.selectedPinnedEventID, "1") + XCTAssertEqual(state.selectedPinnedIndex, 0) XCTAssertEqual(state.displayedMessage.string, "test1") state.previousPin() - XCTAssertEqual(state.selectedPinEventID, "3") - XCTAssertEqual(state.selectedPinIndex, 2) + XCTAssertEqual(state.selectedPinnedEventID, "3") + XCTAssertEqual(state.selectedPinnedIndex, 2) XCTAssertEqual(state.displayedMessage.string, "test3") state.previousPin() - XCTAssertEqual(state.selectedPinEventID, "2") - XCTAssertEqual(state.selectedPinIndex, 1) + XCTAssertEqual(state.selectedPinnedEventID, "2") + XCTAssertEqual(state.selectedPinnedIndex, 1) XCTAssertEqual(state.displayedMessage.string, "test2") } func testSetContent() { - var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3", "4": "test4"], selectedPinEventID: "2")) - XCTAssertEqual(state.selectedPinEventID, "2") - XCTAssertEqual(state.selectedPinIndex, 1) + var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3", "4": "test4"], selectedPinnedEventID: "2")) + XCTAssertEqual(state.selectedPinnedEventID, "2") + XCTAssertEqual(state.selectedPinnedIndex, 1) XCTAssertEqual(state.displayedMessage.string, "test2") XCTAssertEqual(state.count, 4) XCTAssertFalse(state.isEmpty) @@ -91,8 +91,8 @@ class PinnedEventsBannerStateTests: XCTestCase { // let's remove the selected item state.setPinnedEventContents(["1": "test1", "3": "test3", "4": "test4"]) // new selected item is the new latest - XCTAssertEqual(state.selectedPinEventID, "4") - XCTAssertEqual(state.selectedPinIndex, 2) + XCTAssertEqual(state.selectedPinnedEventID, "4") + XCTAssertEqual(state.selectedPinnedIndex, 2) XCTAssertEqual(state.displayedMessage.string, "test4") XCTAssertEqual(state.count, 3) XCTAssertFalse(state.isEmpty) @@ -100,9 +100,9 @@ class PinnedEventsBannerStateTests: XCTestCase { // let's add a new item at the top state.setPinnedEventContents(["0": "test0", "1": "test1", "3": "test3", "4": "test4"]) // selected item doesn't change - XCTAssertEqual(state.selectedPinEventID, "4") + XCTAssertEqual(state.selectedPinnedEventID, "4") // but the index is updated - XCTAssertEqual(state.selectedPinIndex, 3) + XCTAssertEqual(state.selectedPinnedIndex, 3) XCTAssertEqual(state.displayedMessage.string, "test4") XCTAssertEqual(state.count, 4) XCTAssertFalse(state.isEmpty) @@ -110,9 +110,9 @@ class PinnedEventsBannerStateTests: XCTestCase { // let's add a new item at the bottom state.setPinnedEventContents(["0": "test0", "1": "test1", "3": "test3", "4": "test4", "5": "test5"]) // selected item doesn't change - XCTAssertEqual(state.selectedPinEventID, "4") + XCTAssertEqual(state.selectedPinnedEventID, "4") // and index stays the same - XCTAssertEqual(state.selectedPinIndex, 3) + XCTAssertEqual(state.selectedPinnedIndex, 3) XCTAssertEqual(state.displayedMessage.string, "test4") XCTAssertEqual(state.count, 5) XCTAssertFalse(state.isEmpty) @@ -120,12 +120,12 @@ class PinnedEventsBannerStateTests: XCTestCase { // set to tempty state.setPinnedEventContents([:]) XCTAssertTrue(state.isEmpty) - XCTAssertNil(state.selectedPinEventID) + XCTAssertNil(state.selectedPinnedEventID) // set to one item state.setPinnedEventContents(["6": "test6", "7": "test7"]) - XCTAssertEqual(state.selectedPinEventID, "7") - XCTAssertEqual(state.selectedPinIndex, 1) + XCTAssertEqual(state.selectedPinnedEventID, "7") + XCTAssertEqual(state.selectedPinnedIndex, 1) XCTAssertEqual(state.displayedMessage.string, "test7") XCTAssertEqual(state.count, 2) XCTAssertFalse(state.isEmpty) diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 07570b025..bcc41d7cd 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -43,6 +43,7 @@ class RoomScreenViewModelTests: XCTestCase { // setup the room proxy actions publisher roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, mediaProvider: MockMediaProvider(), ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, @@ -67,6 +68,7 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1) // setup the loaded pinned events injection in the timeline let pinnedTimelineMock = TimelineProxyMock() @@ -74,8 +76,8 @@ class RoomScreenViewModelTests: XCTestCase { let providerUpdateSubject = PassthroughSubject<([TimelineItemProxy], PaginationState), Never>() pinnedTimelineProviderMock.underlyingUpdatePublisher = providerUpdateSubject.eraseToAnyPublisher() pinnedTimelineMock.timelineProvider = pinnedTimelineProviderMock - pinnedTimelineProviderMock.itemProxies = [.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "1")), - .event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "2"))] + pinnedTimelineProviderMock.itemProxies = [.event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test1")), id: "1")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test2")), id: "2"))] // check if the banner is now in a loaded state and is showing the counter deferred = deferFulfillment(viewModel.context.$viewState) { viewState in @@ -85,18 +87,20 @@ class RoomScreenViewModelTests: XCTestCase { try await deferred.fulfill() XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 2) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1) // check if the banner is updating alongside the timeline deferred = deferFulfillment(viewModel.context.$viewState) { viewState in viewState.pinnedEventsBannerState.count == 3 } - providerUpdateSubject.send(([.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "1")), - .event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "2")), - .event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "3"))], .initial)) + providerUpdateSubject.send(([.event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test1")), id: "1")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test2")), id: "2")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test3")), id: "3"))], .initial)) + try await deferred.fulfill() XCTAssertFalse(viewModel.context.viewState.pinnedEventsBannerState.isLoading) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) - try await deferred.fulfill() - + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1) + // check how the scrolling changes the banner visibility viewModel.timelineHasScrolled(direction: .top) XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner) @@ -105,6 +109,61 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) } + func testPinnedEventsBannerSelection() async throws { + ServiceLocator.shared.settings.pinningEnabled = true + let timelineSubject = PassthroughSubject() + let updateSubject = PassthroughSubject() + let roomProxyMock = JoinedRoomProxyMock(.init()) + // setup a way to inject the mock of the pinned events timeline + let pinnedTimelineMock = TimelineProxyMock() + let pinnedTimelineProviderMock = RoomTimelineProviderMock() + pinnedTimelineMock.timelineProvider = pinnedTimelineProviderMock + pinnedTimelineProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], PaginationState), Never>().eraseToAnyPublisher() + pinnedTimelineProviderMock.itemProxies = [.event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test1")), id: "1")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test2")), id: "2")), + .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test3")), id: "3"))] + roomProxyMock.underlyingPinnedEventsTimeline = pinnedTimelineMock + let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + initialSelectedPinnedEventID: "test1", + mediaProvider: MockMediaProvider(), + ongoingCallRoomIDPublisher: .init(.init(nil)), + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics) + self.viewModel = viewModel + + // check if the banner is now in a loaded state and is showing the counter + var deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + !viewState.pinnedEventsBannerState.isLoading + } + try await deferred.fulfill() + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 3) + XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) + // And that is actually displaying the `initialSelectedPinEventID` which is gthe first one in the list + XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 0) + + // check if the banner scrolls when tapping the previous pin + deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + viewState.pinnedEventsBannerState.selectedPinnedIndex == 2 + } + let deferredAction = deferFulfillment(viewModel.actions) { action in + if case let .focusEvent(eventID) = action { + return eventID == "test1" + } + return false + } + viewModel.context.send(viewAction: .tappedPinnedEventsBanner) + try await deferred.fulfill() + try await deferredAction.fulfill() + + // check if the banner scrolls to the specific selected pin + deferred = deferFulfillment(viewModel.context.$viewState) { viewState in + viewState.pinnedEventsBannerState.selectedPinnedIndex == 1 + } + viewModel.setSelectedPinnedEventID("test2") + try await deferred.fulfill() + } + func testRoomInfoUpdate() async throws { let updateSubject = PassthroughSubject() let roomProxyMock = JoinedRoomProxyMock(.init(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false)) @@ -112,6 +171,7 @@ class RoomScreenViewModelTests: XCTestCase { roomProxyMock.canUserJoinCallUserIDReturnValue = .success(false) roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, mediaProvider: MockMediaProvider(), ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, @@ -148,6 +208,7 @@ class RoomScreenViewModelTests: XCTestCase { let ongoingCallRoomIDSubject = CurrentValueSubject(nil) let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID")) let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, mediaProvider: MockMediaProvider(), ongoingCallRoomIDPublisher: ongoingCallRoomIDSubject.asCurrentValuePublisher(), appMediator: AppMediatorMock.default, diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index 64a55ae0f..b4674c1e5 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -345,6 +345,7 @@ class TimelineViewModelTests: XCTestCase { let viewModel = TimelineViewModel(roomProxy: roomProxy, timelineController: timelineController, + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -369,6 +370,7 @@ class TimelineViewModelTests: XCTestCase { timelineController.timelineItems = [message] let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])), timelineController: timelineController, + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -394,6 +396,7 @@ class TimelineViewModelTests: XCTestCase { roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher() let viewModel = TimelineViewModel(roomProxy: roomProxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -422,6 +425,7 @@ class TimelineViewModelTests: XCTestCase { roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher() let viewModel = TimelineViewModel(roomProxy: roomProxyMock, timelineController: MockRoomTimelineController(), + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), @@ -451,6 +455,7 @@ class TimelineViewModelTests: XCTestCase { TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")), focussedEventID: focussedEventID, timelineController: timelineController, + isPinnedEventsTimeline: false, mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),