mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Pinned items timeline implementation for the banner (#3099)
This commit is contained in:
@@ -139,7 +139,7 @@ enum RoomScreenViewAction {
|
||||
case hasSwitchedTimeline
|
||||
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case tappedPinBanner
|
||||
case tappedPinnedEventsBanner
|
||||
case viewAllPins
|
||||
}
|
||||
|
||||
@@ -172,10 +172,14 @@ struct RoomScreenViewState: BindableState {
|
||||
var isPinningEnabled = false
|
||||
var lastScrollDirection: ScrollDirection?
|
||||
|
||||
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
|
||||
// It's updated from the room info, so it's faster than using the timeline
|
||||
var pinnedEventIDs: Set<String> = []
|
||||
// This is used to control the banner
|
||||
var pinnedEventsState = PinnedEventsState()
|
||||
|
||||
var shouldShowPinBanner: Bool {
|
||||
isPinningEnabled && !pinnedEventsState.pinnedEventIDs.isEmpty && lastScrollDirection != .top
|
||||
var shouldShowPinnedEventsBanner: Bool {
|
||||
isPinningEnabled && !pinnedEventsState.pinnedEventContents.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var canJoinCall = false
|
||||
@@ -296,39 +300,53 @@ enum ScrollDirection: Equatable {
|
||||
}
|
||||
|
||||
struct PinnedEventsState: Equatable {
|
||||
// For now these will only contain and show the event IDs, but in the future they will also contain the content
|
||||
var pinnedEventIDs: OrderedSet<String> = [] {
|
||||
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
|
||||
didSet {
|
||||
if selectedPinEventID == nil, !pinnedEventIDs.isEmpty {
|
||||
selectedPinEventID = pinnedEventIDs.first
|
||||
} else if pinnedEventIDs.isEmpty {
|
||||
if selectedPinEventID == nil, !pinnedEventContents.keys.isEmpty {
|
||||
selectedPinEventID = pinnedEventContents.keys.last
|
||||
} else if pinnedEventContents.isEmpty {
|
||||
selectedPinEventID = nil
|
||||
} else if let selectedPinEventID, !pinnedEventIDs.contains(selectedPinEventID) {
|
||||
self.selectedPinEventID = pinnedEventIDs.first
|
||||
} else if let selectedPinEventID, !pinnedEventContents.keys.set.contains(selectedPinEventID) {
|
||||
self.selectedPinEventID = pinnedEventContents.firstNonNil { $0.key }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedPinEventID: String?
|
||||
private(set) var selectedPinEventID: String?
|
||||
|
||||
var selectedPinIndex: Int {
|
||||
let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1
|
||||
guard let selectedPinEventID else {
|
||||
return 0
|
||||
return defaultValue
|
||||
}
|
||||
return pinnedEventIDs.firstIndex(of: selectedPinEventID) ?? 0
|
||||
return pinnedEventContents.keys.firstIndex(of: selectedPinEventID) ?? defaultValue
|
||||
}
|
||||
|
||||
// For now we show the event ID as the content, but is just until we have a way to get the real content
|
||||
var selectedPinContent: AttributedString {
|
||||
.init(selectedPinEventID ?? "")
|
||||
guard let selectedPinEventID,
|
||||
var content = pinnedEventContents[selectedPinEventID] else {
|
||||
return AttributedString()
|
||||
}
|
||||
content.font = .compound.bodyMD
|
||||
return content
|
||||
}
|
||||
|
||||
var bannerIndicatorDescription: AttributedString {
|
||||
let index = selectedPinIndex + 1
|
||||
let boldPlaceholder = "{bold}"
|
||||
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, pinnedEventContents.count))
|
||||
boldString.bold()
|
||||
finalString.replace(boldPlaceholder, with: boldString)
|
||||
return finalString
|
||||
}
|
||||
|
||||
mutating func nextPin() {
|
||||
guard !pinnedEventIDs.isEmpty else {
|
||||
guard !pinnedEventContents.isEmpty else {
|
||||
return
|
||||
}
|
||||
let currentIndex = selectedPinIndex
|
||||
let nextIndex = (currentIndex + 1) % pinnedEventIDs.count
|
||||
selectedPinEventID = pinnedEventIDs[nextIndex]
|
||||
let nextIndex = (currentIndex + 1) % pinnedEventContents.count
|
||||
selectedPinEventID = pinnedEventContents.keys[nextIndex]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let analyticsService: AnalyticsService
|
||||
private let pinnedEventStringBuilder: RoomEventStringBuilder
|
||||
|
||||
private let roomScreenInteractionHandler: RoomScreenInteractionHandler
|
||||
|
||||
@@ -66,6 +67,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
self.analyticsService = analyticsService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
|
||||
|
||||
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
|
||||
|
||||
@@ -124,6 +126,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
state.canJoinCall = permission
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
guard let pinnedEventsTimelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else {
|
||||
return
|
||||
}
|
||||
|
||||
buildPinnedEventContent(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 }
|
||||
buildPinnedEventContent(timelineItems: updatedItems)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -196,7 +215,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
Task { state.timelineViewState.isSwitchingTimelines = false }
|
||||
case let .hasScrolled(direction):
|
||||
state.lastScrollDirection = direction
|
||||
case .tappedPinBanner:
|
||||
case .tappedPinnedEventsBanner:
|
||||
if let eventID = state.pinnedEventsState.selectedPinEventID {
|
||||
Task { await focusOnEvent(eventID: eventID) }
|
||||
}
|
||||
@@ -423,12 +442,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
return
|
||||
}
|
||||
// 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 state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs)
|
||||
await state.pinnedEventIDs = roomProxy.pinnedEventIDs
|
||||
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await state.pinnedEventsState.pinnedEventIDs = .init(roomProxy.pinnedEventIDs)
|
||||
await state.pinnedEventIDs = roomProxy.pinnedEventIDs
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -635,6 +654,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
// MARK: - Timeline Item Building
|
||||
|
||||
private func buildPinnedEventContent(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.pinnedEventsState.pinnedEventContents = pinnedEventContents
|
||||
}
|
||||
|
||||
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||
|
||||
|
||||
+28
-15
@@ -23,16 +23,6 @@ struct PinnedItemsBannerView: View {
|
||||
let onMainButtonTap: () -> Void
|
||||
let onViewAllButtonTap: () -> Void
|
||||
|
||||
private var bannerIndicatorDescription: AttributedString {
|
||||
let index = pinnedEventsState.selectedPinIndex + 1
|
||||
let boldPlaceholder = "{bold}"
|
||||
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
|
||||
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, pinnedEventsState.pinnedEventIDs.count))
|
||||
boldString.bold()
|
||||
finalString.replace(boldPlaceholder, with: boldString)
|
||||
return finalString
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
mainButton
|
||||
@@ -48,7 +38,7 @@ struct PinnedItemsBannerView: View {
|
||||
Button { onMainButtonTap() } label: {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
PinnedItemsIndicatorView(pinIndex: pinnedEventsState.selectedPinIndex, pinsCount: pinnedEventsState.pinnedEventIDs.count)
|
||||
PinnedItemsIndicatorView(pinIndex: pinnedEventsState.selectedPinIndex, pinsCount: pinnedEventsState.pinnedEventContents.count)
|
||||
.accessibilityHidden(true)
|
||||
CompoundIcon(\.pinSolid, size: .small, relativeTo: .compound.bodyMD)
|
||||
.foregroundColor(Color.compound.iconSecondaryAlpha)
|
||||
@@ -73,7 +63,7 @@ struct PinnedItemsBannerView: View {
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(bannerIndicatorDescription)
|
||||
Text(pinnedEventsState.bannerIndicatorDescription)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textActionAccent)
|
||||
.lineLimit(1)
|
||||
@@ -86,9 +76,32 @@ struct PinnedItemsBannerView: View {
|
||||
}
|
||||
|
||||
struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
static var attributedContent: AttributedString {
|
||||
var boldPart = AttributedString("Image:")
|
||||
boldPart.bold()
|
||||
var final = boldPart + " content.png"
|
||||
// This should be ignored when presented
|
||||
final.font = .headline
|
||||
return final
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventIDs: ["Content", "NotShown1", "NotShown2"], selectedPinEventID: "Content"),
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
VStack(spacing: 20) {
|
||||
PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventContents: ["1": "Content",
|
||||
"2": "2",
|
||||
"3": "3"],
|
||||
selectedPinEventID: "1"),
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventContents: ["1": "Very very very very long content here",
|
||||
"2": "2"],
|
||||
selectedPinEventID: "1"),
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
PinnedItemsBannerView(pinnedEventsState: .init(pinnedEventContents: ["1": attributedContent],
|
||||
selectedPinEventID: "1"),
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ struct RoomScreen: View {
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Group {
|
||||
if context.viewState.shouldShowPinBanner {
|
||||
if context.viewState.shouldShowPinnedEventsBanner {
|
||||
pinnedItemsBanner
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: context.viewState.shouldShowPinBanner)
|
||||
.animation(.elementDefault, value: context.viewState.shouldShowPinnedEventsBanner)
|
||||
}
|
||||
.navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text.
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -69,7 +69,7 @@ struct RoomScreen: View {
|
||||
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set,
|
||||
pinnedEventIDs: context.viewState.pinnedEventIDs,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled).makeActions()
|
||||
if let actions {
|
||||
@@ -111,7 +111,7 @@ struct RoomScreen: View {
|
||||
|
||||
private var pinnedItemsBanner: some View {
|
||||
PinnedItemsBannerView(pinnedEventsState: context.viewState.pinnedEventsState,
|
||||
onMainButtonTap: { context.send(viewAction: .tappedPinBanner) },
|
||||
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
onViewAllButtonTap: { context.send(viewAction: .viewAllPins) })
|
||||
.transition(.move(edge: .top))
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: context.viewState.canCurrentUserPin,
|
||||
pinnedEventIDs: context.viewState.pinnedEventsState.pinnedEventIDs.set,
|
||||
pinnedEventIDs: context.viewState.pinnedEventIDs,
|
||||
isDM: context.viewState.isEncryptedOneToOneRoom,
|
||||
isViewSourceEnabled: context.viewState.isViewSourceEnabled)
|
||||
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
|
||||
|
||||
Reference in New Issue
Block a user