From e3a8c7f4236ded7a26620b14339df2cad0e77750 Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Fri, 27 Mar 2026 17:19:14 +0100 Subject: [PATCH] implemented the display logic for the formatted date --- .../Screens/Timeline/TimelineModels.swift | 5 ++ .../TimelineTableViewController.swift | 67 +++++++++++++++++++ .../Screens/Timeline/TimelineViewModel.swift | 1 + .../RoomTimelineItemViewState.swift | 56 ++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index a20ca5415..ed93d5195 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -142,12 +142,17 @@ struct TimelineViewState: BindableState { var enableKeyShareOnInvite: Bool + var floatingTimelineDateEnabled: Bool + var bindings: TimelineViewStateBindings } struct TimelineViewStateBindings { var isScrolledToBottom = true + /// The formatted date text for the floating date badge shown while scrolling. + var floatingDateText: String? + /// The state of wether reactions listed on the timeline are expanded/collapsed. /// Key is itemID, value is the collapsed state. var reactionsCollapsed: [TimelineItemIdentifier: Bool] diff --git a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift index 18d3b0363..624693f8e 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineTableViewController.swift @@ -145,6 +145,10 @@ class TimelineTableViewController: UIViewController { } @Binding private var isScrolledToBottom: Bool + @Binding private var floatingDateText: String? + + /// A work item used to auto-hide the floating date badge after scrolling stops. + private var floatingDateHideWorkItem: DispatchWorkItem? private var timelineItemsIDs: [TimelineItemIdentifier.UniqueID] { timelineItemsDictionary.keys.elements.reversed() @@ -175,9 +179,11 @@ class TimelineTableViewController: UIViewController { init(coordinator: TimelineViewRepresentable.Coordinator, isScrolledToBottom: Binding, + floatingDateText: Binding, scrollToBottomPublisher: PassthroughSubject) { self.coordinator = coordinator _isScrolledToBottom = isScrolledToBottom + _floatingDateText = floatingDateText super.init(nibName: nil, bundle: nil) @@ -461,6 +467,10 @@ extension TimelineTableViewController: UITableViewDelegate { if self.isScrolledToBottom != isScrolledToBottom { self.isScrolledToBottom = isScrolledToBottom } + + if !isScrolledToBottom { + updateFloatingDate() + } } // We never want the table view to be fully at the bottom to allow the status bar tap to work properly @@ -505,6 +515,63 @@ extension TimelineTableViewController: UITableViewDelegate { } } +// MARK: - Floating Date Badge + +extension TimelineTableViewController { + /// Computes the formatted date text for the topmost visible timeline item + /// and updates the floating date binding. + func updateFloatingDate() { + guard let dateText = topmostVisibleDateText() else { + return + } + + // Before updating it already schedule it's removal or the future + scheduleFloatingDateHide() + + // Only update on changes to avoid needless SwiftUI recomputation. + if floatingDateText != dateText { + floatingDateText = dateText + } + } + + /// Schedules the floating date badge to be hidden after a delay. + func scheduleFloatingDateHide() { + floatingDateHideWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.floatingDateText = nil + } + floatingDateHideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } + + /// Returns the formatted date text for the topmost visible timeline item. + /// + /// The table view is flipped (unless VoiceOver is running), so the "topmost" + /// visible cell on screen is actually the *last* index path in `indexPathsForVisibleRows` + /// when the table is flipped, or the *first* when it is not. + private func topmostVisibleDateText() -> String? { + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows, + !visibleIndexPaths.isEmpty else { + return nil + } + + // In a flipped table view the last index path is the topmost item on screen; + // when VoiceOver is active the table is not flipped so the first is topmost. + let isFlipped = scaleY == -1 + let orderedPaths = isFlipped ? visibleIndexPaths.reversed() : visibleIndexPaths + + // Walk from topmost downward and return the date of the first item that has one. + for indexPath in orderedPaths { + if let uniqueID = dataSource?.itemIdentifier(for: indexPath), + let timestamp = timelineItemsDictionary[uniqueID]?.timestamp { + return timestamp.formattedDateSeparator() + } + } + + return nil + } +} + // MARK: - Layout extension TimelineTableViewController { diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index a1268aa33..a379a7554 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -108,6 +108,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider, mapTilerConfiguration: appSettings.mapTilerConfiguration, enableKeyShareOnInvite: appSettings.enableKeyShareOnInvite, + floatingTimelineDateEnabled: appSettings.floatingTimelineDateEnabled, bindings: .init(reactionsCollapsed: [:])), mediaProvider: userSession.mediaProvider) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index 730af675c..a86a70b31 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -39,6 +39,11 @@ final class RoomTimelineItemViewState: Identifiable, Equatable, ObservableObject var id: TimelineItemIdentifier.UniqueID { identifier.uniqueID } + + /// The timestamp of the item if available (separator date or event timestamp), `nil` otherwise. + var timestamp: Date? { + type.timestamp + } } enum RoomTimelineItemType: Equatable { @@ -147,4 +152,55 @@ enum RoomTimelineItemType: Equatable { return item.id } } + + /// The timestamp of the item if available. + /// Returns the separator date for `.separator`, the event timestamp for all event-based + /// items, the timestamp of the first event inside a `.group`, and `nil` for virtual items + /// that carry no timestamp (read marker, pagination indicator, timeline start). + var timestamp: Date? { + switch self { + case .separator(let item): + return item.timestamp + case .text(let item): + return item.timestamp + case .image(let item): + return item.timestamp + case .video(let item): + return item.timestamp + case .audio(let item): + return item.timestamp + case .file(let item): + return item.timestamp + case .emote(let item): + return item.timestamp + case .notice(let item): + return item.timestamp + case .redacted(let item): + return item.timestamp + case .encrypted(let item): + return item.timestamp + case .sticker(let item): + return item.timestamp + case .unsupported(let item): + return item.timestamp + case .state(let item): + return item.timestamp + case .location(let item): + return item.timestamp + case .poll(let item): + return item.timestamp + case .voice(let item): + return item.timestamp + case .callInvite(let item): + return item.timestamp + case .callNotification(let item): + return item.timestamp + case .liveLocation(let item): + return item.timestamp + case .group(let item): + return (item.items.first(where: { $0 is EventBasedTimelineItemProtocol }) as? EventBasedTimelineItemProtocol)?.timestamp + case .readMarker, .paginationIndicator, .timelineStart: + return nil + } + } }