feat(reader): redesign ArticleReaderView + global appearance toggle
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

Reading experience:
- Body text #CECECE (dark) / #1A1916 (light) — was near-invisible #4A4A4A
- 30pt heavy headline, 17pt body at 8.5pt line-spacing
- Skeleton pulse loading state replaces dark spinner box
- Hero image fades in on load (0.3s ease-in)
- Orange reading progress bar (2pt) below nav bar
- Max reading width 680pt for iPad legibility

Appearance:
- AppearanceMode enum (system/light/dark) in Theme.swift
- @AppStorage("appearanceMode") applied once in JarvisApp root
- Appearance picker (Picker in Menu) in reader toolbar
- Removed .preferredColorScheme(.dark) from all 12 child views

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-21 01:30:36 +03:00
parent fb2e0ff9a2
commit 7085a4dd45
13 changed files with 385 additions and 180 deletions

View File

@@ -23,6 +23,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
@main
struct JarvisApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@StateObject private var settings = ServerSettings.shared
@StateObject private var store = StoryStore.shared
@StateObject private var ws = WebSocketManager.shared
@@ -39,6 +40,7 @@ struct JarvisApp: App {
OnboardingView()
}
}
.preferredColorScheme(appearanceMode.colorScheme)
.environmentObject(settings)
.environmentObject(store)
.environmentObject(ws)

View File

@@ -34,7 +34,6 @@ struct ConnectivityView: View {
.padding(.vertical, 12)
}
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.task { await recheck() }
}

View File

@@ -77,7 +77,6 @@ struct AddFeedSheet: View {
}
.padding(.horizontal, 20)
}
.preferredColorScheme(.dark)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}

View File

@@ -94,7 +94,6 @@ struct FeedManagerView: View {
feedList
}
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.sheet(isPresented: $showAdd) {
AddFeedSheet { url, name in

View File

@@ -147,7 +147,6 @@ struct SignalFeedView: View {
ShareSheet(items: shareItems(story))
}
}
.preferredColorScheme(.dark)
.onChange(of: store.stories) { _, newValue in
cacheStories(newValue)
}

View File

@@ -125,5 +125,4 @@ struct StoryRowView: View {
}
}
.background(Color.black)
.preferredColorScheme(.dark)
}

View File

@@ -1,9 +1,18 @@
// ArticleReaderView.swift
// Jarvis full article. Caches to SwiftData on load; reads from cache offline.
// Jarvis full article reader. Caches to SwiftData on load; reads from cache offline.
import SwiftUI
import SwiftData
// MARK: - Scroll progress tracking
private struct ContentFrameKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() }
}
// MARK: - ViewModel
@MainActor
final class ArticleReaderViewModel: ObservableObject {
@Published var article: Article?
@@ -20,14 +29,12 @@ final class ArticleReaderViewModel: ObservableObject {
let art = try await api.fetchArticle(id: route.articleId)
article = art
cache(art, context: context)
if let detail = try? await api.fetchStory(id: route.storyId) {
siblings = detail.timeline
.filter { $0.articleId != route.articleId }
.sorted { $0.publishedAt < $1.publishedAt }
}
} catch {
// Offline: fall back to the SwiftData cache.
loadCached(route: route, context: context)
if article == nil {
self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription
@@ -39,8 +46,7 @@ final class ArticleReaderViewModel: ObservableObject {
private func cache(_ art: Article, context: ModelContext) {
let id = art.id
let existing = try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
)
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id }))
if existing?.first == nil {
context.insert(CachedArticle(from: art))
try? context.save()
@@ -50,14 +56,12 @@ final class ArticleReaderViewModel: ObservableObject {
private func loadCached(route: ArticleRoute, context: ModelContext) {
let id = route.articleId
if let cached = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })
))?.first {
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.id == id })))?.first {
article = Article(cached: cached)
}
let sid = route.storyId
let arts = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
)) ?? []
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid }))) ?? []
siblings = arts
.filter { $0.id != route.articleId }
.map { TimelineEntry(articleId: $0.id, source: $0.source, headline: $0.headline,
@@ -65,69 +69,359 @@ final class ArticleReaderViewModel: ObservableObject {
}
}
// MARK: - View
struct ArticleReaderView: View {
let route: ArticleRoute
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var scheme
@StateObject private var vm = ArticleReaderViewModel()
@Query private var cachedArticles: [CachedArticle]
@Query private var readStories: [ReadStory]
@Query private var savedStories: [SavedStory]
@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .dark
@State private var showShare = false
@State private var readProgress: Double = 0
// Adaptive palette
private var bg: Color { scheme == .dark ? Color(hex: "0A0A0A") : Color(hex: "F8F7F4") }
private var bodyFg: Color { scheme == .dark ? Color(hex: "CECECE") : Color(hex: "1A1916") }
private var headFg: Color { scheme == .dark ? Color(hex: "F2F2F2") : Color(hex: "0F0E0C") }
private var mutedFg: Color { scheme == .dark ? Color(hex: "616161") : Color(hex: "888882") }
private var hairline: Color { scheme == .dark ? Color(hex: "1F1F1F") : Color(hex: "E2E0D8") }
private var skelFg: Color { scheme == .dark ? Color(hex: "181818") : Color(hex: "E8E7E3") }
private var article: Article? { vm.article }
private var isCached: Bool { cachedArticles.contains { $0.id == route.articleId } }
private var isRead: Bool { readStories.contains { $0.id == route.storyId } }
private var isSaved: Bool { savedStories.contains { $0.id == route.storyId } }
private var backTitle: String {
let h = route.parentHeadline
return h.count > 30 ? String(h.prefix(30)).trimmingCharacters(in: .whitespaces) + "" : h
return h.count > 28 ? String(h.prefix(28)).trimmingCharacters(in: .whitespaces) + "" : h
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ScrollView {
if let article {
content(article)
} else if vm.isLoading {
ProgressView().tint(Palette.orange).padding(.top, 80)
} else {
Text(vm.error ?? "Article unavailable.")
.font(.system(size: 14))
.foregroundStyle(Color(hex: "555555"))
.padding(.top, 80)
}
bg.ignoresSafeArea()
VStack(spacing: 0) {
progressBar
scrollContent
}
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) { backButton }
ToolbarItemGroup(placement: .topBarTrailing) {
ToolbarItemGroup(placement: .topBarTrailing) { trailingButtons }
}
.toolbarBackground(bg, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task { await vm.load(route: route, context: modelContext) }
}
// MARK: - Progress bar
private var progressBar: some View {
ZStack(alignment: .leading) {
Rectangle().fill(hairline).frame(height: 2)
Rectangle()
.fill(Palette.orange)
.frame(width: UIScreen.main.bounds.width * readProgress, height: 2)
.animation(.linear(duration: 0.08), value: readProgress)
}
}
// MARK: - Scroll content
private var scrollContent: some View {
ScrollView {
VStack(spacing: 0) {
if let article = vm.article {
articleBody(article)
} else if vm.isLoading {
skeletonBody
} else {
errorBody
}
}
.padding(.bottom, 56)
.background(
GeometryReader { geo in
Color.clear.preference(
key: ContentFrameKey.self,
value: geo.frame(in: .named("reader"))
)
}
)
}
.coordinateSpace(name: "reader")
.onPreferenceChange(ContentFrameKey.self) { frame in
let viewH = UIScreen.main.bounds.height
let scrollable = frame.height - viewH
guard scrollable > 0 else { readProgress = 0; return }
readProgress = Double(min(1, max(0, -frame.minY / scrollable)))
}
}
// MARK: - Article layout
@ViewBuilder
private func articleBody(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 0) {
// Source + meta
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
.clipShape(Capsule())
Text(article.publishedAt.clockShort)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(mutedFg)
if isCached { cachedBadge }
Spacer()
}
.padding(.horizontal, 22)
.padding(.top, 24)
.padding(.bottom, 20)
// Headline
Text(article.headline)
.font(.system(size: 30, weight: .heavy))
.foregroundStyle(headFg)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 22)
// Hero image
heroImage(article.imageUrl)
.padding(.horizontal, 16)
.padding(.top, 22)
// Author
if let author = article.author, !author.isEmpty {
Text(author.hasPrefix("By ") ? author : "By \(author)")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(mutedFg)
.padding(.horizontal, 22)
.padding(.top, 18)
}
// Section break
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 26)
.padding(.bottom, 26)
// Body 17pt, generous line-height, readable contrast
Text(article.body)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(bodyFg)
.lineSpacing(8.5)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 680, alignment: .leading)
.padding(.horizontal, 22)
// Cluster
if !vm.siblings.isEmpty {
Rectangle()
.fill(hairline)
.frame(height: 1)
.padding(.horizontal, 22)
.padding(.top, 44)
.padding(.bottom, 24)
clusterSection
.padding(.horizontal, 22)
}
}
}
// MARK: - Hero image
@ViewBuilder
private func heroImage(_ urlString: String?) -> some View {
if let urlString, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity.animation(.easeIn(duration: 0.3)))
case .empty:
PulsingSkeleton(color: skelFg)
case .failure:
RoundedRectangle(cornerRadius: 12)
.fill(skelFg)
.overlay(
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundStyle(mutedFg.opacity(0.5))
)
@unknown default: EmptyView()
}
}
.frame(height: 220)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Cached badge
private var cachedBadge: some View {
HStack(spacing: 4) {
Circle().fill(Color(hex: "4A9E4A")).frame(width: 5, height: 5)
Text("Cached")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: "4A9E4A"))
}
}
// MARK: - Skeleton
private var skeletonBody: some View {
VStack(alignment: .leading, spacing: 0) {
PulsingSkeleton(color: skelFg)
.frame(width: 130, height: 26).clipShape(Capsule())
.padding(.horizontal, 22).padding(.top, 24)
VStack(alignment: .leading, spacing: 10) {
PulsingSkeleton(color: skelFg).frame(height: 30)
.clipShape(RoundedRectangle(cornerRadius: 6))
PulsingSkeleton(color: skelFg).frame(width: 260, height: 30)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal, 22).padding(.top, 16)
PulsingSkeleton(color: skelFg)
.frame(height: 220).clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16).padding(.top, 22)
VStack(alignment: .leading, spacing: 10) {
ForEach(0..<5, id: \.self) { i in
PulsingSkeleton(color: skelFg)
.frame(width: i == 4 ? 180 : .infinity, height: 14)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.padding(.horizontal, 22).padding(.top, 32)
}
}
// MARK: - Error
private var errorBody: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 30))
.foregroundStyle(mutedFg)
Text(vm.error ?? "Article unavailable.")
.font(.system(size: 14))
.foregroundStyle(mutedFg)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
.padding(.horizontal, 40)
}
// MARK: - Cluster section
private var clusterSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("MORE FROM THIS STORY")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.7)
.foregroundStyle(mutedFg)
.padding(.bottom, 16)
ForEach(vm.siblings.prefix(3)) { entry in
NavigationLink(value: ArticleRoute(
articleId: entry.articleId,
storyId: route.storyId,
parentHeadline: route.parentHeadline
)) {
clusterRow(entry)
}
.buttonStyle(.plain)
}
}
}
private func clusterRow(_ entry: TimelineEntry) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(mutedFg)
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(bodyFg)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) {
Rectangle().fill(hairline).frame(height: 0.5)
}
.contentShape(Rectangle())
}
// MARK: - Toolbar
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
Text(backTitle).font(.system(size: 15, weight: .regular)).lineLimit(1)
}
.foregroundStyle(Palette.orange)
}
}
@ViewBuilder
private var trailingButtons: some View {
Button { showShare = true } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color(hex: "888888"))
.font(.system(size: 15, weight: .medium))
.foregroundStyle(mutedFg)
}
Button { toggleSave() } label: {
Image(systemName: isSaved ? "bookmark.fill" : "bookmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(isSaved ? Palette.orange : Color(hex: "888888"))
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isSaved ? Palette.orange : mutedFg)
}
Button { toggleRead() } label: {
Image(systemName: isRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(isRead ? Palette.orange : Color(hex: "888888"))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(isRead ? Palette.orange : mutedFg)
}
Menu {
Picker("Appearance", selection: $appearanceMode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Label(mode.label, systemImage: mode.icon).tag(mode)
}
}
.pickerStyle(.inline)
} label: {
Image(systemName: appearanceMode.icon)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(mutedFg)
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task { await vm.load(route: route, context: modelContext) }
}
// MARK: - Actions
@@ -162,133 +456,24 @@ struct ArticleReaderView: View {
}
return items
}
private var backButton: some View {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 15, weight: .bold))
Text(backTitle).font(.system(size: 16, weight: .regular)).lineLimit(1)
}
.foregroundStyle(Palette.orange)
}
}
@ViewBuilder
private func content(_ article: Article) -> some View {
VStack(alignment: .leading, spacing: 16) {
// Source pill + timestamp + cached badge
HStack(spacing: 10) {
Text(article.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Palette.orange)
.clipShape(Capsule())
Text(article.publishedAt.clockShort)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "666666"))
if isCached { CachedBadge(text: "Cached") }
Spacer()
}
// MARK: - Pulsing skeleton primitive
Text(article.headline)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
private struct PulsingSkeleton: View {
let color: Color
@State private var on = false
heroImage(article.imageUrl)
if let author = article.author, !author.isEmpty {
Text("By \(author)")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: "777777"))
}
Text(article.body)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(Palette.bodyText) // #4A4A4A
.lineSpacing(10) // ~1.65 line-height at 16pt
.fixedSize(horizontal: false, vertical: true)
if !vm.siblings.isEmpty {
Divider().overlay(Palette.hairline).padding(.vertical, 8)
clusterSection
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
private func heroImage(_ urlString: String?) -> some View {
let fallback = RoundedRectangle(cornerRadius: 10).fill(Palette.surface2)
return Group {
if let urlString, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().aspectRatio(contentMode: .fill)
case .empty:
fallback.overlay(ProgressView().tint(Color(hex: "555555")))
case .failure:
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
@unknown default:
fallback
}
}
} else {
fallback.overlay(Image(systemName: "photo")
.font(.system(size: 26)).foregroundStyle(Color(hex: "3A3A3A")))
}
}
.frame(height: 200)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private var clusterSection: some View {
VStack(alignment: .leading, spacing: 0) {
Text("MORE FROM THIS CLUSTER")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.kerning(0.8)
.foregroundStyle(Color(hex: "555555"))
.padding(.bottom, 12)
ForEach(vm.siblings.prefix(3)) { entry in
NavigationLink(value: ArticleRoute(articleId: entry.articleId,
storyId: route.storyId,
parentHeadline: route.parentHeadline)) {
clusterRow(entry)
}
.buttonStyle(.plain)
var body: some View {
color.opacity(on ? 0.9 : 0.45)
.onAppear {
withAnimation(.easeInOut(duration: 0.85).repeatForever()) { on = true }
}
}
}
private func clusterRow(_ entry: TimelineEntry) -> some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(entry.source)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(Palette.orange)
Spacer()
Text(entry.publishedAt.clockShort)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(Color(hex: "555555"))
}
Text(entry.headline)
.font(.system(size: 15, weight: .regular))
.foregroundStyle(Color(hex: "CFCFCF"))
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Rectangle().fill(Palette.hairline).frame(height: 0.5) }
.contentShape(Rectangle())
}
}
// MARK: - Offline Article reconstruction
// Reconstruct an Article from its cached copy for offline display.
extension Article {
init(cached: CachedArticle) {
self.init(

View File

@@ -28,7 +28,6 @@ struct RootTabView: View {
}
}
.tint(Color("KisaniOrange"))
.preferredColorScheme(.dark)
.task { await store.loadStories(refresh: true) }
}
}

View File

@@ -76,7 +76,6 @@ struct LatestView: View {
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
}
@@ -124,7 +123,6 @@ struct SavedView: View {
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
}
@@ -165,7 +163,6 @@ struct SearchView: View {
.navigationBarHidden(true)
.jarvisDestinations()
}
.preferredColorScheme(.dark)
}
private var searchBar: some View {

View File

@@ -33,7 +33,6 @@ struct NotificationsView: View {
.padding(.horizontal, 20).padding(.vertical, 12)
}
}
.preferredColorScheme(.dark)
.navigationBarBackButtonHidden(false)
.task { await manager.refreshAuthStatus() }
.onChange(of: settings.morningEnabled) { _, _ in apply() }

View File

@@ -51,7 +51,6 @@ struct SettingsView: View {
}
.navigationBarHidden(true)
}
.preferredColorScheme(.dark)
.presentationDragIndicator(.visible)
.confirmationDialog("Clear offline cache?", isPresented: $showClearConfirm, titleVisibility: .visible) {
Button("Clear cache", role: .destructive) { CacheMaintenance.clear(modelContext) }

View File

@@ -165,6 +165,36 @@ struct ArticleRoute: Hashable {
let parentHeadline: String
}
// MARK: - Appearance mode
enum AppearanceMode: String, CaseIterable {
case system, light, dark
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var icon: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon.stars"
}
}
}
// MARK: - Stable feed ordering
extension StorySummary {

View File

@@ -125,7 +125,6 @@ struct StoryDetailView: View {
}
.toolbarBackground(Color.black, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.preferredColorScheme(.dark)
.sheet(isPresented: $showShare) { ShareSheet(items: shareContent) }
.task {
markRead()