From 0233f2ae4869b6cfb5bc35182ed550a679d2c818 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Sun, 4 Jan 2026 18:27:37 -0600 Subject: [PATCH] longform: add reading progress bar Display a thin purple progress bar at top of longform articles (kind 30023) that tracks scroll position through the content. Uses top/bottom GeometryReader trackers to measure content bounds and calculates progress linearly. Closes: https://github.com/damus-io/damus/issues/3494 Changelog-Added: Added reading progress bar for longform articles Signed-off-by: alltheseas --- damus/Features/Chat/ChatroomThreadView.swift | 86 +++++++++++++++++++ .../Longform/Views/ReadingProgressBar.swift | 37 ++++++++ 2 files changed, 123 insertions(+) create mode 100644 damus/Features/Longform/Views/ReadingProgressBar.swift diff --git a/damus/Features/Chat/ChatroomThreadView.swift b/damus/Features/Chat/ChatroomThreadView.swift index bfc73bd0..ca1bff6d 100644 --- a/damus/Features/Chat/ChatroomThreadView.swift +++ b/damus/Features/Chat/ChatroomThreadView.swift @@ -23,9 +23,45 @@ struct ChatroomThreadView: View { @State var showStickyHeader: Bool = false @State var untrustedSectionOffset: CGFloat = 0 + // Add state for reading progress (longform articles) + @State private var readingProgress: CGFloat = 0 + @State private var viewportHeight: CGFloat = 0 + @State private var contentTopY: CGFloat = 0 + @State private var contentBottomY: CGFloat = 0 + @State private var initialTopY: CGFloat? = nil + private static let untrusted_network_section_id = "untrusted-network-section" private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2) + /// Returns true if the selected event is a longform article (kind 30023). + var isLongformEvent: Bool { + thread.selected_event.kind == 30023 + } + + /// Updates reading progress based on scroll position. + private func updateReadingProgress() { + guard thread.selected_event.kind == 30023 else { return } + guard viewportHeight > 0 else { return } + + // Capture initial position on first update + if initialTopY == nil { + initialTopY = contentTopY + } + guard let startY = initialTopY else { return } + + // Content height is constant (bottom - top in global coords) + let contentHeight = contentBottomY - contentTopY + guard contentHeight > 0 else { return } + + // How much we've scrolled from initial position + // As we scroll down, contentTopY decreases, so scrolled = startY - currentTopY + let scrolled = startY - contentTopY + let maxScroll = max(contentHeight - viewportHeight, 1) + + let progress = scrolled / maxScroll + readingProgress = min(max(progress, 0), 1) + } + func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) { let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top @@ -115,6 +151,20 @@ struct ChatroomThreadView: View { ZStack(alignment: .top) { ScrollView(.vertical) { + VStack(spacing: 0) { + // Top scroll position tracker + GeometryReader { geo in + Color.clear + .onChange(of: geo.frame(in: .global).minY) { newY in + contentTopY = newY + updateReadingProgress() + } + .onAppear { + contentTopY = geo.frame(in: .global).minY + } + } + .frame(height: 1) + LazyVStack(alignment: .leading, spacing: 8) { // MARK: - Parents events view ForEach(thread.parent_events, id: \.id) { parent_event in @@ -221,11 +271,37 @@ struct ChatroomThreadView: View { } } + // Bottom scroll position tracker - placed before EndBlock so we measure article content, not padding + GeometryReader { geo in + Color.clear + .onChange(of: geo.frame(in: .global).minY) { newY in + contentBottomY = newY + updateReadingProgress() + } + .onAppear { + contentBottomY = geo.frame(in: .global).minY + } + } + .frame(height: 1) + EndBlock() HStack {} .frame(height: tabHeight + getSafeAreaBottom()) + } // End VStack wrapper } + .background( + GeometryReader { geo in + Color.clear + .onAppear { + viewportHeight = geo.size.height + } + .onChange(of: geo.size.height) { newHeight in + viewportHeight = newHeight + updateReadingProgress() + } + } + ) if showStickyHeader && !untrusted_events.isEmpty { VStack { @@ -240,6 +316,15 @@ struct ChatroomThreadView: View { .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(1) } + + // Reading progress bar - show for longform articles + if thread.selected_event.kind == 30023 { + VStack(spacing: 0) { + ReadingProgressBar(progress: readingProgress) + Spacer() + } + .zIndex(100) + } } .onReceive(handle_notify(.post), perform: { notify in switch notify { @@ -285,3 +370,4 @@ struct ChatroomView_Previews: PreviewProvider { } } } + diff --git a/damus/Features/Longform/Views/ReadingProgressBar.swift b/damus/Features/Longform/Views/ReadingProgressBar.swift new file mode 100644 index 00000000..8304dc72 --- /dev/null +++ b/damus/Features/Longform/Views/ReadingProgressBar.swift @@ -0,0 +1,37 @@ +// +// ReadingProgressBar.swift +// damus +// +// Created by Claude on 2026-01-03. +// + +import SwiftUI + +/// A thin progress bar that indicates reading progress through longform content. +struct ReadingProgressBar: View { + /// Reading progress from 0.0 to 1.0. + let progress: CGFloat + + var body: some View { + GeometryReader { geometry in + Rectangle() + .fill(DamusColors.purple) + .frame(width: geometry.size.width * min(max(progress, 0), 1)) + } + .frame(height: 4) + .background(Color.gray.opacity(0.3)) + } +} + +struct ReadingProgressBar_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + ReadingProgressBar(progress: 0) + ReadingProgressBar(progress: 0.25) + ReadingProgressBar(progress: 0.5) + ReadingProgressBar(progress: 0.75) + ReadingProgressBar(progress: 1.0) + } + .padding() + } +}