Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ If you want to test it with me on testflight or think we can get it on the app s
<!-- USAGE EXAMPLES -->
## Usage

Tap the clock on **Boards → Recent Threads** to search recently read threads and resume your saved position. History uses the existing **Remember Reading Progress** setting and is limited to 300 threads. Remove an entry or clear the list to erase its reading progress too.

Use a post’s **… → Save Reply** action to keep its text and exact link in **Favorites → Saved Replies**. Search saved text, open the original post, share its link, or swipe to remove it. Text stays available even if the original thread expires; images and videos are not saved with it.

In a thread, use **Jump → First Post / Latest Reply** to move through long discussions without repeated scrolling. Hidden posts are skipped.
Expand Down
4 changes: 4 additions & 0 deletions swiftchan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
AC000001CCCC000100000002 /* RecentThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC000001CCCC000100000001 /* RecentThreadsView.swift */; };
AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000003AAAA000100000001 /* SavedReplyTests.swift */; };
AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000002AAAA000100000001 /* SavedRepliesView.swift */; };
AF000001AAAA000100000002 /* SavedReply.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000001AAAA000100000001 /* SavedReply.swift */; };
Expand Down Expand Up @@ -154,6 +155,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
AC000001CCCC000100000001 /* RecentThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentThreadsView.swift; sourceTree = "<group>"; };
AF000003AAAA000100000001 /* SavedReplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReplyTests.swift; sourceTree = "<group>"; };
AF000002AAAA000100000001 /* SavedRepliesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRepliesView.swift; sourceTree = "<group>"; };
AF000001AAAA000100000001 /* SavedReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReply.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -524,6 +526,7 @@
isa = PBXGroup;
children = (
CC000004CCCC000100000004 /* OpenLinkSheet.swift */,
AC000001CCCC000100000001 /* RecentThreadsView.swift */,
95181792254CFB600032616E /* BoardsView.swift */,
950846FD2752CA260098DF73 /* BoardSection.swift */,
955814B7254D24B900A966BA /* BoardView.swift */,
Expand Down Expand Up @@ -814,6 +817,7 @@
BF000002EEEE000100000002 /* FavoritesBackupStore.swift in Sources */,
DD000003DDDD000100000002 /* ThreadUITestFixture.swift in Sources */,
CC000003CCCC000100000003 /* OpenLinkSheet.swift in Sources */,
AC000001CCCC000100000002 /* RecentThreadsView.swift in Sources */,
95181793254CFB600032616E /* BoardsView.swift in Sources */,
95BEA02D2561816100F77A14 /* ThumbnailMediaView.swift in Sources */,
95105369256600C9002E4051 /* Post.swift in Sources */,
Expand Down
41 changes: 37 additions & 4 deletions swiftchan/Models/ThreadReadingProgress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ struct ThreadReadingProgress: Codable, Equatable {
var postID: Int
var highestReadID: Int
var updatedAt: Date
var title: String?
}

struct RecentThread: Identifiable {
let board: String
let threadID: Int
let progress: ThreadReadingProgress
var id: String { "\(board)/\(threadID)" }
var title: String { progress.title ?? "Thread #\(threadID)" }
}

/// Small, bounded local reading metadata, independent of saved favorites and their schema.
Expand All @@ -24,6 +33,7 @@ final class ThreadReadingStore {
}()

private(set) var resetID = UUID()
private var revision = 0
@ObservationIgnored private let defaults: UserDefaults?
private let key = "threadReadingProgress.v1"
private let limit: Int
Expand All @@ -40,11 +50,26 @@ final class ThreadReadingStore {
entries["\(board.lowercased())/\(threadID)"]
}

func record(board: String, threadID: Int, postID: Int, highestReadID: Int, now: Date = Date()) {
guard threadID > 0, postID > 0 else { return }
let identity = "\(board.lowercased())/\(threadID)"
var recentThreads: [RecentThread] {
_ = revision
return entries.compactMap { key, progress -> RecentThread? in
let parts = key.split(separator: "/")
guard parts.count == 2, let board = Deeplinker.normalizedBoard(String(parts[0])),
let threadID = Int(parts[1]), threadID > 0 else { return nil }
return RecentThread(board: board, threadID: threadID, progress: progress)
}.sorted {
$0.progress.updatedAt == $1.progress.updatedAt ? $0.id < $1.id : $0.progress.updatedAt > $1.progress.updatedAt
}
}

func record(board: String, threadID: Int, postID: Int, highestReadID: Int, now: Date = Date(), title: String? = nil) {
guard threadID > 0, postID > 0, let board = Deeplinker.normalizedBoard(board) else { return }
let identity = "\(board)/\(threadID)"
let title = title.map { String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(512)) }
.flatMap { $0.isEmpty ? nil : $0 }
entries[identity] = ThreadReadingProgress(postID: postID,
highestReadID: max(postID, highestReadID, entries[identity]?.highestReadID ?? 0), updatedAt: now)
highestReadID: max(postID, highestReadID, entries[identity]?.highestReadID ?? 0), updatedAt: now,
title: title ?? entries[identity]?.title)
if entries.count > limit {
let retained = entries.sorted { $0.value.updatedAt > $1.value.updatedAt }.prefix(limit)
entries = Dictionary(uniqueKeysWithValues: retained.map { ($0.key, $0.value) })
Expand All @@ -56,13 +81,21 @@ final class ThreadReadingStore {
guard dirty, let data = try? JSONEncoder().encode(entries) else { return }
defaults?.set(data, forKey: key)
dirty = false
revision += 1
}

func remove(_ thread: RecentThread) {
entries.removeValue(forKey: thread.id)
dirty = true
flush()
}

func clear() {
entries.removeAll()
resetID = UUID()
defaults?.removeObject(forKey: key)
dirty = false
revision += 1
}
}

Expand Down
8 changes: 8 additions & 0 deletions swiftchan/Views/Boards/BoardsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ struct BoardsView: View {
boardContent
.navigationTitle(Constants.title)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
NavigationLink {
RecentThreadsView()
} label: {
Label("Recent Threads", systemImage: "clock")
}
.accessibilityIdentifier("Recent Threads")
}
ToolbarItem(placement: .topBarTrailing) {
Button("Open Link", systemImage: "link") { showOpenLink = true }
.accessibilityIdentifier("Open Link Button")
Expand Down
2 changes: 1 addition & 1 deletion swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ struct ThreadView: View {
reading.observe(visiblePostIDs: visiblePostIDs)
if let postID = reading.postID {
ThreadReadingStore.shared.record(board: viewModel.boardName, threadID: viewModel.id,
postID: postID, highestReadID: reading.highestReadID)
postID: postID, highestReadID: reading.highestReadID, title: viewModel.title)
}
}

Expand Down
57 changes: 57 additions & 0 deletions swiftchan/Views/Boards/RecentThreadsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI

struct RecentThreadsView: View {
@State private var search = ""
@State private var confirmClear = false
@AppStorage("rememberThreadPositions") private var rememberProgress = true
private let store = ThreadReadingStore.shared

private var matches: [RecentThread] {
let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
return store.recentThreads.filter {
query.isEmpty || "\($0.board) \($0.threadID) \($0.title)".localizedStandardContains(query)
}
}

var body: some View {
List {
if store.recentThreads.isEmpty {
ContentUnavailableView("No Recent Threads", systemImage: "clock",
description: Text("Threads appear here when Remember Reading Progress is enabled."))
} else if matches.isEmpty {
ContentUnavailableView.search(text: search)
}
Section {
ForEach(matches) { thread in
NavigationLink {
ThreadView(boardName: thread.board, postNumber: thread.threadID)
} label: {
VStack(alignment: .leading, spacing: 5) {
Text(thread.title).font(.headline).lineLimit(2)
Text("/\(thread.board)/ · #\(thread.threadID)").font(.caption).foregroundStyle(.secondary)
Text(thread.progress.updatedAt, style: .relative).font(.caption).foregroundStyle(.secondary)
}
}
.accessibilityIdentifier("Recent Thread \(thread.id)")
.swipeActions {
Button("Remove", role: .destructive) { store.remove(thread) }
}
}
} footer: {
Text(rememberProgress
? "Your last 300 threads. Removing entries also clears their saved reading positions."
: "Remember Reading Progress is off. New threads won’t be added; existing entries stay until you remove them.")
}
}
.navigationTitle("Recent Threads")
.searchable(text: $search, prompt: "Search title, board, or thread number")
.toolbar {
Button("Clear", role: .destructive) { confirmClear = true }
.disabled(store.recentThreads.isEmpty)
.accessibilityIdentifier("Clear Recent Threads")
}
.confirmationDialog("Clear recent threads and saved reading progress?", isPresented: $confirmClear, titleVisibility: .visible) {
Button("Clear All", role: .destructive) { store.clear() }
}
}
}
2 changes: 1 addition & 1 deletion swiftchan/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ struct SettingsView: View {
Section(header: Text("Thread").font(.title)) {
Toggle("Remember Reading Progress", isOn: $rememberThreadPositions)
Button("Clear Reading Progress", role: .destructive) { showClearReadingConfirmation = true }
.confirmationDialog("Clear saved positions and unread progress?", isPresented: $showClearReadingConfirmation, titleVisibility: .visible) {
.confirmationDialog("Clear recent threads, saved positions, and unread progress?", isPresented: $showClearReadingConfirmation, titleVisibility: .visible) {
Button("Clear Reading Progress", role: .destructive) { ThreadReadingStore.shared.clear() }
}
Toggle("Auto Refresh Enabled", isOn: $autoRefreshEnabled)
Expand Down
30 changes: 30 additions & 0 deletions swiftchanTests/ReadingProgressTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@ import XCTest

@MainActor
final class ReadingProgressTests: XCTestCase {
func testRecentThreadsKeepTitlesOrderAndPersistIndividualRemoval() throws {
let name = "ReadingProgressTests.\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let store = ThreadReadingStore(defaults: defaults, limit: 2)
store.record(board: "biz", threadID: 100, postID: 105, highestReadID: 105,
now: Date(timeIntervalSince1970: 1), title: "Metals")
store.record(board: "g", threadID: 100, postID: 106, highestReadID: 106,
now: Date(timeIntervalSince1970: 2), title: "Technology")
store.record(board: "biz", threadID: 100, postID: 108, highestReadID: 108,
now: Date(timeIntervalSince1970: 3))
store.flush()
XCTAssertEqual(store.recentThreads.map(\.id), ["biz/100", "g/100"])
XCTAssertEqual(store.recentThreads.first?.title, "Metals")
let reloaded = ThreadReadingStore(defaults: defaults)
XCTAssertEqual(reloaded.recentThreads.first?.title, "Metals")
reloaded.remove(try XCTUnwrap(reloaded.recentThreads.first))
XCTAssertNil(reloaded.progress(board: "biz", threadID: 100))
XCTAssertEqual(ThreadReadingStore(defaults: defaults).recentThreads.map(\.id), ["g/100"])
reloaded.clear()
XCTAssertTrue(reloaded.recentThreads.isEmpty)
}

func testOldReadingRecordsWithoutTitlesStillDecode() throws {
let data = Data(#"{"postID":105,"highestReadID":108,"updatedAt":0}"#.utf8)
let progress = try JSONDecoder().decode(ThreadReadingProgress.self, from: data)
XCTAssertNil(progress.title)
XCTAssertEqual(RecentThread(board: "biz", threadID: 100, progress: progress).title, "Thread #100")
}

func testProgressPersistsAcrossStoreInstancesAndIsScopedToBoardAndThread() throws {
let name = "ReadingProgressTests.\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
Expand Down
26 changes: 26 additions & 0 deletions swiftchanUITests/BrowsingUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@ import XCTest

@MainActor
final class BrowsingUITests: XCTestCase {
func testRecentThreadsResumePositionAndCanBeCleared() {
let app = launchThreadFixture()
openFixtureThread(in: app, anchor: "#p105")
app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Recent Threads"].tap()
let recent = app.buttons["Recent Thread biz/100"]
XCTAssertTrue(recent.waitForExistence(timeout: 5))
attachScreenshot("Recent Threads")
recent.tap()
XCTAssertTrue(app.staticTexts["#105"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["#105"].isHittable)
XCTAssertFalse(app.staticTexts["#100"].isHittable)
app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Clear Recent Threads"].tap()
app.buttons["Clear All"].tap()
XCTAssertTrue(app.staticTexts["No Recent Threads"].waitForExistence(timeout: 5))
}

func testRecentThreadsDoNotCollectWhenRememberingIsDisabled() {
let app = launchThreadFixture(rememberProgress: false)
openFixtureThread(in: app)
app.navigationBars.buttons.element(boundBy: 0).tap()
app.buttons["Recent Threads"].tap()
XCTAssertTrue(app.staticTexts["No Recent Threads"].waitForExistence(timeout: 5))
}

func testSaveReplyAndReopenExactPostFromFavorites() {
let app = launchThreadFixture(rememberProgress: false)
openFixtureThread(in: app, anchor: "#p105")
Expand Down