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
@@ -21,7 +21,7 @@ import WysiwygComposer
struct RoomScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
var focussedEventID: String?
var focussedEvent: FocusEvent?
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
@@ -63,7 +63,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
}
init(parameters: RoomScreenCoordinatorParameters) {
var selectedPinnedEventID: String?
if let focussedEvent = parameters.focussedEvent {
selectedPinnedEventID = focussedEvent.shouldSetPin ? focussedEvent.eventID : nil
}
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
initialSelectedPinnedEventID: selectedPinnedEventID,
mediaProvider: parameters.mediaProvider,
ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher,
appMediator: parameters.appMediator,
@@ -71,8 +77,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
analyticsService: ServiceLocator.shared.analytics)
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID,
focussedEventID: parameters.focussedEvent?.eventID,
timelineController: parameters.timelineController,
isPinnedEventsTimeline: false,
mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
@@ -133,6 +140,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
composerViewModel.process(timelineAction: action)
case .hasScrolled(direction: let direction):
roomViewModel.timelineHasScrolled(direction: direction)
case .viewInRoomTimeline:
fatalError("The action: \(action) should not be sent to this coordinator")
}
}
.store(in: &cancellables)
@@ -151,7 +160,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
switch actions {
case .focusEvent(eventID: let eventID):
focusOnEvent(eventID: eventID)
focusOnEvent(FocusEvent(eventID: eventID, shouldSetPin: false))
case .displayPinnedEventsTimeline:
actionsSubject.send(.presentPinnedEventsTimeline)
case .displayRoomDetails:
@@ -168,7 +177,11 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
composerViewModel.loadDraft()
}
func focusOnEvent(eventID: String) {
func focusOnEvent(_ focussedEvent: FocusEvent) {
let eventID = focussedEvent.eventID
if focussedEvent.shouldSetPin {
roomViewModel.setSelectedPinnedEventID(eventID)
}
Task { await timelineViewModel.focusOnEvent(eventID: eventID) }
}
@@ -75,10 +75,10 @@ enum PinnedEventsBannerState: Equatable {
}
}
var selectedPinEventID: String? {
var selectedPinnedEventID: String? {
switch self {
case .loaded(let state):
return state.selectedPinEventID
return state.selectedPinnedEventID
default:
return nil
}
@@ -93,10 +93,10 @@ enum PinnedEventsBannerState: Equatable {
}
}
var selectedPinIndex: Int {
var selectedPinnedIndex: Int {
switch self {
case .loaded(let state):
return state.selectedPinIndex
return state.selectedPinnedIndex
case .loading(let numbersOfEvents):
// We always want the index to be the last one when loading, since is the default one.
return numbersOfEvents - 1
@@ -108,12 +108,12 @@ enum PinnedEventsBannerState: Equatable {
case .loading:
return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription)
case .loaded(let state):
return state.selectedPinContent
return state.selectedPinnedContent
}
}
var bannerIndicatorDescription: AttributedString {
let index = selectedPinIndex + 1
let index = selectedPinnedIndex + 1
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count))
@@ -136,10 +136,72 @@ enum PinnedEventsBannerState: Equatable {
switch self {
case .loading:
// The default selected event should always be the last one.
self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last))
self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinnedEventID: pinnedEventContents.keys.last))
case .loaded(var state):
state.pinnedEventContents = pinnedEventContents
self = .loaded(state: state)
}
}
// Note that if we are setting this value, this is definitely sent from the pinned events timeline
// so we can assume that the pinned events timeline is already loaded and we only need to set the
// selection for the loaded state
mutating func setSelectedPinnedEventID(_ eventID: String) {
switch self {
case .loaded(var state):
state.selectedPinnedEventID = eventID
self = .loaded(state: state)
case .loading:
break
}
}
}
struct PinnedEventsState: Equatable {
var pinnedEventContents: OrderedDictionary<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 analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder
private var initialSelectedPinnedEventID: String?
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
@@ -52,6 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
init(roomProxy: JoinedRoomProxyProtocol,
initialSelectedPinnedEventID: String?,
mediaProvider: MediaProviderProtocol,
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
appMediator: AppMediatorProtocol,
@@ -61,6 +63,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
self.appMediator = appMediator
self.appSettings = appSettings
self.analyticsService = analyticsService
self.initialSelectedPinnedEventID = initialSelectedPinnedEventID
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
super.init(initialViewState: .init(roomTitle: roomProxy.roomTitle,
@@ -69,13 +72,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
bindings: .init()),
mediaProvider: mediaProvider)
Task {
await handleRoomInfoUpdate()
}
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
}
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .tappedPinnedEventsBanner:
if let eventID = state.pinnedEventsBannerState.selectedPinEventID {
if let eventID = state.pinnedEventsBannerState.selectedPinnedEventID {
actionsSubject.send(.focusEvent(eventID: eventID))
}
state.pinnedEventsBannerState.previousPin()
@@ -94,6 +101,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.lastScrollDirection = direction
}
func setSelectedPinnedEventID(_ eventID: String) {
state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID)
}
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
let roomInfoSubscription = roomProxy
.actionsPublisher
@@ -110,13 +121,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.store(in: &cancellables)
Task { [weak self] in
// Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle.
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
await self?.handleRoomInfoUpdate()
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
await self?.handleRoomInfoUpdate()
}
}
@@ -159,6 +168,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
// If it's the first time we are setting the pinned events, we should select the initial event if available.
if let initialSelectedPinnedEventID {
state.pinnedEventsBannerState.setSelectedPinnedEventID(initialSelectedPinnedEventID)
self.initialSelectedPinnedEventID = nil
}
}
private func handleRoomInfoUpdate() async {
@@ -194,6 +209,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
extension RoomScreenViewModel {
static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel {
RoomScreenViewModel(roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MockMediaProvider(),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -22,4 +22,5 @@ protocol RoomScreenViewModelProtocol {
var context: RoomScreenViewModel.Context { get }
func timelineHasScrolled(direction: ScrollDirection)
func setSelectedPinnedEventID(_ eventID: String)
}
@@ -38,7 +38,7 @@ struct PinnedItemsBannerView: View {
Button { onMainButtonTap() } label: {
HStack(spacing: 0) {
HStack(spacing: 10) {
PinnedItemsIndicatorView(pinIndex: state.selectedPinIndex, pinsCount: state.count)
PinnedItemsIndicatorView(pinIndex: state.selectedPinnedIndex, pinsCount: state.count)
.accessibilityHidden(true)
CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD)
.foregroundColor(Color.compound.iconSecondaryAlpha)
@@ -98,16 +98,16 @@ struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Content",
"2": "2",
"3": "3"],
selectedPinEventID: "1")),
selectedPinnedEventID: "1")),
onMainButtonTap: { },
onViewAllButtonTap: { })
PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": "Very very very very long content here",
"2": "2"],
selectedPinEventID: "1")),
selectedPinnedEventID: "1")),
onMainButtonTap: { },
onViewAllButtonTap: { })
PinnedItemsBannerView(state: .loaded(state: .init(pinnedEventContents: ["1": attributedContent],
selectedPinEventID: "1")),
selectedPinnedEventID: "1")),
onMainButtonTap: { },
onViewAllButtonTap: { })
PinnedItemsBannerView(state: .loading(numbersOfEvents: 5),
@@ -75,7 +75,9 @@ struct RoomScreen: View {
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled).makeActions()
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline)
.makeActions()
if let actions {
TimelineItemMenu(item: info.item, actions: actions)
.environmentObject(timelineContext)
@@ -218,6 +220,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(),
isPinnedEventsTimeline: false,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),