implemented the display logic for the formatted date

This commit is contained in:
Mauro Romito
2026-03-27 17:19:14 +01:00
committed by Mauro
parent eafe1f25e3
commit e3a8c7f423
4 changed files with 129 additions and 0 deletions
@@ -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]
@@ -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<Bool>,
floatingDateText: Binding<String?>,
scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
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 {
@@ -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)
@@ -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
}
}
}