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
@@ -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)
}
}
}
@@ -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(),
@@ -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(),
@@ -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(),