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:
committed by
Daniel D’Aquino
parent
e8e2653316
commit
28a2c23a76
@@ -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 */,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
damus/Features/Longform/Views/LongformMarkdownView.swift
Normal file
42
damus/Features/Longform/Views/LongformMarkdownView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user