Pinned Events Timeline actions and differentiation (#3182)

This commit is contained in:
Mauro
2024-08-22 17:35:44 +02:00
committed by GitHub
parent d54e08e815
commit 4eb1ed5d09
44 changed files with 671 additions and 200 deletions
+4
View File
@@ -696,6 +696,7 @@
9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */; }; 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */; };
9C4EC28A921486B1775D7F8C /* IdentityConfirmedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 307702DD66E7DDCDD9214784 /* IdentityConfirmedScreen.swift */; }; 9C4EC28A921486B1775D7F8C /* IdentityConfirmedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 307702DD66E7DDCDD9214784 /* IdentityConfirmedScreen.swift */; };
9C55746D8F6A3E35CFCF4A7A /* AuthenticationStartLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 598F01EBD0C4CC550C644418 /* AuthenticationStartLogo.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 */; }; 9D2E03DB175A6AB14589076D /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3F7BCCB18C15B30CCA39A9 /* AnalyticsEvents */; };
9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */; }; 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */; };
9D9690D2FD4CD26FF670620F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75EF87651B00A176AB08E97 /* AppDelegate.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 = "<group>"; }; A436057DBEA1A23CA8CB1FD7 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = "<group>"; };
A443FAE2EE820A5790C35C8D /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; }; A443FAE2EE820A5790C35C8D /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
A4A1003A0F7A1DFB47F4E2D0 /* TimelineItemMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMock.swift; sourceTree = "<group>"; }; A4A1003A0F7A1DFB47F4E2D0 /* TimelineItemMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMock.swift; sourceTree = "<group>"; };
A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineFlowCoordinator.swift; sourceTree = "<group>"; };
A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = "<group>"; }; A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = "<group>"; };
A69869844D2B6F5BD9AABF85 /* OIDCConfigurationProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCConfigurationProxy.swift; sourceTree = "<group>"; }; A69869844D2B6F5BD9AABF85 /* OIDCConfigurationProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCConfigurationProxy.swift; sourceTree = "<group>"; };
A6B19D10B102956066AF117B /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; }; A6B19D10B102956066AF117B /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
@@ -3435,6 +3437,7 @@
A9B069D7772DDF6513E0F1B8 /* AuthenticationFlowCoordinator.swift */, A9B069D7772DDF6513E0F1B8 /* AuthenticationFlowCoordinator.swift */,
7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */, 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */,
C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */, C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */,
A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */,
9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */, 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */,
0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */, 0833F51229E166BCA141D004 /* RoomRolesAndPermissionsFlowCoordinator.swift */,
D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */, D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */,
@@ -6503,6 +6506,7 @@
7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */, 7708976CEE6AFB5CFAEFBA68 /* PillTextAttachment.swift in Sources */,
8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */, 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */,
7E2BB42805C59DB57E95610F /* PillView.swift in Sources */, 7E2BB42805C59DB57E95610F /* PillView.swift in Sources */,
9CBB04365408F9D6F46BA3A7 /* PinnedEventsTimelineFlowCoordinator.swift in Sources */,
5D52925FEB1B780C65B0529F /* PinnedEventsTimelineScreen.swift in Sources */, 5D52925FEB1B780C65B0529F /* PinnedEventsTimelineScreen.swift in Sources */,
E184FFAD32342D3D6E2F89AA /* PinnedEventsTimelineScreenCoordinator.swift in Sources */, E184FFAD32342D3D6E2F89AA /* PinnedEventsTimelineScreenCoordinator.swift in Sources */,
FEC03105D1BDE0F49BD7F243 /* PinnedEventsTimelineScreenModels.swift in Sources */, FEC03105D1BDE0F49BD7F243 /* PinnedEventsTimelineScreenModels.swift in Sources */,
@@ -102,6 +102,7 @@
"action_tap_for_options" = "Tap for options"; "action_tap_for_options" = "Tap for options";
"action_try_again" = "Try again"; "action_try_again" = "Try again";
"action_unpin" = "Unpin"; "action_unpin" = "Unpin";
"action_view_in_timeline" = "View in timeline";
"action_view_source" = "View source"; "action_view_source" = "View source";
"action_yes" = "Yes"; "action_yes" = "Yes";
"action.load_more" = "Load more"; "action.load_more" = "Load more";
@@ -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<PinnedEventsTimelineFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<PinnedEventsTimelineFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var cancellables = Set<AnyCancellable>()
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)
}
}
@@ -72,6 +72,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - used to avoid deallocation // periphery:ignore - used to avoid deallocation
private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator? private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator?
// periphery:ignore - used to avoid deallocation // periphery:ignore - used to avoid deallocation
private var pinnedEventsTimelineFlowCoordinator: PinnedEventsTimelineFlowCoordinator?
// periphery:ignore - used to avoid deallocation
private var childRoomFlowCoordinator: RoomFlowCoordinator? private var childRoomFlowCoordinator: RoomFlowCoordinator?
private let stateMachine: StateMachine<State, Event> = .init(state: .initial) private let stateMachine: StateMachine<State, Event> = .init(state: .initial)
@@ -166,7 +168,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else if roomID != roomProxy.id { } else if roomID != roomProxy.id {
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated))
} else { } else {
roomScreenCoordinator?.focusOnEvent(eventID: eventID) roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false))
} }
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias: case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above. break // These are converted to a room ID route one level above.
@@ -194,7 +196,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch room { switch room {
case .joined(let roomProxy): case .joined(let roomProxy):
await storeAndSubscribeToRoomProxy(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: default:
stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated)) stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated))
} }
@@ -270,16 +273,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .roomDetails(isRoot: false) return .roomDetails(isRoot: false)
case (.room, .presentRoomMemberDetails(userID: let userID)): case (.room, .presentRoomMemberDetails(userID: let userID)):
return .roomMemberDetails(userID: userID, fromRoomMembersList: false) return .roomMemberDetails(userID: userID, previousState: .room)
case (.roomMembersList, .presentRoomMemberDetails(userID: let userID)): case (.roomMembersList, .presentRoomMemberDetails(userID: let userID)):
return .roomMemberDetails(userID: userID, fromRoomMembersList: true) return .roomMemberDetails(userID: userID, previousState: .roomMembersList)
case (.roomMemberDetails(_, let fromRoomMembersList), .dismissRoomMemberDetails): case (.roomMemberDetails(_, let previousState), .dismissRoomMemberDetails):
return fromRoomMembersList ? .roomMembersList : .room return previousState
case (.roomMemberDetails(_, fromRoomMembersList: false), .presentUserProfile(let userID)): case (.roomMemberDetails(_, let previousState), .presentUserProfile(let userID)):
return .userProfile(userID: userID) return .userProfile(userID: userID, previousState: previousState)
case (.userProfile, .dismissUserProfile): case (.userProfile(_, let previousState), .dismissUserProfile):
return .room return previousState
case (.roomDetails, .presentInviteUsersScreen): case (.roomDetails, .presentInviteUsersScreen):
return .inviteUsersScreen(fromRoomMembersList: false) return .inviteUsersScreen(fromRoomMembersList: false)
@@ -351,6 +354,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .rolesAndPermissions return .rolesAndPermissions
case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen): case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen):
return .roomDetails(isRoot: false) return .roomDetails(isRoot: false)
case (.roomDetails, .presentRoomMemberDetails(let userID)):
return .roomMemberDetails(userID: userID, previousState: fromState)
// Child flow // Child flow
@@ -375,8 +381,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (_, .dismissJoinRoomScreen, .complete): case (_, .dismissJoinRoomScreen, .complete):
dismissFlow(animated: animated) dismissFlow(animated: animated)
case (_, .presentRoom(let focussedEventID), .room): case (_, .presentRoom(let focussedEvent), .room):
Task { await self.presentRoom(fromState: context.fromState, focussedEventID: focussedEventID, animated: animated) } Task { await self.presentRoom(fromState: context.fromState, focussedEvent: focussedEvent, animated: animated) }
case (_, .dismissFlow, .complete): case (_, .dismissFlow, .complete):
dismissFlow(animated: animated) dismissFlow(animated: animated)
@@ -470,7 +476,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break break
case (.room, .presentPinnedEventsTimeline, .pinnedEventsTimeline): case (.room, .presentPinnedEventsTimeline, .pinnedEventsTimeline):
Task { await self.presentPinnedEventsTimeline() } presentPinnedEventsTimeline()
case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .room): case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .room):
break break
@@ -480,7 +486,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break break
case (.roomDetails, .presentPinnedEventsTimeline, .pinnedEventsTimeline): case (.roomDetails, .presentPinnedEventsTimeline, .pinnedEventsTimeline):
Task { await self.presentPinnedEventsTimeline() } presentPinnedEventsTimeline()
case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .roomDetails): case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .roomDetails):
break break
@@ -493,6 +499,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentRolesAndPermissionsScreen() presentRolesAndPermissionsScreen()
case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen, .roomDetails): case (.rolesAndPermissions, .dismissRolesAndPermissionsScreen, .roomDetails):
rolesAndPermissionsFlowCoordinator = nil rolesAndPermissionsFlowCoordinator = nil
case (.roomDetails, .presentRoomMemberDetails(let userID), .roomMemberDetails):
presentRoomMemberDetails(userID: userID)
case (.roomMemberDetails, .dismissRoomMemberDetails, .roomDetails):
break
case (.roomMemberDetails, .dismissUserProfile, .roomDetails):
break
// Child flow // Child flow
case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild): 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 /// Updates the navigation stack so it displays the timeline for the given room
/// - Parameters: /// - Parameters:
/// - fromState: The state that asked for the room presentation. /// - 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 /// - 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 // 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 // through the correct states before presenting the room
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
@@ -545,8 +559,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// The room is already on the stack, no need to present it again // The room is already on the stack, no need to present it again
// Check if we need to focus on an event // Check if we need to focus on an event
if let focussedEventID { if let focussedEvent {
roomScreenCoordinator?.focusOnEvent(eventID: focussedEventID) roomScreenCoordinator?.focusOnEvent(focussedEvent)
} }
return return
@@ -565,7 +579,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
initialFocussedEventID: focussedEventID, initialFocussedEventID: focussedEvent?.eventID,
timelineItemFactory: timelineItemFactory) timelineItemFactory: timelineItemFactory)
self.timelineController = timelineController self.timelineController = timelineController
@@ -576,7 +590,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory) let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
focussedEventID: focussedEventID, focussedEvent: focussedEvent,
timelineController: timelineController, timelineController: timelineController,
mediaProvider: userSession.mediaProvider, mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(), mediaPlayerProvider: MediaPlayerProvider(),
@@ -660,7 +674,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) { if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) {
await storeAndSubscribeToRoomProxy(roomProxy) 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)) analytics.trackJoinedRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace, activeMemberCount: UInt(roomProxy.activeMembersCount))
} else { } 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) { private func presentPollForm(mode: PollFormMode) {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode)) let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode))
@@ -1345,6 +1323,43 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.start() 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 // MARK: - Child Flow
private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) async { private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) async {
@@ -1405,8 +1420,8 @@ private extension RoomFlowCoordinator {
case notificationSettings case notificationSettings
case globalNotificationSettings case globalNotificationSettings
case roomMembersList case roomMembersList
case roomMemberDetails(userID: String, fromRoomMembersList: Bool) case roomMemberDetails(userID: String, previousState: State)
case userProfile(userID: String) case userProfile(userID: String, previousState: State)
case inviteUsersScreen(fromRoomMembersList: Bool) case inviteUsersScreen(fromRoomMembersList: Bool)
case mediaUploadPicker(source: MediaPickerScreenSource) case mediaUploadPicker(source: MediaPickerScreenSource)
case mediaUploadPreview(fileURL: URL) case mediaUploadPreview(fileURL: URL)
@@ -1434,7 +1449,7 @@ private extension RoomFlowCoordinator {
case presentJoinRoomScreen(via: [String]) case presentJoinRoomScreen(via: [String])
case dismissJoinRoomScreen case dismissJoinRoomScreen
case presentRoom(focussedEventID: String?) case presentRoom(focussedEvent: FocusEvent?)
case dismissFlow case dismissFlow
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String) 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 { private extension Result {
var isFailure: Bool { var isFailure: Bool {
switch self { switch self {
@@ -1518,3 +1527,10 @@ private enum PinnedEventsTimelineSource: Hashable {
case room case room
case details(isRoot: Bool) 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
}
+2
View File
@@ -238,6 +238,8 @@ internal enum L10n {
internal static var actionTryAgain: String { return L10n.tr("Localizable", "action_try_again") } internal static var actionTryAgain: String { return L10n.tr("Localizable", "action_try_again") }
/// Unpin /// Unpin
internal static var actionUnpin: String { return L10n.tr("Localizable", "action_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 /// View source
internal static var actionViewSource: String { return L10n.tr("Localizable", "action_view_source") } internal static var actionViewSource: String { return L10n.tr("Localizable", "action_view_source") }
/// Yes /// Yes
@@ -28,8 +28,10 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
enum PinnedEventsTimelineScreenCoordinatorAction { enum PinnedEventsTimelineScreenCoordinatorAction {
case dismiss case dismiss
case displayUser(userID: String)
// Consider adding CustomStringConvertible conformance if the actions contain PII case presentLocationViewer(geoURI: GeoURI, description: String?)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayRoomScreenWithFocussedPin(eventID: String)
} }
final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
@@ -50,6 +52,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
viewModel = PinnedEventsTimelineScreenViewModel() viewModel = PinnedEventsTimelineScreenViewModel()
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
isPinnedEventsTimeline: true,
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider, mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager, voiceMessageMediaManager: parameters.voiceMessageMediaManager,
@@ -70,6 +73,27 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
} }
} }
.store(in: &cancellables) .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 { func toPresentable() -> AnyView {
@@ -35,8 +35,24 @@ struct PinnedEventsTimelineScreen: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar } .toolbar { toolbar }
.background(.compound.bgCanvasDefault) .background(.compound.bgCanvasDefault)
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.interactiveDismissDisabled() .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 @ViewBuilder
@@ -83,6 +99,7 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
timelineController.timelineItems = [] timelineController.timelineItems = []
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
timelineController: timelineController, timelineController: timelineController,
isPinnedEventsTimeline: true,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -67,7 +67,8 @@ struct RoomPollsHistoryScreen: View {
Text(DateFormatter.pollTimestamp.string(from: pollTimelineItem.timestamp)) Text(DateFormatter.pollTimestamp.string(from: pollTimelineItem.timestamp))
.font(.compound.bodySM) .font(.compound.bodySM)
.foregroundColor(.compound.textSecondary) .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 { switch action {
case .selectOption(let optionID): case .selectOption(let optionID):
guard let pollStartID = pollTimelineItem.item.id.eventID else { return } guard let pollStartID = pollTimelineItem.item.id.eventID else { return }
@@ -21,7 +21,7 @@ import WysiwygComposer
struct RoomScreenCoordinatorParameters { struct RoomScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol let roomProxy: JoinedRoomProxyProtocol
var focussedEventID: String? var focussedEvent: FocusEvent?
let timelineController: RoomTimelineControllerProtocol let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol
@@ -63,7 +63,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
} }
init(parameters: RoomScreenCoordinatorParameters) { init(parameters: RoomScreenCoordinatorParameters) {
var selectedPinnedEventID: String?
if let focussedEvent = parameters.focussedEvent {
selectedPinnedEventID = focussedEvent.shouldSetPin ? focussedEvent.eventID : nil
}
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
initialSelectedPinnedEventID: selectedPinnedEventID,
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher, ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher,
appMediator: parameters.appMediator, appMediator: parameters.appMediator,
@@ -71,8 +77,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
analyticsService: ServiceLocator.shared.analytics) analyticsService: ServiceLocator.shared.analytics)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID, focussedEventID: parameters.focussedEvent?.eventID,
timelineController: parameters.timelineController, timelineController: parameters.timelineController,
isPinnedEventsTimeline: false,
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider, mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager, voiceMessageMediaManager: parameters.voiceMessageMediaManager,
@@ -133,6 +140,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
composerViewModel.process(timelineAction: action) composerViewModel.process(timelineAction: action)
case .hasScrolled(direction: let direction): case .hasScrolled(direction: let direction):
roomViewModel.timelineHasScrolled(direction: direction) roomViewModel.timelineHasScrolled(direction: direction)
case .viewInRoomTimeline:
fatalError("The action: \(action) should not be sent to this coordinator")
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -151,7 +160,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
switch actions { switch actions {
case .focusEvent(eventID: let eventID): case .focusEvent(eventID: let eventID):
focusOnEvent(eventID: eventID) focusOnEvent(FocusEvent(eventID: eventID, shouldSetPin: false))
case .displayPinnedEventsTimeline: case .displayPinnedEventsTimeline:
actionsSubject.send(.presentPinnedEventsTimeline) actionsSubject.send(.presentPinnedEventsTimeline)
case .displayRoomDetails: case .displayRoomDetails:
@@ -168,7 +177,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
composerViewModel.loadDraft() 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) } Task { await timelineViewModel.focusOnEvent(eventID: eventID) }
} }
@@ -75,10 +75,10 @@ enum PinnedEventsBannerState: Equatable {
} }
} }
var selectedPinEventID: String? { var selectedPinnedEventID: String? {
switch self { switch self {
case .loaded(let state): case .loaded(let state):
return state.selectedPinEventID return state.selectedPinnedEventID
default: default:
return nil return nil
} }
@@ -93,10 +93,10 @@ enum PinnedEventsBannerState: Equatable {
} }
} }
var selectedPinIndex: Int { var selectedPinnedIndex: Int {
switch self { switch self {
case .loaded(let state): case .loaded(let state):
return state.selectedPinIndex return state.selectedPinnedIndex
case .loading(let numbersOfEvents): case .loading(let numbersOfEvents):
// We always want the index to be the last one when loading, since is the default one. // We always want the index to be the last one when loading, since is the default one.
return numbersOfEvents - 1 return numbersOfEvents - 1
@@ -108,12 +108,12 @@ enum PinnedEventsBannerState: Equatable {
case .loading: case .loading:
return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription) return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription)
case .loaded(let state): case .loaded(let state):
return state.selectedPinContent return state.selectedPinnedContent
} }
} }
var bannerIndicatorDescription: AttributedString { var bannerIndicatorDescription: AttributedString {
let index = selectedPinIndex + 1 let index = selectedPinnedIndex + 1
let boldPlaceholder = "{bold}" let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder)) var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count)) var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count))
@@ -136,10 +136,72 @@ enum PinnedEventsBannerState: Equatable {
switch self { switch self {
case .loading: case .loading:
// The default selected event should always be the last one. // 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): case .loaded(var state):
state.pinnedEventContents = pinnedEventContents state.pinnedEventContents = pinnedEventContents
self = .loaded(state: state) 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<String, AttributedString> = [:] {
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]
}
}
} }
@@ -27,6 +27,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
private let analyticsService: AnalyticsService private let analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder private let pinnedEventStringBuilder: RoomEventStringBuilder
private var initialSelectedPinnedEventID: String?
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init() private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
@@ -52,6 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
init(roomProxy: JoinedRoomProxyProtocol, init(roomProxy: JoinedRoomProxyProtocol,
initialSelectedPinnedEventID: String?,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>, ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
appMediator: AppMediatorProtocol, appMediator: AppMediatorProtocol,
@@ -61,6 +63,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.appMediator = appMediator self.appMediator = appMediator
self.appSettings = appSettings self.appSettings = appSettings
self.analyticsService = analyticsService self.analyticsService = analyticsService
self.initialSelectedPinnedEventID = initialSelectedPinnedEventID
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
super.init(initialViewState: .init(roomTitle: roomProxy.roomTitle, super.init(initialViewState: .init(roomTitle: roomProxy.roomTitle,
@@ -69,13 +72,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
bindings: .init()), bindings: .init()),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
Task {
await handleRoomInfoUpdate()
}
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher) setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
} }
override func process(viewAction: RoomScreenViewAction) { override func process(viewAction: RoomScreenViewAction) {
switch viewAction { switch viewAction {
case .tappedPinnedEventsBanner: case .tappedPinnedEventsBanner:
if let eventID = state.pinnedEventsBannerState.selectedPinEventID { if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID {
actionsSubject.send(.focusEvent(eventID: eventID)) actionsSubject.send(.focusEvent(eventID: eventID))
} }
state.pinnedEventsBannerState.previousPin() state.pinnedEventsBannerState.previousPin()
@@ -94,6 +101,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.lastScrollDirection = direction state.lastScrollDirection = direction
} }
func setSelectedPinnedEventID(_ eventID: String) {
state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID)
}
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) { private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
let roomInfoSubscription = roomProxy let roomInfoSubscription = roomProxy
.actionsPublisher .actionsPublisher
@@ -110,13 +121,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.store(in: &cancellables) .store(in: &cancellables)
Task { [weak self] in 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 { for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
} }
await self?.handleRoomInfoUpdate() await self?.handleRoomInfoUpdate()
} }
} }
@@ -159,6 +168,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents) 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 { private func handleRoomInfoUpdate() async {
@@ -194,6 +209,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
extension RoomScreenViewModel { extension RoomScreenViewModel {
static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel { static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel {
RoomScreenViewModel(roomProxy: roomProxyMock, RoomScreenViewModel(roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
ongoingCallRoomIDPublisher: .init(.init(nil)), ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
@@ -22,4 +22,5 @@ protocol RoomScreenViewModelProtocol {
var context: RoomScreenViewModel.Context { get } var context: RoomScreenViewModel.Context { get }
func timelineHasScrolled(direction: ScrollDirection) func timelineHasScrolled(direction: ScrollDirection)
func setSelectedPinnedEventID(_ eventID: String)
} }
@@ -38,7 +38,7 @@ struct PinnedItemsBannerView: View {
Button { onMainButtonTap() } label: { Button { onMainButtonTap() } label: {
HStack(spacing: 0) { HStack(spacing: 0) {
HStack(spacing: 10) { HStack(spacing: 10) {
PinnedItemsIndicatorView(pinIndex: state.selectedPinIndex, pinsCount: state.count) PinnedItemsIndicatorView(pinIndex: state.selectedPinnedIndex, pinsCount: state.count)
.accessibilityHidden(true) .accessibilityHidden(true)
CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD) CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD)
.foregroundColor(Color.compound.iconSecondaryAlpha) .foregroundColor(Color.compound.iconSecondaryAlpha)
@@ -98,16 +98,16 @@ struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Content", PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Content",
"2": "2", "2": "2",
"3": "3"], "3": "3"],
selectedPinEventID: "1")), selectedPinnedEventID: "1")),
onMainButtonTap: { }, onMainButtonTap: { },
onViewAllButtonTap: { }) onViewAllButtonTap: { })
PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Very very very very long content here", PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Very very very very long content here",
"2": "2"], "2": "2"],
selectedPinEventID: "1")), selectedPinnedEventID: "1")),
onMainButtonTap: { }, onMainButtonTap: { },
onViewAllButtonTap: { }) onViewAllButtonTap: { })
PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": attributedContent], PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": attributedContent],
selectedPinEventID: "1")), selectedPinnedEventID: "1")),
onMainButtonTap: { }, onMainButtonTap: { },
onViewAllButtonTap: { }) onViewAllButtonTap: { })
PinnedItemsBannerView(state: .loading(numbersOfEvents: 5), PinnedItemsBannerView(state: .loading(numbersOfEvents: 5),
@@ -75,7 +75,9 @@ struct RoomScreen: View {
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin, canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom, isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled).makeActions() isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline)
.makeActions()
if let actions { if let actions {
TimelineItemMenu(item: info.item, actions: actions) TimelineItemMenu(item: info.item, actions: actions)
.environmentObject(timelineContext) .environmentObject(timelineContext)
@@ -218,6 +220,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -31,6 +31,8 @@ enum TimelineInteractionHandlerAction {
case displayAudioRecorderPermissionError case displayAudioRecorderPermissionError
case displayErrorToast(String) case displayErrorToast(String)
case viewInRoomTimeline(eventID: String)
} }
@MainActor @MainActor
@@ -176,6 +178,9 @@ class TimelineInteractionHandler {
case .unpin: case .unpin:
guard let eventID = itemID.eventID else { return } guard let eventID = itemID.eventID else { return }
Task { await timelineController.unpin(eventID: eventID) } Task { await timelineController.unpin(eventID: eventID) }
case .viewInRoomTimeline:
guard let eventID = itemID.eventID else { return }
actionsSubject.send(.viewInRoomTimeline(eventID: eventID))
} }
if action.switchToDefaultComposer { if action.switchToDefaultComposer {
@@ -32,6 +32,7 @@ enum TimelineViewModelAction {
case displayLocation(body: String, geoURI: GeoURI, description: String?) case displayLocation(body: String, geoURI: GeoURI, description: String?)
case composer(action: TimelineComposerAction) case composer(action: TimelineComposerAction)
case hasScrolled(direction: ScrollDirection) case hasScrolled(direction: ScrollDirection)
case viewInRoomTimeline(eventID: String)
} }
enum TimelineViewPollAction { enum TimelineViewPollAction {
@@ -89,6 +90,7 @@ enum TimelineComposerAction {
} }
struct TimelineViewState: BindableState { struct TimelineViewState: BindableState {
let isPinnedEventsTimeline: Bool
var roomID: String var roomID: String
var members: [String: RoomMemberState] = [:] var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = [] var typingMembers: [String] = []
@@ -222,52 +224,3 @@ enum ScrollDirection: Equatable {
case top case top
case bottom case bottom
} }
struct PinnedEventsState: Equatable {
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
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]
}
}
}
@@ -52,6 +52,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
init(roomProxy: JoinedRoomProxyProtocol, init(roomProxy: JoinedRoomProxyProtocol,
focussedEventID: String? = nil, focussedEventID: String? = nil,
timelineController: RoomTimelineControllerProtocol, timelineController: RoomTimelineControllerProtocol,
isPinnedEventsTimeline: Bool,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol, mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
@@ -80,7 +81,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: appSettings, appSettings: appSettings,
analyticsService: analyticsService) analyticsService: analyticsService)
super.init(initialViewState: TimelineViewState(roomID: roomProxy.id, super.init(initialViewState: TimelineViewState(isPinnedEventsTimeline: isPinnedEventsTimeline,
roomID: roomProxy.id,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID, ownUserID: roomProxy.ownUserID,
@@ -93,6 +95,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
showFocusLoadingIndicator() showFocusLoadingIndicator()
} }
Task {
await updatePinnedEventIDs()
}
setupSubscriptions() setupSubscriptions()
setupDirectRoomSubscriptionsIfNeeded() setupDirectRoomSubscriptionsIfNeeded()
@@ -375,9 +381,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
.actionsPublisher .actionsPublisher
.filter { $0 == .roomInfoUpdate } .filter { $0 == .roomInfoUpdate }
Task { [weak self] in 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 { for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
@@ -429,6 +432,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} }
case .showDebugInfo(let debugInfo): case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo state.bindings.debugInfo = debugInfo
case .viewInRoomTimeline(let eventID):
actionsSubject.send(.viewInRoomTimeline(eventID: eventID))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -641,13 +646,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} else { } else {
for (index, item) in itemGroup.enumerated() { for (index, item) in itemGroup.enumerated() {
if index == 0 { if index == 0 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .first),
forKey: item.id.timelineID) forKey: item.id.timelineID)
} else if index == itemGroup.count - 1 { } 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) forKey: item.id.timelineID)
} else { } else {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .middle),
forKey: item.id.timelineID) forKey: item.id.timelineID)
} }
} }
@@ -822,6 +827,7 @@ extension TimelineViewModel {
static let mock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), static let mock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
focussedEventID: nil, focussedEventID: nil,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -829,6 +835,18 @@ extension TimelineViewModel {
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings, appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics) 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 { private struct TimelineContextKey: EnvironmentKey {
@@ -63,6 +63,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
case endPoll(pollStartID: String) case endPoll(pollStartID: String)
case pin case pin
case unpin case unpin
case viewInRoomTimeline
var id: Self { self } 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. /// The action's label.
@ViewBuilder @ViewBuilder
var label: some View { var label: some View {
@@ -139,6 +149,8 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
Label(L10n.actionPin, icon: \.pin) Label(L10n.actionPin, icon: \.pin)
case .unpin: case .unpin:
Label(L10n.actionUnpin, icon: \.unpin) Label(L10n.actionUnpin, icon: \.unpin)
case .viewInRoomTimeline:
Label(L10n.actionViewInTimeline, icon: \.visibilityOn)
} }
} }
} }
@@ -24,6 +24,7 @@ struct TimelineItemMenuActionProvider {
let pinnedEventIDs: Set<String> let pinnedEventIDs: Set<String>
let isDM: Bool let isDM: Bool
let isViewSourceEnabled: Bool let isViewSourceEnabled: Bool
let isPinnedEventsTimeline: Bool
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity
func makeActions() -> TimelineItemMenuActions? { func makeActions() -> TimelineItemMenuActions? {
@@ -102,8 +103,13 @@ struct TimelineItemMenuActionProvider {
if item.isRedacted { if item.isRedacted {
actions = actions.filter(\.canAppearInRedacted) 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 { private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool {
@@ -23,21 +23,48 @@ enum PollViewAction {
case end 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 { struct PollView: View {
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
let poll: Poll let poll: Poll
let editable: Bool let state: PollViewState
let actionHandler: (PollViewAction) -> Void let actionHandler: (PollViewAction) -> Void
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { if state.isPreview {
questionView questionView
optionsView } else {
summaryView VStack(alignment: .leading, spacing: 16) {
toolbarView questionView
optionsView
summaryView
toolbarView
}
.frame(maxWidth: 450)
} }
.frame(maxWidth: 450)
} }
// MARK: - Private // MARK: - Private
@@ -88,7 +115,7 @@ struct PollView: View {
Button { Button {
toolbarAction() toolbarAction()
} label: { } label: {
Text(editable ? L10n.actionEditPoll : L10n.actionEndPoll) Text(state.isEditable ? L10n.actionEditPoll : L10n.actionEndPoll)
.lineLimit(2, reservesSpace: false) .lineLimit(2, reservesSpace: false)
.font(.compound.bodyLGSemibold) .font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textOnSolidPrimary) .foregroundColor(.compound.textOnSolidPrimary)
@@ -105,7 +132,7 @@ struct PollView: View {
} }
private func toolbarAction() { private func toolbarAction() {
if editable { if state.isEditable {
actionHandler(.edit) actionHandler(.edit)
} else { } else {
actionHandler(.end) actionHandler(.end)
@@ -146,28 +173,32 @@ private extension Poll {
struct PollView_Previews: PreviewProvider, TestablePreview { struct PollView_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
PollView(poll: .disclosed(), editable: false) { _ in } PollView(poll: .disclosed(), state: .full(isEditable: false)) { _ in }
.padding() .padding()
.previewDisplayName("Disclosed") .previewDisplayName("Disclosed")
PollView(poll: .undisclosed(), editable: false) { _ in } PollView(poll: .undisclosed(), state: .full(isEditable: false)) { _ in }
.padding() .padding()
.previewDisplayName("Undisclosed") .previewDisplayName("Undisclosed")
PollView(poll: .endedDisclosed, editable: false) { _ in } PollView(poll: .endedDisclosed, state: .full(isEditable: false)) { _ in }
.padding() .padding()
.previewDisplayName("Ended, Disclosed") .previewDisplayName("Ended, Disclosed")
PollView(poll: .endedUndisclosed, editable: false) { _ in } PollView(poll: .endedUndisclosed, state: .full(isEditable: false)) { _ in }
.padding() .padding()
.previewDisplayName("Ended, Undisclosed") .previewDisplayName("Ended, Undisclosed")
PollView(poll: .disclosed(createdByAccountOwner: true), editable: true) { _ in } PollView(poll: .disclosed(createdByAccountOwner: true), state: .full(isEditable: true)) { _ in }
.padding() .padding()
.previewDisplayName("Creator, disclosed") .previewDisplayName("Creator, disclosed")
PollView(poll: .emptyDisclosed, editable: true) { _ in } PollView(poll: .emptyDisclosed, state: .full(isEditable: true)) { _ in }
.padding() .padding()
.previewDisplayName("Creator, no votes") .previewDisplayName("Creator, no votes")
PollView(poll: .emptyDisclosed, state: .preview) { _ in }
.padding()
.previewDisplayName("Preview")
} }
} }
@@ -54,6 +54,7 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Room", members: members)) let roomProxyMock = JoinedRoomProxyMock(.init(name: "Room", members: members))
let mock = TimelineViewModel(roomProxy: roomProxyMock, let mock = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -147,7 +147,8 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
canCurrentUserPin: context.viewState.canCurrentUserPin, canCurrentUserPin: context.viewState.canCurrentUserPin,
pinnedEventIDs: context.viewState.pinnedEventIDs, pinnedEventIDs: context.viewState.pinnedEventIDs,
isDM: context.viewState.isEncryptedOneToOneRoom, isDM: context.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: context.viewState.isViewSourceEnabled) isViewSourceEnabled: context.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: context.viewState.isPinnedEventsTimeline)
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action)) context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
} }
@@ -32,7 +32,10 @@ struct TimelineItemStatusView: View {
@ViewBuilder @ViewBuilder
private var mainContent: some View { 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 readReceipts
} else { } else {
deliveryStatusBadge deliveryStatusBadge
@@ -92,6 +92,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Test", members: members)), static let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Test", members: members)),
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -99,6 +99,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
focussedEventID: focussedEventID, focussedEventID: focussedEventID,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -20,9 +20,18 @@ struct PollRoomTimelineView: View {
let timelineItem: PollRoomTimelineItem let timelineItem: PollRoomTimelineItem
@EnvironmentObject private var context: TimelineViewModel.Context @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 { var body: some View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
PollView(poll: poll, editable: timelineItem.isEditable) { action in PollView(poll: poll,
state: state) { action in
switch action { switch action {
case .selectOption(let optionID): case .selectOption(let optionID):
guard let eventID, let option = poll.options.first(where: { $0.id == optionID }), !option.isSelected else { return } 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 { struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock static let viewModel = TimelineViewModel.mock
static let pinnedEventsTimelineViewModel = TimelineViewModel.pinnedEventsTimelineMock
static var previews: some View { static var previews: some View {
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false)) PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false))
@@ -76,5 +86,9 @@ struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true)) PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true))
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
.previewDisplayName("Creator, no votes, Bubble") .previewDisplayName("Creator, no votes, Bubble")
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isEditable: true))
.environmentObject(pinnedEventsTimelineViewModel.context)
.previewDisplayName("Preview")
} }
} }
@@ -84,6 +84,7 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview {
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -44,7 +44,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
} }
do { 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() await timeline.subscribeForUpdates()
innerPinnedEventsTimeline = timeline innerPinnedEventsTimeline = timeline
return timeline return timeline
@@ -24,6 +24,10 @@ struct GeoURI: Hashable {
let latitude: Double let latitude: Double
let longitude: Double let longitude: Double
let uncertainty: Double? let uncertainty: Double?
var bodyMessage: String {
"Location was shared at \(string)"
}
// MARK: - Setup // MARK: - Setup
@@ -25,6 +25,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineItemFactory: RoomTimelineItemFactoryProtocol private let timelineItemFactory: RoomTimelineItemFactoryProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
private let serialDispatchQueue: DispatchQueue private let serialDispatchQueue: DispatchQueue
private let shouldHideStart: Bool
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>() let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
@@ -44,12 +45,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
init(roomProxy: JoinedRoomProxyProtocol, init(roomProxy: JoinedRoomProxyProtocol,
timelineProxy: TimelineProxyProtocol, timelineProxy: TimelineProxyProtocol,
initialFocussedEventID: String?, initialFocussedEventID: String?,
shouldHideStart: Bool,
timelineItemFactory: RoomTimelineItemFactoryProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol,
appSettings: AppSettings) { appSettings: AppSettings) {
self.roomProxy = roomProxy self.roomProxy = roomProxy
liveTimelineProvider = timelineProxy.timelineProvider liveTimelineProvider = timelineProxy.timelineProvider
self.timelineItemFactory = timelineItemFactory self.timelineItemFactory = timelineItemFactory
self.appSettings = appSettings self.appSettings = appSettings
self.shouldHideStart = shouldHideStart
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility) serialDispatchQueue = DispatchQueue(label: "io.element.elementx.roomtimelineprovider", qos: .utility)
activeTimeline = timelineProxy activeTimeline = timelineProxy
@@ -364,7 +367,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
// Check if we need to add anything to the top of the timeline. // Check if we need to add anything to the top of the timeline.
switch paginationState.backward { switch paginationState.backward {
case .timelineEndReached: case .timelineEndReached:
if !roomProxy.isEncryptedOneToOneRoom { if !shouldHideStart, !roomProxy.isEncryptedOneToOneRoom {
let timelineStart = TimelineStartRoomTimelineItem(name: roomProxy.name) let timelineStart = TimelineStartRoomTimelineItem(name: roomProxy.name)
newTimelineItems.insert(timelineStart, at: 0) newTimelineItems.insert(timelineStart, at: 0)
} }
@@ -23,6 +23,7 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
RoomTimelineController(roomProxy: roomProxy, RoomTimelineController(roomProxy: roomProxy,
timelineProxy: roomProxy.timeline, timelineProxy: roomProxy.timeline,
initialFocussedEventID: initialFocussedEventID, initialFocussedEventID: initialFocussedEventID,
shouldHideStart: false,
timelineItemFactory: timelineItemFactory, timelineItemFactory: timelineItemFactory,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings)
} }
@@ -35,6 +36,7 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
return RoomTimelineController(roomProxy: roomProxy, return RoomTimelineController(roomProxy: roomProxy,
timelineProxy: pinnedEventsTimeline, timelineProxy: pinnedEventsTimeline,
initialFocussedEventID: nil, initialFocussedEventID: nil,
shouldHideStart: true,
timelineItemFactory: timelineItemFactory, timelineItemFactory: timelineItemFactory,
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings)
} }
@@ -446,7 +446,7 @@ class MockScreen: Identifiable {
let client = try UITestsSignalling.Client(mode: .app) let client = try UITestsSignalling.Client(mode: .app)
client.signals.sink { [weak self] signal in client.signals.sink { [weak self] signal in
guard case .timeline(.focusOnEvent(let eventID)) = signal else { return } guard case .timeline(.focusOnEvent(let eventID)) = signal else { return }
coordinator.focusOnEvent(eventID: eventID) coordinator.focusOnEvent(.init(eventID: eventID, shouldSetPin: false))
try? client.send(.success) try? client.send(.success)
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -649,6 +649,7 @@ class MockScreen: Identifiable {
let timelineController = RoomTimelineController(roomProxy: roomProxy, let timelineController = RoomTimelineController(roomProxy: roomProxy,
timelineProxy: roomProxy.timeline, timelineProxy: roomProxy.timeline,
initialFocussedEventID: nil, initialFocussedEventID: nil,
shouldHideStart: false,
timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org", timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org",
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")), stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")),
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:901024e6cd60483e2af6001b87f72cb98fc347cd53c82097f232f602994ea5f7
size 79270
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:901024e6cd60483e2af6001b87f72cb98fc347cd53c82097f232f602994ea5f7
size 79270
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7dd720bde5e4e8496a48d89e624017904e1c7861187698488ddd53e211963171
size 39185
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7dd720bde5e4e8496a48d89e624017904e1c7861187698488ddd53e211963171
size 39185
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88c3bb5b185ac4c183744884d63de94a2554872e317e09b9454fc895d2aa2035
size 74948
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88c3bb5b185ac4c183744884d63de94a2554872e317e09b9454fc895d2aa2035
size 74948
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76c12d5338c4d8832169ac2c06d3ef37b91e66a02b5e57b61a14d6bd0b546def
size 33674
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76c12d5338c4d8832169ac2c06d3ef37b91e66a02b5e57b61a14d6bd0b546def
size 33674
+3
View File
@@ -28,6 +28,7 @@ class PillContextTests: XCTestCase {
proxyMock.membersPublisher = subject.asCurrentValuePublisher() proxyMock.membersPublisher = subject.asCurrentValuePublisher()
let mock = TimelineViewModel(roomProxy: proxyMock, let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -56,6 +57,7 @@ class PillContextTests: XCTestCase {
proxyMock.membersPublisher = subject.asCurrentValuePublisher() proxyMock.membersPublisher = subject.asCurrentValuePublisher()
let mock = TimelineViewModel(roomProxy: proxyMock, let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -77,6 +79,7 @@ class PillContextTests: XCTestCase {
mockController.roomProxy = proxyMock mockController.roomProxy = proxyMock
let mock = TimelineViewModel(roomProxy: proxyMock, let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController, timelineController: mockController,
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -37,9 +37,9 @@ class PinnedEventsBannerStateTests: XCTestCase {
XCTAssertTrue(state.isLoading) XCTAssertTrue(state.isLoading)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
XCTAssertNil(state.selectedPinEventID) XCTAssertNil(state.selectedPinnedEventID)
XCTAssertEqual(state.displayedMessage.string, L10n.screenRoomPinnedBannerLoadingDescription) XCTAssertEqual(state.displayedMessage.string, L10n.screenRoomPinnedBannerLoadingDescription)
XCTAssertEqual(state.selectedPinIndex, 4) XCTAssertEqual(state.selectedPinnedIndex, 4)
XCTAssertEqual(state.count, 5) XCTAssertEqual(state.count, 5)
XCTAssertEqual(state.bannerIndicatorDescription.string, L10n.screenRoomPinnedBannerIndicatorDescription(L10n.screenRoomPinnedBannerIndicator(5, 5))) XCTAssertEqual(state.bannerIndicatorDescription.string, L10n.screenRoomPinnedBannerIndicatorDescription(L10n.screenRoomPinnedBannerIndicator(5, 5)))
} }
@@ -48,42 +48,42 @@ class PinnedEventsBannerStateTests: XCTestCase {
var state = PinnedEventsBannerState.loading(numbersOfEvents: 2) var state = PinnedEventsBannerState.loading(numbersOfEvents: 2)
XCTAssertTrue(state.isLoading) XCTAssertTrue(state.isLoading)
state.setPinnedEventContents(["1": "test1", "2": "test2"]) 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) XCTAssertFalse(state.isLoading)
} }
func testLoaded() { 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.isLoading)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
XCTAssertEqual(state.selectedPinEventID, "2") XCTAssertEqual(state.selectedPinnedEventID, "2")
XCTAssertEqual(state.displayedMessage.string, "test2") XCTAssertEqual(state.displayedMessage.string, "test2")
XCTAssertEqual(state.selectedPinIndex, 1) XCTAssertEqual(state.selectedPinnedIndex, 1)
XCTAssertEqual(state.count, 2) XCTAssertEqual(state.count, 2)
XCTAssertEqual(state.bannerIndicatorDescription.string, L10n.screenRoomPinnedBannerIndicatorDescription(L10n.screenRoomPinnedBannerIndicator(2, 2))) XCTAssertEqual(state.bannerIndicatorDescription.string, L10n.screenRoomPinnedBannerIndicatorDescription(L10n.screenRoomPinnedBannerIndicator(2, 2)))
} }
func testPreviousPin() { func testPreviousPin() {
var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3"], selectedPinEventID: "1")) var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3"], selectedPinnedEventID: "1"))
XCTAssertEqual(state.selectedPinEventID, "1") XCTAssertEqual(state.selectedPinnedEventID, "1")
XCTAssertEqual(state.selectedPinIndex, 0) XCTAssertEqual(state.selectedPinnedIndex, 0)
XCTAssertEqual(state.displayedMessage.string, "test1") XCTAssertEqual(state.displayedMessage.string, "test1")
state.previousPin() state.previousPin()
XCTAssertEqual(state.selectedPinEventID, "3") XCTAssertEqual(state.selectedPinnedEventID, "3")
XCTAssertEqual(state.selectedPinIndex, 2) XCTAssertEqual(state.selectedPinnedIndex, 2)
XCTAssertEqual(state.displayedMessage.string, "test3") XCTAssertEqual(state.displayedMessage.string, "test3")
state.previousPin() state.previousPin()
XCTAssertEqual(state.selectedPinEventID, "2") XCTAssertEqual(state.selectedPinnedEventID, "2")
XCTAssertEqual(state.selectedPinIndex, 1) XCTAssertEqual(state.selectedPinnedIndex, 1)
XCTAssertEqual(state.displayedMessage.string, "test2") XCTAssertEqual(state.displayedMessage.string, "test2")
} }
func testSetContent() { func testSetContent() {
var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3", "4": "test4"], selectedPinEventID: "2")) var state = PinnedEventsBannerState.loaded(state: .init(pinnedEventContents: ["1": "test1", "2": "test2", "3": "test3", "4": "test4"], selectedPinnedEventID: "2"))
XCTAssertEqual(state.selectedPinEventID, "2") XCTAssertEqual(state.selectedPinnedEventID, "2")
XCTAssertEqual(state.selectedPinIndex, 1) XCTAssertEqual(state.selectedPinnedIndex, 1)
XCTAssertEqual(state.displayedMessage.string, "test2") XCTAssertEqual(state.displayedMessage.string, "test2")
XCTAssertEqual(state.count, 4) XCTAssertEqual(state.count, 4)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
@@ -91,8 +91,8 @@ class PinnedEventsBannerStateTests: XCTestCase {
// let's remove the selected item // let's remove the selected item
state.setPinnedEventContents(["1": "test1", "3": "test3", "4": "test4"]) state.setPinnedEventContents(["1": "test1", "3": "test3", "4": "test4"])
// new selected item is the new latest // new selected item is the new latest
XCTAssertEqual(state.selectedPinEventID, "4") XCTAssertEqual(state.selectedPinnedEventID, "4")
XCTAssertEqual(state.selectedPinIndex, 2) XCTAssertEqual(state.selectedPinnedIndex, 2)
XCTAssertEqual(state.displayedMessage.string, "test4") XCTAssertEqual(state.displayedMessage.string, "test4")
XCTAssertEqual(state.count, 3) XCTAssertEqual(state.count, 3)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
@@ -100,9 +100,9 @@ class PinnedEventsBannerStateTests: XCTestCase {
// let's add a new item at the top // let's add a new item at the top
state.setPinnedEventContents(["0": "test0", "1": "test1", "3": "test3", "4": "test4"]) state.setPinnedEventContents(["0": "test0", "1": "test1", "3": "test3", "4": "test4"])
// selected item doesn't change // selected item doesn't change
XCTAssertEqual(state.selectedPinEventID, "4") XCTAssertEqual(state.selectedPinnedEventID, "4")
// but the index is updated // but the index is updated
XCTAssertEqual(state.selectedPinIndex, 3) XCTAssertEqual(state.selectedPinnedIndex, 3)
XCTAssertEqual(state.displayedMessage.string, "test4") XCTAssertEqual(state.displayedMessage.string, "test4")
XCTAssertEqual(state.count, 4) XCTAssertEqual(state.count, 4)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
@@ -110,9 +110,9 @@ class PinnedEventsBannerStateTests: XCTestCase {
// let's add a new item at the bottom // let's add a new item at the bottom
state.setPinnedEventContents(["0": "test0", "1": "test1", "3": "test3", "4": "test4", "5": "test5"]) state.setPinnedEventContents(["0": "test0", "1": "test1", "3": "test3", "4": "test4", "5": "test5"])
// selected item doesn't change // selected item doesn't change
XCTAssertEqual(state.selectedPinEventID, "4") XCTAssertEqual(state.selectedPinnedEventID, "4")
// and index stays the same // and index stays the same
XCTAssertEqual(state.selectedPinIndex, 3) XCTAssertEqual(state.selectedPinnedIndex, 3)
XCTAssertEqual(state.displayedMessage.string, "test4") XCTAssertEqual(state.displayedMessage.string, "test4")
XCTAssertEqual(state.count, 5) XCTAssertEqual(state.count, 5)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
@@ -120,12 +120,12 @@ class PinnedEventsBannerStateTests: XCTestCase {
// set to tempty // set to tempty
state.setPinnedEventContents([:]) state.setPinnedEventContents([:])
XCTAssertTrue(state.isEmpty) XCTAssertTrue(state.isEmpty)
XCTAssertNil(state.selectedPinEventID) XCTAssertNil(state.selectedPinnedEventID)
// set to one item // set to one item
state.setPinnedEventContents(["6": "test6", "7": "test7"]) state.setPinnedEventContents(["6": "test6", "7": "test7"])
XCTAssertEqual(state.selectedPinEventID, "7") XCTAssertEqual(state.selectedPinnedEventID, "7")
XCTAssertEqual(state.selectedPinIndex, 1) XCTAssertEqual(state.selectedPinnedIndex, 1)
XCTAssertEqual(state.displayedMessage.string, "test7") XCTAssertEqual(state.displayedMessage.string, "test7")
XCTAssertEqual(state.count, 2) XCTAssertEqual(state.count, 2)
XCTAssertFalse(state.isEmpty) XCTAssertFalse(state.isEmpty)
@@ -43,6 +43,7 @@ class RoomScreenViewModelTests: XCTestCase {
// setup the room proxy actions publisher // setup the room proxy actions publisher
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
ongoingCallRoomIDPublisher: .init(.init(nil)), ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
@@ -67,6 +68,7 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill() try await deferred.fulfill()
XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading) XCTAssertTrue(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1)
// setup the loaded pinned events injection in the timeline // setup the loaded pinned events injection in the timeline
let pinnedTimelineMock = TimelineProxyMock() let pinnedTimelineMock = TimelineProxyMock()
@@ -74,8 +76,8 @@ class RoomScreenViewModelTests: XCTestCase {
let providerUpdateSubject = PassthroughSubject<([TimelineItemProxy], PaginationState), Never>() let providerUpdateSubject = PassthroughSubject<([TimelineItemProxy], PaginationState), Never>()
pinnedTimelineProviderMock.underlyingUpdatePublisher = providerUpdateSubject.eraseToAnyPublisher() pinnedTimelineProviderMock.underlyingUpdatePublisher = providerUpdateSubject.eraseToAnyPublisher()
pinnedTimelineMock.timelineProvider = pinnedTimelineProviderMock pinnedTimelineMock.timelineProvider = pinnedTimelineProviderMock
pinnedTimelineProviderMock.itemProxies = [.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "1")), pinnedTimelineProviderMock.itemProxies = [.event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test1")), id: "1")),
.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "2"))] .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 // check if the banner is now in a loaded state and is showing the counter
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
@@ -85,18 +87,20 @@ class RoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill() try await deferred.fulfill()
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 2) XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.count, 2)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1)
// check if the banner is updating alongside the timeline // check if the banner is updating alongside the timeline
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
viewState.pinnedEventsBannerState.count == 3 viewState.pinnedEventsBannerState.count == 3
} }
providerUpdateSubject.send(([.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "1")), providerUpdateSubject.send(([.event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test1")), id: "1")),
.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "2")), .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test2")), id: "2")),
.event(.init(item: EventTimelineItemSDKMock(configuration: .init()), id: "3"))], .initial)) .event(.init(item: EventTimelineItemSDKMock(configuration: .init(eventID: "test3")), id: "3"))], .initial))
try await deferred.fulfill()
XCTAssertFalse(viewModel.context.viewState.pinnedEventsBannerState.isLoading) XCTAssertFalse(viewModel.context.viewState.pinnedEventsBannerState.isLoading)
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
try await deferred.fulfill() XCTAssertEqual(viewModel.context.viewState.pinnedEventsBannerState.selectedPinnedIndex, 1)
// check how the scrolling changes the banner visibility // check how the scrolling changes the banner visibility
viewModel.timelineHasScrolled(direction: .top) viewModel.timelineHasScrolled(direction: .top)
XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner) XCTAssertFalse(viewModel.context.viewState.shouldShowPinnedEventsBanner)
@@ -105,6 +109,61 @@ class RoomScreenViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner) XCTAssertTrue(viewModel.context.viewState.shouldShowPinnedEventsBanner)
} }
func testPinnedEventsBannerSelection() async throws {
ServiceLocator.shared.settings.pinningEnabled = true
let timelineSubject = PassthroughSubject<TimelineProxyProtocol, Never>()
let updateSubject = PassthroughSubject<JoinedRoomProxyAction, Never>()
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 { func testRoomInfoUpdate() async throws {
let updateSubject = PassthroughSubject<JoinedRoomProxyAction, Never>() let updateSubject = PassthroughSubject<JoinedRoomProxyAction, Never>()
let roomProxyMock = JoinedRoomProxyMock(.init(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false)) let roomProxyMock = JoinedRoomProxyMock(.init(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false))
@@ -112,6 +171,7 @@ class RoomScreenViewModelTests: XCTestCase {
roomProxyMock.canUserJoinCallUserIDReturnValue = .success(false) roomProxyMock.canUserJoinCallUserIDReturnValue = .success(false)
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher() roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
ongoingCallRoomIDPublisher: .init(.init(nil)), ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
@@ -148,6 +208,7 @@ class RoomScreenViewModelTests: XCTestCase {
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil) let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID")) let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID"))
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock, let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
ongoingCallRoomIDPublisher: ongoingCallRoomIDSubject.asCurrentValuePublisher(), ongoingCallRoomIDPublisher: ongoingCallRoomIDSubject.asCurrentValuePublisher(),
appMediator: AppMediatorMock.default, appMediator: AppMediatorMock.default,
@@ -345,6 +345,7 @@ class TimelineViewModelTests: XCTestCase {
let viewModel = TimelineViewModel(roomProxy: roomProxy, let viewModel = TimelineViewModel(roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -369,6 +370,7 @@ class TimelineViewModelTests: XCTestCase {
timelineController.timelineItems = [message] timelineController.timelineItems = [message]
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])), let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])),
timelineController: timelineController, timelineController: timelineController,
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -394,6 +396,7 @@ class TimelineViewModelTests: XCTestCase {
roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher() roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher()
let viewModel = TimelineViewModel(roomProxy: roomProxyMock, let viewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -422,6 +425,7 @@ class TimelineViewModelTests: XCTestCase {
roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher() roomProxyMock.underlyingActionsPublisher = actionsSubject.eraseToAnyPublisher()
let viewModel = TimelineViewModel(roomProxy: roomProxyMock, let viewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(), timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
@@ -451,6 +455,7 @@ class TimelineViewModelTests: XCTestCase {
TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")), TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")),
focussedEventID: focussedEventID, focussedEventID: focussedEventID,
timelineController: timelineController, timelineController: timelineController,
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(),