From 28a2c23a76ed5cf94ea5f893ebbdce9a7869a749 Mon Sep 17 00:00:00 2001 From: alltheseas Date: Sun, 4 Jan 2026 18:27:49 -0600 Subject: [PATCH] longform: add sepia mode and line height settings Add settings for longform article reading experience: - Sepia mode toggle for comfortable reading with warm tones - Line height slider (1.2-2.0x) for adjustable text spacing Both settings persist and apply to the full longform article view. Closes: https://github.com/damus-io/damus/issues/3495 Changelog-Added: Added sepia mode and line height settings for longform articles Signed-off-by: alltheseas --- damus.xcodeproj/project.pbxproj | 16 +++++++ damus/Features/Chat/ChatroomThreadView.swift | 9 ++++ .../Models/LoadableNostrEventView.swift | 4 +- .../Features/Events/Models/NoteContent.swift | 2 +- damus/Features/Events/NoteContentView.swift | 11 +++-- .../Longform/Views/LongformMarkdownView.swift | 42 ++++++++++++++++++ .../Settings/Models/UserSettingsStore.swift | 6 +++ .../Views/AppearanceSettingsView.swift | 43 +++++++++++++++++++ damus/Shared/Components/DamusColors.swift | 18 ++++++++ 9 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 damus/Features/Longform/Views/LongformMarkdownView.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index a958fa47..133ef7b7 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -320,6 +320,8 @@ 4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5588229F33F5B00DC6A45 /* StringCodable.swift */; }; 4CA9275D2A28FF630098A105 /* LongformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275C2A28FF630098A105 /* LongformView.swift */; }; 4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275E2A2902B20098A105 /* LongformPreview.swift */; }; + 5C78A7912E30358100CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; + 5C78A7952E30359100CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; 4CA927612A290E340098A105 /* EventShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927602A290E340098A105 /* EventShell.swift */; }; 4CA927632A290EB10098A105 /* EventTop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927622A290EB10098A105 /* EventTop.swift */; }; 4CA927652A290F1A0098A105 /* TimeDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927642A290F1A0098A105 /* TimeDot.swift */; }; @@ -1005,6 +1007,8 @@ 82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; }; 82D6FC3B2CD99F7900C925F4 /* LongformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275C2A28FF630098A105 /* LongformView.swift */; }; 82D6FC3C2CD99F7900C925F4 /* LongformPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275E2A2902B20098A105 /* LongformPreview.swift */; }; + 5C78A7922E30358200CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; + 5C78A7962E30359200CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; 82D6FC3D2CD99F7900C925F4 /* EventShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927602A290E340098A105 /* EventShell.swift */; }; 82D6FC3E2CD99F7900C925F4 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; 82D6FC3F2CD99F7900C925F4 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; @@ -1551,6 +1555,8 @@ D73E5F352C6A97F4007EB227 /* WideEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6C29CD022E008DB934 /* WideEventView.swift */; }; D73E5F362C6A97F4007EB227 /* LongformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275C2A28FF630098A105 /* LongformView.swift */; }; D73E5F372C6A97F4007EB227 /* LongformPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA9275E2A2902B20098A105 /* LongformPreview.swift */; }; + 5C78A7932E30358300CF177D /* ReadingProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */; }; + 5C78A7972E30359300CF177D /* LongformMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */; }; D73E5F382C6A97F4007EB227 /* EventShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA927602A290E340098A105 /* EventShell.swift */; }; D73E5F392C6A97F4007EB227 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D73E5F3A2C6A97F4007EB227 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; @@ -2418,6 +2424,8 @@ 4CA5588229F33F5B00DC6A45 /* StringCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCodable.swift; sourceTree = ""; }; 4CA9275C2A28FF630098A105 /* LongformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformView.swift; sourceTree = ""; }; 4CA9275E2A2902B20098A105 /* LongformPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformPreview.swift; sourceTree = ""; }; + 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = ""; }; + 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformMarkdownView.swift; sourceTree = ""; }; 4CA927602A290E340098A105 /* EventShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventShell.swift; sourceTree = ""; }; 4CA927622A290EB10098A105 /* EventTop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTop.swift; sourceTree = ""; }; 4CA927642A290F1A0098A105 /* TimeDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeDot.swift; sourceTree = ""; }; @@ -4351,6 +4359,8 @@ children = ( 4CA9275C2A28FF630098A105 /* LongformView.swift */, 4CA9275E2A2902B20098A105 /* LongformPreview.swift */, + 5C78A7902E30358000CF177D /* ReadingProgressBar.swift */, + 5C78A7942E30359000CF177D /* LongformMarkdownView.swift */, ); path = Views; sourceTree = ""; @@ -6147,6 +6157,8 @@ D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */, D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */, 4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */, + 5C78A7912E30358100CF177D /* ReadingProgressBar.swift in Sources */, + 5C78A7952E30359100CF177D /* LongformMarkdownView.swift in Sources */, 4C5F9116283D855D0052CD1C /* EventsModel.swift in Sources */, 4C32B94F2A9AD44700DC3548 /* Int+extension.swift in Sources */, 4CEE2AED2805B22500AB5EEF /* NostrRequest.swift in Sources */, @@ -6820,6 +6832,8 @@ 82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */, 82D6FC3B2CD99F7900C925F4 /* LongformView.swift in Sources */, 82D6FC3C2CD99F7900C925F4 /* LongformPreview.swift in Sources */, + 5C78A7922E30358200CF177D /* ReadingProgressBar.swift in Sources */, + 5C78A7962E30359200CF177D /* LongformMarkdownView.swift in Sources */, D75154C22EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */, 82D6FC3D2CD99F7900C925F4 /* EventShell.swift in Sources */, 82D6FC3E2CD99F7900C925F4 /* MentionView.swift in Sources */, @@ -7258,6 +7272,8 @@ D73E5F8A2C6AA69C007EB227 /* SideMenuView.swift in Sources */, D73E5F362C6A97F4007EB227 /* LongformView.swift in Sources */, D73E5F372C6A97F4007EB227 /* LongformPreview.swift in Sources */, + 5C78A7932E30358300CF177D /* ReadingProgressBar.swift in Sources */, + 5C78A7972E30359300CF177D /* LongformMarkdownView.swift in Sources */, D73E5F382C6A97F4007EB227 /* EventShell.swift in Sources */, D73E5F882C6AA661007EB227 /* NostrScript.swift in Sources */, D73E5F392C6A97F4007EB227 /* MentionView.swift in Sources */, diff --git a/damus/Features/Chat/ChatroomThreadView.swift b/damus/Features/Chat/ChatroomThreadView.swift index ca1bff6d..36172602 100644 --- a/damus/Features/Chat/ChatroomThreadView.swift +++ b/damus/Features/Chat/ChatroomThreadView.swift @@ -297,6 +297,10 @@ struct ChatroomThreadView: View { viewportHeight = geo.size.height } .onChange(of: geo.size.height) { newHeight in + // Reset baseline on significant height change (orientation, text size) + if abs(newHeight - viewportHeight) > 50 { + initialTopY = nil + } viewportHeight = newHeight updateReadingProgress() } @@ -344,6 +348,11 @@ struct ChatroomThreadView: View { thread.subscribe() scroll_to_event(scroller: scroller, id: thread.selected_event.id, delay: 0.1, animate: false) } + .onChange(of: thread.selected_event.id) { _ in + // Reset reading progress when switching to a different event + initialTopY = nil + readingProgress = 0 + } .onDisappear() { thread.unsubscribe() } diff --git a/damus/Features/Events/Models/LoadableNostrEventView.swift b/damus/Features/Events/Models/LoadableNostrEventView.swift index 8ef25041..7c8efc54 100644 --- a/damus/Features/Events/Models/LoadableNostrEventView.swift +++ b/damus/Features/Events/Models/LoadableNostrEventView.swift @@ -78,7 +78,9 @@ class LoadableNostrEventViewModel: ObservableObject { return .unknown_or_unsupported_kind } case .naddr(let naddr): - guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr) else { return .not_found } + guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr) else { + return .not_found + } return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state))) } } diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift index 5f5aac5e..71ee273a 100644 --- a/damus/Features/Events/Models/NoteContent.swift +++ b/damus/Features/Events/Models/NoteContent.swift @@ -383,7 +383,7 @@ struct LongformContent { /// Estimated reading time in minutes, based on average reading speed of 200 words per minute. var estimatedReadTimeMinutes: Int { - return max(1, words / 200) + return max(1, Int(ceil(Double(words) / 200.0))) } init(_ markdown: String) { diff --git a/damus/Features/Events/NoteContentView.swift b/damus/Features/Events/NoteContentView.swift index 8ed3bec2..557b057a 100644 --- a/damus/Features/Events/NoteContentView.swift +++ b/damus/Features/Events/NoteContentView.swift @@ -362,10 +362,13 @@ struct NoteContentView: View { switch self.note_artifacts { case .longform(let md): // Note: Do NOT apply .fixedSize to longform content - it prevents async images from expanding - Markdown(md.markdown) - .markdownImageProvider(.kingfisher(disable_animation: damus_state.settings.disable_animation)) - .markdownInlineImageProvider(.kingfisher) - .padding([.leading, .trailing, .top]) + // Limit line length to ~600pt for optimal readability (50-75 chars per line) + LongformMarkdownView( + markdown: md.markdown, + disableAnimation: damus_state.settings.disable_animation, + lineHeightMultiplier: damus_state.settings.longform_line_height, + sepiaEnabled: damus_state.settings.longform_sepia_mode + ) case .separated(let separated): if #available(iOS 17.4, macOS 14.4, *) { MainContent(artifacts: separated) diff --git a/damus/Features/Longform/Views/LongformMarkdownView.swift b/damus/Features/Longform/Views/LongformMarkdownView.swift new file mode 100644 index 00000000..22539d4a --- /dev/null +++ b/damus/Features/Longform/Views/LongformMarkdownView.swift @@ -0,0 +1,42 @@ +// +// LongformMarkdownView.swift +// damus +// +// Created by Claude on 2026-01-03. +// + +import SwiftUI +import MarkdownUI + +/// A view that renders longform markdown content with optional sepia mode that adapts to light/dark color scheme. +struct LongformMarkdownView: View { + let markdown: MarkdownContent + let disableAnimation: Bool + /// Line height multiplier (e.g., 1.5 means 1.5x line height) + let lineHeightMultiplier: CGFloat + let sepiaEnabled: Bool + + @Environment(\.colorScheme) var colorScheme + + /// Relative line spacing in em units (1.5x multiplier = 0.5em extra spacing) + private var relativeLineSpacing: CGFloat { + lineHeightMultiplier - 1.0 + } + + var body: some View { + Markdown(markdown) + // Override only paragraph style, preserving all other default formatting (headings, lists, etc.) + .markdownBlockStyle(\.paragraph) { configuration in + configuration.label + .relativeLineSpacing(.em(relativeLineSpacing)) + .markdownMargin(top: 0, bottom: 16) + } + .markdownImageProvider(.kingfisher(disable_animation: disableAnimation)) + .markdownInlineImageProvider(.kingfisher) + .frame(maxWidth: 600, alignment: .leading) + .frame(maxWidth: .infinity) + .padding([.leading, .trailing, .top]) + .background(sepiaEnabled ? DamusColors.sepiaBackground(for: colorScheme) : Color.clear) + .foregroundStyle(sepiaEnabled ? DamusColors.sepiaText(for: colorScheme) : Color.primary) + } +} diff --git a/damus/Features/Settings/Models/UserSettingsStore.swift b/damus/Features/Settings/Models/UserSettingsStore.swift index c2b4994b..ae2c6aa7 100644 --- a/damus/Features/Settings/Models/UserSettingsStore.swift +++ b/damus/Features/Settings/Models/UserSettingsStore.swift @@ -172,6 +172,12 @@ class UserSettingsStore: ObservableObject { @Setting(key: "font_size", default_value: 1.0) var font_size: Double + @Setting(key: "longform_sepia_mode", default_value: false) + var longform_sepia_mode: Bool + + @Setting(key: "longform_line_height", default_value: 1.5) + var longform_line_height: Double + @Setting(key: "dm_notification", default_value: true) var dm_notification: Bool diff --git a/damus/Features/Settings/Views/AppearanceSettingsView.swift b/damus/Features/Settings/Views/AppearanceSettingsView.swift index 63b023cd..13278728 100644 --- a/damus/Features/Settings/Views/AppearanceSettingsView.swift +++ b/damus/Features/Settings/Views/AppearanceSettingsView.swift @@ -26,6 +26,35 @@ struct ResizedEventPreview: View { } } +/// Preview component showing sample text with the current line height setting applied +struct LineHeightPreview: View { + let lineHeight: Double + let sepiaEnabled: Bool + @Environment(\.colorScheme) var colorScheme + + private let sampleText = NSLocalizedString( + "The quick brown fox jumps over the lazy dog. This preview shows how your line spacing will appear in longform articles.", + comment: "Sample text for line height preview in settings" + ) + + var body: some View { + let font = Font.body + // Use Dynamic Type: get actual body font size from system preferences + let baseFontSize = UIFont.preferredFont(forTextStyle: .body).pointSize + let spacing = baseFontSize * (lineHeight - 1.0) + + Text(sampleText) + .font(font) + .lineSpacing(spacing) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(sepiaEnabled ? DamusColors.sepiaBackground(for: colorScheme) : Color(UIColor.secondarySystemBackground)) + .foregroundStyle(sepiaEnabled ? DamusColors.sepiaText(for: colorScheme) : Color.primary) + .cornerRadius(8) + .padding(.top, 8) + } +} + struct AppearanceSettingsView: View { let damus_state: DamusState @ObservedObject var settings: UserSettingsStore @@ -61,6 +90,20 @@ struct AppearanceSettingsView: View { FontSize } + // MARK: - Reading + Section(header: Text("Reading", comment: "Section header for reading appearance settings")) { + Toggle(NSLocalizedString("Sepia mode for longform articles", comment: "Setting to enable sepia reading mode for longform articles"), isOn: $settings.longform_sepia_mode) + .toggleStyle(.switch) + + VStack(alignment: .leading) { + Text(String(format: NSLocalizedString("Line height: %.1fx", comment: "Label showing current line height multiplier setting"), settings.longform_line_height)) + Slider(value: $settings.longform_line_height, in: 1.2...1.8, step: 0.1) + + // Preview of line height + LineHeightPreview(lineHeight: settings.longform_line_height, sepiaEnabled: settings.longform_sepia_mode) + } + } + // MARK: - Text Truncation Section(header: Text("Text Truncation", comment: "Section header for damus text truncation user configuration")) { Toggle(NSLocalizedString("Truncate timeline text", comment: "Setting to truncate text in timeline"), isOn: $settings.truncate_timeline_text) diff --git a/damus/Shared/Components/DamusColors.swift b/damus/Shared/Components/DamusColors.swift index bf5d96e5..9a9d1069 100644 --- a/damus/Shared/Components/DamusColors.swift +++ b/damus/Shared/Components/DamusColors.swift @@ -60,6 +60,24 @@ class DamusColors { static let pink = Color(red: 211/255.0, green: 76/255.0, blue: 217/255.0) static let lighterPink = Color(red: 248/255.0, green: 105/255.0, blue: 182/255.0) static let lightBackgroundPink = Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0) + + // Sepia mode colors for comfortable longform reading (based on research: 25% lower luminescence reduces eye strain) + // Light mode sepia + static let sepiaBackgroundLight = Color(red: 0.98, green: 0.95, blue: 0.90) // #FAF3E6 - warm off-white + static let sepiaTextLight = Color(red: 0.35, green: 0.27, blue: 0.20) // #5A4632 - warm brown + // Dark mode sepia (subtle warm tint that blends with dark UI) + static let sepiaBackgroundDark = Color(red: 0.08, green: 0.07, blue: 0.06) // Near-black with subtle warmth + static let sepiaTextDark = Color(red: 0.85, green: 0.80, blue: 0.72) // Warm off-white text + + /// Returns appropriate sepia background for current color scheme. + static func sepiaBackground(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? sepiaBackgroundDark : sepiaBackgroundLight + } + + /// Returns appropriate sepia text color for current color scheme. + static func sepiaText(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? sepiaTextDark : sepiaTextLight + } } func hex_col(r: UInt8, g: UInt8, b: UInt8) -> Color {