mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
PinnedBanner is now managed by the RoomScreenViewModel (#3163)
This commit is contained in:
@@ -62,7 +62,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
roomViewModel = RoomScreenViewModel()
|
||||
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
|
||||
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
||||
focussedEventID: parameters.focussedEventID,
|
||||
@@ -129,8 +131,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
composerViewModel.process(timelineAction: action)
|
||||
case .displayCallScreen:
|
||||
actionsSubject.send(.presentCallScreen)
|
||||
case .displayPinnedEventsTimeline:
|
||||
actionsSubject.send(.presentPinnedEventsTimeline)
|
||||
case .hasScrolled(direction: let direction):
|
||||
roomViewModel.timelineHasScrolled(direction: direction)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -144,8 +146,15 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomViewModel.actions
|
||||
.sink { [weak self] _ in
|
||||
.sink { [weak self] actions in
|
||||
guard let self else { return }
|
||||
|
||||
switch actions {
|
||||
case .focusEvent(eventID: let eventID):
|
||||
focusOnEvent(eventID: eventID)
|
||||
case .displayPinnedEventsTimeline:
|
||||
actionsSubject.send(.presentPinnedEventsTimeline)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
||||
@@ -15,18 +15,119 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
|
||||
enum RoomScreenViewModelAction { }
|
||||
enum RoomScreenViewModelAction {
|
||||
case focusEvent(eventID: String)
|
||||
case displayPinnedEventsTimeline
|
||||
}
|
||||
|
||||
enum RoomScreenViewAction { }
|
||||
enum RoomScreenViewAction {
|
||||
case tappedPinnedEventsBanner
|
||||
case viewAllPins
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
var lastScrollDirection: ScrollDirection?
|
||||
var isPinningEnabled = false
|
||||
// This is used to control the banner
|
||||
var pinnedEventsBannerState: PinnedEventsBannerState = .loading(numbersOfEvents: 0)
|
||||
var shouldShowPinnedEventsBanner: Bool {
|
||||
isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct RoomScreenViewStateBindings { }
|
||||
|
||||
enum RoomScreenComposerAction {
|
||||
case saveDraft
|
||||
case loadDraft
|
||||
enum PinnedEventsBannerState: Equatable {
|
||||
case loading(numbersOfEvents: Int)
|
||||
case loaded(state: PinnedEventsState)
|
||||
|
||||
var isEmpty: Bool {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.pinnedEventContents.isEmpty
|
||||
case .loading(let numberOfEvents):
|
||||
return numberOfEvents == 0
|
||||
}
|
||||
}
|
||||
|
||||
var isLoading: Bool {
|
||||
switch self {
|
||||
case .loading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var selectedPinEventID: String? {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.selectedPinEventID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.pinnedEventContents.count
|
||||
case .loading(let numberOfEvents):
|
||||
return numberOfEvents
|
||||
}
|
||||
}
|
||||
|
||||
var selectedPinIndex: Int {
|
||||
switch self {
|
||||
case .loaded(let state):
|
||||
return state.selectedPinIndex
|
||||
case .loading(let numbersOfEvents):
|
||||
// We always want the index to be the last one when loading, since is the default one.
|
||||
return numbersOfEvents - 1
|
||||
}
|
||||
}
|
||||
|
||||
var displayedMessage: AttributedString {
|
||||
switch self {
|
||||
case .loading:
|
||||
return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription)
|
||||
case .loaded(let state):
|
||||
return state.selectedPinContent
|
||||
}
|
||||
}
|
||||
|
||||
var bannerIndicatorDescription: AttributedString {
|
||||
let index = selectedPinIndex + 1
|
||||
let boldPlaceholder = "{bold}"
|
||||
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count))
|
||||
boldString.bold()
|
||||
finalString.replace(boldPlaceholder, with: boldString)
|
||||
return finalString
|
||||
}
|
||||
|
||||
mutating func previousPin() {
|
||||
switch self {
|
||||
case .loaded(var state):
|
||||
state.previousPin()
|
||||
self = .loaded(state: state)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mutating func setPinnedEventContents(_ pinnedEventContents: OrderedDictionary<String, AttributedString>) {
|
||||
switch self {
|
||||
case .loading:
|
||||
// The default selected event should always be the last one.
|
||||
self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last))
|
||||
case .loaded(var state):
|
||||
state.pinnedEventContents = pinnedEventContents
|
||||
self = .loaded(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +16,148 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
|
||||
|
||||
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let pinnedEventStringBuilder: RoomEventStringBuilder
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init() {
|
||||
private var pinnedEventsTimelineProvider: RoomTimelineProviderProtocol? {
|
||||
didSet {
|
||||
guard let pinnedEventsTimelineProvider else {
|
||||
return
|
||||
}
|
||||
|
||||
buildPinnedEventContents(timelineItems: pinnedEventsTimelineProvider.itemProxies)
|
||||
pinnedEventsTimelineProvider.updatePublisher
|
||||
// When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing
|
||||
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] updatedItems, _ in
|
||||
guard let self else { return }
|
||||
buildPinnedEventContents(timelineItems: updatedItems)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
init(roomProxy: RoomProxyProtocol,
|
||||
appMediator: AppMediatorProtocol,
|
||||
appSettings: AppSettings) {
|
||||
self.roomProxy = roomProxy
|
||||
self.appMediator = appMediator
|
||||
self.appSettings = appSettings
|
||||
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
|
||||
|
||||
super.init(initialViewState: .init(bindings: .init()))
|
||||
|
||||
setupSubscriptions()
|
||||
}
|
||||
|
||||
override func process(viewAction: RoomScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .tappedPinnedEventsBanner:
|
||||
if let eventID = state.pinnedEventsBannerState.selectedPinEventID {
|
||||
actionsSubject.send(.focusEvent(eventID: eventID))
|
||||
}
|
||||
state.pinnedEventsBannerState.previousPin()
|
||||
case .viewAllPins:
|
||||
actionsSubject.send(.displayPinnedEventsTimeline)
|
||||
}
|
||||
}
|
||||
|
||||
func timelineHasScrolled(direction: ScrollDirection) {
|
||||
state.lastScrollDirection = direction
|
||||
}
|
||||
|
||||
private func setupSubscriptions() {
|
||||
let roomInfoSubscription = roomProxy
|
||||
.actionsPublisher
|
||||
.filter { $0 == .roomInfoUpdate }
|
||||
|
||||
Task { [weak self] in
|
||||
// Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle.
|
||||
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
|
||||
await self?.updatePinnedEventIDs()
|
||||
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await self?.updatePinnedEventIDs()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let pinningEnabledPublisher = appSettings.$pinningEnabled
|
||||
|
||||
pinningEnabledPublisher
|
||||
.weakAssign(to: \.state.isPinningEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
pinningEnabledPublisher
|
||||
.combineLatest(appMediator.networkMonitor.reachabilityPublisher)
|
||||
.filter { $0.0 && $0.1 == .reachable }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.setupPinnedEventsTimelineProviderIfNeeded()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
|
||||
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
|
||||
|
||||
for item in timelineItems {
|
||||
// Only remote events are pinned
|
||||
if case let .event(event) = item,
|
||||
let eventID = event.id.eventID {
|
||||
pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent),
|
||||
forKey: eventID)
|
||||
}
|
||||
}
|
||||
|
||||
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
|
||||
}
|
||||
|
||||
private func updatePinnedEventIDs() async {
|
||||
let pinnedEventIDs = await roomProxy.pinnedEventIDs
|
||||
// Only update the loading state of the banner
|
||||
if state.pinnedEventsBannerState.isLoading {
|
||||
state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPinnedEventsTimelineProviderIfNeeded() {
|
||||
guard pinnedEventsTimelineProvider == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
guard let timelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else {
|
||||
return
|
||||
}
|
||||
|
||||
if pinnedEventsTimelineProvider == nil {
|
||||
pinnedEventsTimelineProvider = timelineProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomScreenViewModel {
|
||||
static func mock() -> RoomScreenViewModel {
|
||||
RoomScreenViewModel()
|
||||
RoomScreenViewModel(roomProxy: RoomProxyMock(.init()),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,6 @@ import Foundation
|
||||
protocol RoomScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get }
|
||||
var context: RoomScreenViewModel.Context { get }
|
||||
|
||||
func timelineHasScrolled(direction: ScrollDirection)
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ struct RoomScreen: View {
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Group {
|
||||
if timelineContext.viewState.shouldShowPinnedEventsBanner {
|
||||
if roomContext.viewState.shouldShowPinnedEventsBanner {
|
||||
pinnedItemsBanner
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: timelineContext.viewState.shouldShowPinnedEventsBanner)
|
||||
.animation(.elementDefault, value: roomContext.viewState.shouldShowPinnedEventsBanner)
|
||||
}
|
||||
.navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text.
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -114,9 +114,9 @@ struct RoomScreen: View {
|
||||
}
|
||||
|
||||
private var pinnedItemsBanner: some View {
|
||||
PinnedItemsBannerView(state: timelineContext.viewState.pinnedEventsBannerState,
|
||||
onMainButtonTap: { timelineContext.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
onViewAllButtonTap: { timelineContext.send(viewAction: .viewAllPins) })
|
||||
PinnedItemsBannerView(state: roomContext.viewState.pinnedEventsBannerState,
|
||||
onMainButtonTap: { roomContext.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
onViewAllButtonTap: { roomContext.send(viewAction: .viewAllPins) })
|
||||
.transition(.move(edge: .top))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user