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
This commit is contained in:
alltheseas
2026-01-04 18:27:49 -06:00
committed by Daniel D’Aquino
parent e8e2653316
commit 28a2c23a76
9 changed files with 145 additions and 6 deletions

View File

@@ -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 = "<group>"; };
4CA9275C2A28FF630098A105 /* LongformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformView.swift; sourceTree = "<group>"; };
4CA9275E2A2902B20098A105 /* LongformPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformPreview.swift; sourceTree = "<group>"; };
5C78A7902E30358000CF177D /* ReadingProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressBar.swift; sourceTree = "<group>"; };
5C78A7942E30359000CF177D /* LongformMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformMarkdownView.swift; sourceTree = "<group>"; };
4CA927602A290E340098A105 /* EventShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventShell.swift; sourceTree = "<group>"; };
4CA927622A290EB10098A105 /* EventTop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTop.swift; sourceTree = "<group>"; };
4CA927642A290F1A0098A105 /* TimeDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeDot.swift; sourceTree = "<group>"; };
@@ -4351,6 +4359,8 @@
children = (
4CA9275C2A28FF630098A105 /* LongformView.swift */,
4CA9275E2A2902B20098A105 /* LongformPreview.swift */,
5C78A7902E30358000CF177D /* ReadingProgressBar.swift */,
5C78A7942E30359000CF177D /* LongformMarkdownView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -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 */,

View File

@@ -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()
}

View File

@@ -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)))
}
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {