mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Pinned Events Timeline actions and differentiation (#3182)
This commit is contained in:
+26
-2
@@ -28,8 +28,10 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
|
||||
|
||||
enum PinnedEventsTimelineScreenCoordinatorAction {
|
||||
case dismiss
|
||||
|
||||
// Consider adding CustomStringConvertible conformance if the actions contain PII
|
||||
case displayUser(userID: String)
|
||||
case presentLocationViewer(geoURI: GeoURI, description: String?)
|
||||
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
|
||||
case displayRoomScreenWithFocussedPin(eventID: String)
|
||||
}
|
||||
|
||||
final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -50,6 +52,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
viewModel = PinnedEventsTimelineScreenViewModel()
|
||||
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
||||
timelineController: parameters.timelineController,
|
||||
isPinnedEventsTimeline: true,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
@@ -70,6 +73,27 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
timelineViewModel.actions.sink { [weak self] action in
|
||||
MXLog.info("Coordinator: received timeline view model action: \(action)")
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .tappedOnSenderDetails(let userID):
|
||||
actionsSubject.send(.displayUser(userID: userID))
|
||||
case .displayMessageForwarding(let forwardingItem):
|
||||
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
|
||||
case .displayLocation(_, let geoURI, let description):
|
||||
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
|
||||
case .viewInRoomTimeline(let eventID):
|
||||
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
|
||||
// These other actions will not be handled in this view
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen, .composer, .hasScrolled:
|
||||
// These actions are not handled in this coordinator
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
|
||||
+18
-1
@@ -35,8 +35,24 @@ struct PinnedEventsTimelineScreen: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.background(.compound.bgCanvasDefault)
|
||||
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
|
||||
.interactiveDismissDisabled()
|
||||
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
|
||||
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
|
||||
.sheet(item: $timelineContext.actionMenuInfo) { info in
|
||||
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
|
||||
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
|
||||
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
|
||||
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline)
|
||||
.makeActions()
|
||||
if let actions {
|
||||
TimelineItemMenu(item: info.item, actions: actions)
|
||||
.environmentObject(timelineContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -83,6 +99,7 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
|
||||
timelineController.timelineItems = []
|
||||
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
|
||||
timelineController: timelineController,
|
||||
isPinnedEventsTimeline: true,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
|
||||
@@ -67,7 +67,8 @@ struct RoomPollsHistoryScreen: View {
|
||||
Text(DateFormatter.pollTimestamp.string(from: pollTimelineItem.timestamp))
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
PollView(poll: pollTimelineItem.item.poll, editable: pollTimelineItem.item.isEditable) { action in
|
||||
PollView(poll: pollTimelineItem.item.poll,
|
||||
state: .full(isEditable: pollTimelineItem.item.isEditable)) { action in
|
||||
switch action {
|
||||
case .selectOption(let optionID):
|
||||
guard let pollStartID = pollTimelineItem.item.id.eventID else { return }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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(),
|
||||
|
||||
@@ -31,6 +31,8 @@ enum TimelineInteractionHandlerAction {
|
||||
|
||||
case displayAudioRecorderPermissionError
|
||||
case displayErrorToast(String)
|
||||
|
||||
case viewInRoomTimeline(eventID: String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -176,6 +178,9 @@ class TimelineInteractionHandler {
|
||||
case .unpin:
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
Task { await timelineController.unpin(eventID: eventID) }
|
||||
case .viewInRoomTimeline:
|
||||
guard let eventID = itemID.eventID else { return }
|
||||
actionsSubject.send(.viewInRoomTimeline(eventID: eventID))
|
||||
}
|
||||
|
||||
if action.switchToDefaultComposer {
|
||||
|
||||
@@ -32,6 +32,7 @@ enum TimelineViewModelAction {
|
||||
case displayLocation(body: String, geoURI: GeoURI, description: String?)
|
||||
case composer(action: TimelineComposerAction)
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case viewInRoomTimeline(eventID: String)
|
||||
}
|
||||
|
||||
enum TimelineViewPollAction {
|
||||
@@ -89,6 +90,7 @@ enum TimelineComposerAction {
|
||||
}
|
||||
|
||||
struct TimelineViewState: BindableState {
|
||||
let isPinnedEventsTimeline: Bool
|
||||
var roomID: String
|
||||
var members: [String: RoomMemberState] = [:]
|
||||
var typingMembers: [String] = []
|
||||
@@ -222,52 +224,3 @@ enum ScrollDirection: Equatable {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
struct PinnedEventsState: Equatable {
|
||||
var pinnedEventContents: OrderedDictionary<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,
|
||||
focussedEventID: String? = nil,
|
||||
timelineController: RoomTimelineControllerProtocol,
|
||||
isPinnedEventsTimeline: Bool,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
|
||||
@@ -80,7 +81,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
appSettings: appSettings,
|
||||
analyticsService: analyticsService)
|
||||
|
||||
super.init(initialViewState: TimelineViewState(roomID: roomProxy.id,
|
||||
super.init(initialViewState: TimelineViewState(isPinnedEventsTimeline: isPinnedEventsTimeline,
|
||||
roomID: roomProxy.id,
|
||||
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
|
||||
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
||||
ownUserID: roomProxy.ownUserID,
|
||||
@@ -93,6 +95,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
showFocusLoadingIndicator()
|
||||
}
|
||||
|
||||
Task {
|
||||
await updatePinnedEventIDs()
|
||||
}
|
||||
|
||||
setupSubscriptions()
|
||||
setupDirectRoomSubscriptionsIfNeeded()
|
||||
|
||||
@@ -375,9 +381,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
.actionsPublisher
|
||||
.filter { $0 == .roomInfoUpdate }
|
||||
Task { [weak self] in
|
||||
// Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle.
|
||||
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
|
||||
await self?.updatePinnedEventIDs()
|
||||
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
@@ -429,6 +432,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
case .showDebugInfo(let debugInfo):
|
||||
state.bindings.debugInfo = debugInfo
|
||||
case .viewInRoomTimeline(let eventID):
|
||||
actionsSubject.send(.viewInRoomTimeline(eventID: eventID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -641,13 +646,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
} else {
|
||||
for (index, item) in itemGroup.enumerated() {
|
||||
if index == 0 {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first),
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .first),
|
||||
forKey: item.id.timelineID)
|
||||
} else if index == itemGroup.count - 1 {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .last),
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .last),
|
||||
forKey: item.id.timelineID)
|
||||
} else {
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle),
|
||||
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .middle),
|
||||
forKey: item.id.timelineID)
|
||||
}
|
||||
}
|
||||
@@ -822,6 +827,7 @@ extension TimelineViewModel {
|
||||
static let mock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
|
||||
focussedEventID: nil,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
isPinnedEventsTimeline: false,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
@@ -829,6 +835,18 @@ extension TimelineViewModel {
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
|
||||
static let pinnedEventsTimelineMock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
|
||||
focussedEventID: nil,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
isPinnedEventsTimeline: true,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
}
|
||||
|
||||
private struct TimelineContextKey: EnvironmentKey {
|
||||
|
||||
@@ -63,6 +63,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case endPoll(pollStartID: String)
|
||||
case pin
|
||||
case unpin
|
||||
case viewInRoomTimeline
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
@@ -106,6 +107,15 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
var canAppearInPinnedEventsTimeline: Bool {
|
||||
switch self {
|
||||
case .viewInRoomTimeline, .pin, .unpin, .forward, .redact:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// The action's label.
|
||||
@ViewBuilder
|
||||
var label: some View {
|
||||
@@ -139,6 +149,8 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
Label(L10n.actionPin, icon: \.pin)
|
||||
case .unpin:
|
||||
Label(L10n.actionUnpin, icon: \.unpin)
|
||||
case .viewInRoomTimeline:
|
||||
Label(L10n.actionViewInTimeline, icon: \.visibilityOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -24,6 +24,7 @@ struct TimelineItemMenuActionProvider {
|
||||
let pinnedEventIDs: Set<String>
|
||||
let isDM: Bool
|
||||
let isViewSourceEnabled: Bool
|
||||
let isPinnedEventsTimeline: Bool
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func makeActions() -> TimelineItemMenuActions? {
|
||||
@@ -102,8 +103,13 @@ struct TimelineItemMenuActionProvider {
|
||||
if item.isRedacted {
|
||||
actions = actions.filter(\.canAppearInRedacted)
|
||||
}
|
||||
|
||||
if isPinnedEventsTimeline {
|
||||
actions.insert(.viewInRoomTimeline, at: 0)
|
||||
actions = actions.filter(\.canAppearInPinnedEventsTimeline)
|
||||
}
|
||||
|
||||
return .init(isReactable: item.isReactable, actions: actions, debugActions: debugActions)
|
||||
return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable, actions: actions, debugActions: debugActions)
|
||||
}
|
||||
|
||||
private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool {
|
||||
|
||||
@@ -23,21 +23,48 @@ enum PollViewAction {
|
||||
case end
|
||||
}
|
||||
|
||||
enum PollViewState {
|
||||
case preview
|
||||
case full(isEditable: Bool)
|
||||
|
||||
var isPreview: Bool {
|
||||
switch self {
|
||||
case .preview:
|
||||
return true
|
||||
case .full:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isEditable: Bool {
|
||||
switch self {
|
||||
case .preview:
|
||||
return false
|
||||
case .full(let isEditable):
|
||||
return isEditable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PollView: View {
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
|
||||
let poll: Poll
|
||||
let editable: Bool
|
||||
let state: PollViewState
|
||||
let actionHandler: (PollViewAction) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if state.isPreview {
|
||||
questionView
|
||||
optionsView
|
||||
summaryView
|
||||
toolbarView
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
questionView
|
||||
optionsView
|
||||
summaryView
|
||||
toolbarView
|
||||
}
|
||||
.frame(maxWidth: 450)
|
||||
}
|
||||
.frame(maxWidth: 450)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
@@ -88,7 +115,7 @@ struct PollView: View {
|
||||
Button {
|
||||
toolbarAction()
|
||||
} label: {
|
||||
Text(editable ? L10n.actionEditPoll : L10n.actionEndPoll)
|
||||
Text(state.isEditable ? L10n.actionEditPoll : L10n.actionEndPoll)
|
||||
.lineLimit(2, reservesSpace: false)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textOnSolidPrimary)
|
||||
@@ -105,7 +132,7 @@ struct PollView: View {
|
||||
}
|
||||
|
||||
private func toolbarAction() {
|
||||
if editable {
|
||||
if state.isEditable {
|
||||
actionHandler(.edit)
|
||||
} else {
|
||||
actionHandler(.end)
|
||||
@@ -146,28 +173,32 @@ private extension Poll {
|
||||
|
||||
struct PollView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
PollView(poll: .disclosed(), editable: false) { _ in }
|
||||
PollView(poll: .disclosed(), state: .full(isEditable: false)) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Disclosed")
|
||||
|
||||
PollView(poll: .undisclosed(), editable: false) { _ in }
|
||||
PollView(poll: .undisclosed(), state: .full(isEditable: false)) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Undisclosed")
|
||||
|
||||
PollView(poll: .endedDisclosed, editable: false) { _ in }
|
||||
PollView(poll: .endedDisclosed, state: .full(isEditable: false)) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Ended, Disclosed")
|
||||
|
||||
PollView(poll: .endedUndisclosed, editable: false) { _ in }
|
||||
PollView(poll: .endedUndisclosed, state: .full(isEditable: false)) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Ended, Undisclosed")
|
||||
|
||||
PollView(poll: .disclosed(createdByAccountOwner: true), editable: true) { _ in }
|
||||
PollView(poll: .disclosed(createdByAccountOwner: true), state: .full(isEditable: true)) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Creator, disclosed")
|
||||
|
||||
PollView(poll: .emptyDisclosed, editable: true) { _ in }
|
||||
PollView(poll: .emptyDisclosed, state: .full(isEditable: true)) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Creator, no votes")
|
||||
|
||||
PollView(poll: .emptyDisclosed, state: .preview) { _ in }
|
||||
.padding()
|
||||
.previewDisplayName("Preview")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
|
||||
let roomProxyMock = JoinedRoomProxyMock(.init(name: "Room", members: members))
|
||||
let mock = TimelineViewModel(roomProxy: roomProxyMock,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
isPinnedEventsTimeline: false,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
|
||||
@@ -147,7 +147,8 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventIDs,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled)
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled,
|
||||
isPinnedEventsTimeline: context.viewState.isPinnedEventsTimeline)
|
||||
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
|
||||
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ struct TimelineItemStatusView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty {
|
||||
if context.viewState.isPinnedEventsTimeline {
|
||||
// Do not display any status when is a pinned events timeline
|
||||
EmptyView()
|
||||
} else if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty {
|
||||
readReceipts
|
||||
} else {
|
||||
deliveryStatusBadge
|
||||
|
||||
@@ -92,6 +92,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
static let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Test", members: members)),
|
||||
timelineController: MockRoomTimelineController(),
|
||||
isPinnedEventsTimeline: false,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
|
||||
+1
@@ -99,6 +99,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
|
||||
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
|
||||
focussedEventID: focussedEventID,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
isPinnedEventsTimeline: false,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
|
||||
+15
-1
@@ -20,9 +20,18 @@ struct PollRoomTimelineView: View {
|
||||
let timelineItem: PollRoomTimelineItem
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
private var state: PollViewState {
|
||||
if context.viewState.isPinnedEventsTimeline {
|
||||
return .preview
|
||||
} else {
|
||||
return .full(isEditable: timelineItem.isEditable)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
PollView(poll: poll, editable: timelineItem.isEditable) { action in
|
||||
PollView(poll: poll,
|
||||
state: state) { action in
|
||||
switch action {
|
||||
case .selectOption(let optionID):
|
||||
guard let eventID, let option = poll.options.first(where: { $0.id == optionID }), !option.isSelected else { return }
|
||||
@@ -51,6 +60,7 @@ struct PollRoomTimelineView: View {
|
||||
|
||||
struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let pinnedEventsTimelineViewModel = TimelineViewModel.pinnedEventsTimelineMock
|
||||
|
||||
static var previews: some View {
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false))
|
||||
@@ -76,5 +86,9 @@ struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true))
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Creator, no votes, Bubble")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isEditable: true))
|
||||
.environmentObject(pinnedEventsTimelineViewModel.context)
|
||||
.previewDisplayName("Preview")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
|
||||
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
|
||||
timelineController: MockRoomTimelineController(),
|
||||
isPinnedEventsTimeline: false,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
|
||||
Reference in New Issue
Block a user