From c438069291f924f9a0b1956058b3917cfa314291 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 27 Sep 2026 14:03:30 -0500 Subject: [PATCH] Add searchable recent threads using saved reading progress --- README.md | 2 + swiftchan.xcodeproj/project.pbxproj | 4 ++ swiftchan/Models/ThreadReadingProgress.swift | 41 +++++++++++-- swiftchan/Views/Boards/BoardsView.swift | 8 +++ .../Boards/Catalog/Thread/ThreadView.swift | 2 +- .../Views/Boards/RecentThreadsView.swift | 57 +++++++++++++++++++ swiftchan/Views/SettingsView.swift | 2 +- swiftchanTests/ReadingProgressTests.swift | 30 ++++++++++ swiftchanUITests/BrowsingUITests.swift | 26 +++++++++ 9 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 swiftchan/Views/Boards/RecentThreadsView.swift diff --git a/README.md b/README.md index a96ac28..3393917 100644 --- a/README.md +++ b/README.md @@ -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 +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. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index e7129f0..594e201 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -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 */; }; @@ -154,6 +155,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AC000001CCCC000100000001 /* RecentThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentThreadsView.swift; sourceTree = ""; }; AF000003AAAA000100000001 /* SavedReplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReplyTests.swift; sourceTree = ""; }; AF000002AAAA000100000001 /* SavedRepliesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRepliesView.swift; sourceTree = ""; }; AF000001AAAA000100000001 /* SavedReply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReply.swift; sourceTree = ""; }; @@ -524,6 +526,7 @@ isa = PBXGroup; children = ( CC000004CCCC000100000004 /* OpenLinkSheet.swift */, + AC000001CCCC000100000001 /* RecentThreadsView.swift */, 95181792254CFB600032616E /* BoardsView.swift */, 950846FD2752CA260098DF73 /* BoardSection.swift */, 955814B7254D24B900A966BA /* BoardView.swift */, @@ -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 */, diff --git a/swiftchan/Models/ThreadReadingProgress.swift b/swiftchan/Models/ThreadReadingProgress.swift index eb6753a..e20d40c 100644 --- a/swiftchan/Models/ThreadReadingProgress.swift +++ b/swiftchan/Models/ThreadReadingProgress.swift @@ -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. @@ -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 @@ -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) }) @@ -56,6 +81,13 @@ 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() { @@ -63,6 +95,7 @@ final class ThreadReadingStore { resetID = UUID() defaults?.removeObject(forKey: key) dirty = false + revision += 1 } } diff --git a/swiftchan/Views/Boards/BoardsView.swift b/swiftchan/Views/Boards/BoardsView.swift index dcbbeba..664ace3 100644 --- a/swiftchan/Views/Boards/BoardsView.swift +++ b/swiftchan/Views/Boards/BoardsView.swift @@ -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") diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index cf854e9..e5ddfb1 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -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) } } diff --git a/swiftchan/Views/Boards/RecentThreadsView.swift b/swiftchan/Views/Boards/RecentThreadsView.swift new file mode 100644 index 0000000..e0df334 --- /dev/null +++ b/swiftchan/Views/Boards/RecentThreadsView.swift @@ -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() } + } + } +} diff --git a/swiftchan/Views/SettingsView.swift b/swiftchan/Views/SettingsView.swift index 3cd7374..4cc7f0a 100644 --- a/swiftchan/Views/SettingsView.swift +++ b/swiftchan/Views/SettingsView.swift @@ -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) diff --git a/swiftchanTests/ReadingProgressTests.swift b/swiftchanTests/ReadingProgressTests.swift index 782fc74..1cbcd1f 100644 --- a/swiftchanTests/ReadingProgressTests.swift +++ b/swiftchanTests/ReadingProgressTests.swift @@ -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)) diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index d7688d9..3593d5b 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -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")