Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RoomScreenViewModel refactor part 2 #3169

Merged
merged 2 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {

init(parameters: RoomScreenCoordinatorParameters) {
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider,
appMediator: parameters.appMediator,
appSettings: ServiceLocator.shared.settings)
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)

timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID,
Expand Down Expand Up @@ -103,8 +105,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
guard let self else { return }

switch action {
case .displayRoomDetails:
actionsSubject.send(.presentRoomDetails)
case .displayEmojiPicker(let itemID, let selectedEmojis):
actionsSubject.send(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
case .displayReportContent(let itemID, let senderID):
Expand All @@ -121,16 +121,14 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentPollForm(mode: mode))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
case .displayRoomMemberDetails(userID: let userID):
case .tappedOnSenderDetails(userID: let userID):
actionsSubject.send(.presentRoomMemberDetails(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem))
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
case .composer(let action):
composerViewModel.process(timelineAction: action)
case .displayCallScreen:
actionsSubject.send(.presentCallScreen)
case .hasScrolled(direction: let direction):
roomViewModel.timelineHasScrolled(direction: direction)
}
Expand All @@ -154,6 +152,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
focusOnEvent(eventID: eventID)
case .displayPinnedEventsTimeline:
actionsSubject.send(.presentPinnedEventsTimeline)
case .displayRoomDetails:
actionsSubject.send(.presentRoomDetails)
case .displayCall:
actionsSubject.send(.presentCallScreen)
}
}
.store(in: &cancellables)
Expand Down
10 changes: 10 additions & 0 deletions ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,21 @@ import OrderedCollections
enum RoomScreenViewModelAction {
case focusEvent(eventID: String)
case displayPinnedEventsTimeline
case displayRoomDetails
case displayCall
}

enum RoomScreenViewAction {
case tappedPinnedEventsBanner
case viewAllPins
case displayRoomDetails
case displayCall
}

struct RoomScreenViewState: BindableState {
var roomTitle = ""
var roomAvatar: RoomAvatar

var lastScrollDirection: ScrollDirection?
var isPinningEnabled = false
// This is used to control the banner
Expand All @@ -36,6 +43,9 @@ struct RoomScreenViewState: BindableState {
isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
}

var canJoinCall = false
var hasOngoingCall: Bool

var bindings: RoomScreenViewStateBindings
}

Expand Down
46 changes: 38 additions & 8 deletions ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let roomProxy: RoomProxyProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder

private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
Expand All @@ -51,14 +52,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}

init(roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings) {
appSettings: AppSettings,
analyticsService: AnalyticsService) {
self.roomProxy = roomProxy
self.appMediator = appMediator
self.appSettings = appSettings
self.analyticsService = analyticsService
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)

super.init(initialViewState: .init(bindings: .init()))
super.init(initialViewState: .init(roomTitle: roomProxy.roomTitle,
roomAvatar: roomProxy.avatar,
hasOngoingCall: roomProxy.hasOngoingCall,
bindings: .init()),
imageProvider: mediaProvider)

setupSubscriptions()
}
Expand All @@ -72,6 +80,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.pinnedEventsBannerState.previousPin()
case .viewAllPins:
actionsSubject.send(.displayPinnedEventsTimeline)
case .displayRoomDetails:
actionsSubject.send(.displayRoomDetails)
case .displayCall:
actionsSubject.send(.displayCall)
analyticsService.trackInteraction(name: .MobileRoomCallButton)
}
}

Expand All @@ -84,15 +97,25 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.actionsPublisher
.filter { $0 == .roomInfoUpdate }

roomInfoSubscription
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
guard let self else { return }
state.roomTitle = roomProxy.roomTitle
state.roomAvatar = roomProxy.avatar
state.hasOngoingCall = roomProxy.hasOngoingCall
}
.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?.updatePinnedEventIDs()
await self?.handleRoomInfoUpdate()
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
await self?.updatePinnedEventIDs()
await self?.handleRoomInfoUpdate()
}
}
.store(in: &cancellables)
Expand Down Expand Up @@ -128,12 +151,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
}

private func updatePinnedEventIDs() async {
private func handleRoomInfoUpdate() async {
let pinnedEventIDs = await roomProxy.pinnedEventIDs
// Only update the loading state of the banner
if state.pinnedEventsBannerState.isLoading {
state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count)
}

let userID = roomProxy.ownUserID
if case let .success(permission) = await roomProxy.canUserJoinCall(userID: userID) {
state.canJoinCall = permission
}
}

private func setupPinnedEventsTimelineProviderIfNeeded() {
Expand All @@ -154,10 +182,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}

extension RoomScreenViewModel {
static func mock() -> RoomScreenViewModel {
RoomScreenViewModel(roomProxy: RoomProxyMock(.init()),
static func mock(roomProxyMock: RoomProxyMock) -> RoomScreenViewModel {
RoomScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MockMediaProvider(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
}
}

Expand Down
25 changes: 13 additions & 12 deletions ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,29 +163,29 @@ struct RoomScreen: View {
// .principal + .primaryAction works better than .navigation leading + trailing
// as the latter disables interaction in the action button for rooms with long names
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: timelineContext.viewState.roomTitle,
roomAvatar: timelineContext.viewState.roomAvatar,
imageProvider: timelineContext.imageProvider)
RoomHeaderView(roomName: roomContext.viewState.roomTitle,
roomAvatar: roomContext.viewState.roomAvatar,
imageProvider: roomContext.imageProvider)
// Using a button stops it from getting truncated in the navigation bar
.contentShape(.rect)
.onTapGesture {
timelineContext.send(viewAction: .displayRoomDetails)
roomContext.send(viewAction: .displayRoomDetails)
}
}

if !ProcessInfo.processInfo.isiOSAppOnMac {
ToolbarItem(placement: .primaryAction) {
callButton
.disabled(timelineContext.viewState.canJoinCall == false)
.disabled(roomContext.viewState.canJoinCall == false)
}
}
}

@ViewBuilder
private var callButton: some View {
if timelineContext.viewState.hasOngoingCall {
if roomContext.viewState.hasOngoingCall {
Button {
timelineContext.send(viewAction: .displayCall)
roomContext.send(viewAction: .displayCall)
} label: {
Label(L10n.actionJoin, icon: \.videoCallSolid)
.labelStyle(.titleAndIcon)
Expand All @@ -194,7 +194,7 @@ struct RoomScreen: View {
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
} else {
Button {
timelineContext.send(viewAction: .displayCall)
roomContext.send(viewAction: .displayCall)
} label: {
CompoundIcon(\.videoCallSolid)
}
Expand All @@ -210,10 +210,11 @@ struct RoomScreen: View {
// MARK: - Previews

struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let roomViewModel = RoomScreenViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true)),
static let roomProxyMock = RoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true))
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -503,10 +503,6 @@ class TimelineInteractionHandler {
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
}

func displayRoomMemberDetails(userID: String) async {
actionsSubject.send(.displayRoomMemberDetails(userID: userID))
}

func processItemTap(_ itemID: TimelineItemIdentifier) async -> RoomTimelineControllerAction {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
return .none
Expand Down
57 changes: 24 additions & 33 deletions ElementX/Sources/Screens/Timeline/TimelineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import OrderedCollections
import SwiftUI

enum TimelineViewModelAction {
case displayRoomDetails
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayCameraPicker
Expand All @@ -28,11 +27,10 @@ enum TimelineViewModelAction {
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL)
case displayRoomMemberDetails(userID: String)
case tappedOnSenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case composer(action: TimelineComposerAction)
case displayCallScreen
case hasScrolled(direction: ScrollDirection)
}

Expand All @@ -48,41 +46,39 @@ enum TimelineAudioPlayerAction {
}

enum TimelineViewAction {
case itemAppeared(itemID: TimelineItemIdentifier) // t
case itemDisappeared(itemID: TimelineItemIdentifier) // t
case itemAppeared(itemID: TimelineItemIdentifier)
case itemDisappeared(itemID: TimelineItemIdentifier)

case itemTapped(itemID: TimelineItemIdentifier) // t
case itemSendInfoTapped(itemID: TimelineItemIdentifier) // t
case toggleReaction(key: String, itemID: TimelineItemIdentifier) // t
case sendReadReceiptIfNeeded(TimelineItemIdentifier) // t
case paginateBackwards // t
case paginateForwards // t
case scrollToBottom // t
case itemTapped(itemID: TimelineItemIdentifier)
case itemSendInfoTapped(itemID: TimelineItemIdentifier)
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards
case paginateForwards
case scrollToBottom

case displayTimelineItemMenu(itemID: TimelineItemIdentifier) // t
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) // not t
case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)

case displayRoomDetails // not t
case displayRoomMemberDetails(userID: String) // t -> change name
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String) // t -> handle externally
case displayEmojiPicker(itemID: TimelineItemIdentifier) // t -> handle externally
case displayReadReceipts(itemID: TimelineItemIdentifier) // t -> handle externally
case displayCall // not t
case tappedOnSenderDetails(userID: String)
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String)
case displayEmojiPicker(itemID: TimelineItemIdentifier)
case displayReadReceipts(itemID: TimelineItemIdentifier)

case handlePasteOrDrop(provider: NSItemProvider) // not t
case handlePollAction(TimelineViewPollAction) // t
case handleAudioPlayerAction(TimelineAudioPlayerAction) // t
case handlePasteOrDrop(provider: NSItemProvider)
case handlePollAction(TimelineViewPollAction)
case handleAudioPlayerAction(TimelineAudioPlayerAction)

/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String) // t
case focusOnEventID(String)
/// Switch back to a live timeline (from a detached one).
case focusLive // t
case focusLive
/// The timeline scrolled to reveal the focussed item.
case scrolledToFocussedItem // t
case scrolledToFocussedItem
/// The table view has loaded the first items for a new timeline.
case hasSwitchedTimeline // t
case hasSwitchedTimeline

case hasScrolled(direction: ScrollDirection) // t
case hasScrolled(direction: ScrollDirection)
}

enum TimelineComposerAction {
Expand All @@ -94,8 +90,6 @@ enum TimelineComposerAction {

struct TimelineViewState: BindableState {
var roomID: String
var roomTitle = ""
var roomAvatar: RoomAvatar
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
Expand All @@ -113,9 +107,6 @@ struct TimelineViewState: BindableState {
// It's updated from the room info, so it's faster than using the timeline
var pinnedEventIDs: Set<String> = []

var canJoinCall = false
var hasOngoingCall = false

var bindings: TimelineViewStateBindings

/// A closure providing the associated audio player state for an item in the timeline.
Expand Down
Loading
Loading