diff --git a/README.md b/README.md index b410eb6..a96ac28 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,11 @@ If you want to test it with me on testflight or think we can get it on the app s ## Usage +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. -Use **Settings → Favorites Backup** to export saved thread links and named generals as JSON. Importing a backup adds missing entries while keeping existing names. Backups do not include media files, settings, or reading progress. +Use **Settings → Favorites Backup** to export saved thread links, named generals, and saved replies as JSON. Importing a backup adds missing entries while keeping existing names and reply snapshots. Older backups still import; backups containing saved replies require a version of Swiftchan with reply bookmarks. Backups do not include media files, settings, or reading progress. Use **Open Link** on the Boards tab to jump to a board by entering its shortcut, such as `/biz/`, or paste a full board or thread URL. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 591e117..e7129f0 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000004EEEE000100000001 /* FavoritesBackupTests.swift */; }; BF000003EEEE000100000002 /* FavoritesBackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000003EEEE000100000001 /* FavoritesBackupView.swift */; }; BF000002EEEE000100000002 /* FavoritesBackupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF000002EEEE000100000001 /* FavoritesBackupStore.swift */; }; @@ -151,6 +154,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 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 = ""; }; BF000004EEEE000100000001 /* FavoritesBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupTests.swift; sourceTree = ""; }; BF000003EEEE000100000001 /* FavoritesBackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupView.swift; sourceTree = ""; }; BF000002EEEE000100000001 /* FavoritesBackupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesBackupStore.swift; sourceTree = ""; }; @@ -297,6 +303,7 @@ 1F62ABB509907B2E2D948712 /* Favorites */ = { isa = PBXGroup; children = ( + AF000002AAAA000100000001 /* SavedRepliesView.swift */, 5DAF769CBA7D852E789BB9FE /* FavoritesView.swift */, D01E215C9A3306E1A631FA2C /* FavoriteThreadRow.swift */, E087391199AA472B45F68D1C /* RecurringFavoriteRow.swift */, @@ -338,6 +345,7 @@ 9510536725660071002E4051 /* Models */ = { isa = PBXGroup; children = ( + AF000001AAAA000100000001 /* SavedReply.swift */, BF000001EEEE000100000001 /* FavoritesBackup.swift */, EE000001EEEE000100000001 /* HiddenPostStore.swift */, DD000002DDDD000100000001 /* GeneralSuggestion.swift */, @@ -406,6 +414,7 @@ 951817A1254CFB620032616E /* swiftchanTests */ = { isa = PBXGroup; children = ( + AF000003AAAA000100000001 /* SavedReplyTests.swift */, BF000004EEEE000100000001 /* FavoritesBackupTests.swift */, EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */, DD000004DDDD000100000001 /* ReadingProgressTests.swift */, @@ -784,6 +793,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */, + AF000001AAAA000100000002 /* SavedReply.swift in Sources */, 954FB5832730BDF0009CC52F /* RoundedCorners.swift in Sources */, 95CEE159271A67590016EAC1 /* SortRow.swift in Sources */, 9524F4B62580545C0006395F /* FileExport.swift in Sources */, @@ -876,6 +887,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */, BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */, EE000003EEEE000100000002 /* HiddenPostStoreTests.swift in Sources */, DD000004DDDD000100000002 /* ReadingProgressTests.swift in Sources */, diff --git a/swiftchan/Models/FavoritesBackup.swift b/swiftchan/Models/FavoritesBackup.swift index 03d706f..0fd69f2 100644 --- a/swiftchan/Models/FavoritesBackup.swift +++ b/swiftchan/Models/FavoritesBackup.swift @@ -23,21 +23,34 @@ struct FavoritesBackup: Codable, Equatable { struct ImportPlan { let threads: [Thread] let generals: [General] + let replies: [Reply] let skipped: Int } + struct Reply: Codable, Equatable { + var board: String + let threadID: Int + let postID: Int + let title: String + let text: String + let savedAt: Date + var key: String { "\(board)/\(postID)" } + } + let format: String let version: Int let exportedAt: Date var threads: [Thread] var generals: [General] + var replies: [Reply]? - init(threads: [Thread], generals: [General], exportedAt: Date = Date()) { + init(threads: [Thread], generals: [General], replies: [Reply]? = nil, exportedAt: Date = Date()) { format = "swiftchan.favorites" - version = 1 + version = replies?.isEmpty == false ? 2 : 1 self.exportedAt = exportedAt self.threads = threads self.generals = generals + self.replies = replies } func encoded() throws -> Data { @@ -58,7 +71,7 @@ struct FavoritesBackup: Codable, Equatable { func validated() throws -> Self { guard format == "swiftchan.favorites" else { throw BackupError.invalidFile } - guard version == 1 else { throw BackupError.unsupportedVersion } + guard version == 1 || version == 2 else { throw BackupError.unsupportedVersion } var result = self for index in result.threads.indices { guard let board = Deeplinker.normalizedBoard(result.threads[index].board), result.threads[index].number > 0 else { @@ -76,18 +89,29 @@ struct FavoritesBackup: Codable, Equatable { let name = result.generals[index].name?.trimmingCharacters(in: .whitespacesAndNewlines) result.generals[index].name = name?.isEmpty == false ? name : nil } + if var replies = result.replies { + for index in replies.indices { + guard let board = Deeplinker.normalizedBoard(replies[index].board), + replies[index].threadID > 0, replies[index].postID >= replies[index].threadID else { throw BackupError.invalidFile } + replies[index].board = board + } + result.replies = replies + } return result } /// Existing favorites win; repeated records in a file are imported only once. - func importPlan(threadKeys: Set, generalKeys: Set) throws -> ImportPlan { + func importPlan(threadKeys: Set, generalKeys: Set, replyKeys: Set = []) throws -> ImportPlan { let backup = try validated() var threadsSeen = threadKeys var generalsSeen = generalKeys + var repliesSeen = replyKeys let threads = backup.threads.filter { threadsSeen.insert($0.key).inserted } let generals = backup.generals.filter { generalsSeen.insert($0.key).inserted } - return ImportPlan(threads: threads, generals: generals, - skipped: backup.threads.count + backup.generals.count - threads.count - generals.count) + let replies = (backup.replies ?? []).filter { repliesSeen.insert($0.key).inserted } + return ImportPlan(threads: threads, generals: generals, replies: replies, + skipped: backup.threads.count + backup.generals.count + (backup.replies?.count ?? 0) + - threads.count - generals.count - replies.count) } enum BackupError: LocalizedError { diff --git a/swiftchan/Models/FavoritesSchema.swift b/swiftchan/Models/FavoritesSchema.swift index 19b9e22..464ed60 100644 --- a/swiftchan/Models/FavoritesSchema.swift +++ b/swiftchan/Models/FavoritesSchema.swift @@ -84,20 +84,26 @@ enum FavoritesSchemaV2: VersionedSchema { static var models: [any PersistentModel.Type] { [FavoriteThread.self, RecurringFavorite.self] } } +enum FavoritesSchemaV3: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + static var models: [any PersistentModel.Type] { [FavoriteThread.self, RecurringFavorite.self, SavedReply.self] } +} + enum FavoritesMigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [FavoritesSchemaV1.self, FavoritesSchemaV2.self] } + static var schemas: [any VersionedSchema.Type] { [FavoritesSchemaV1.self, FavoritesSchemaV2.self, FavoritesSchemaV3.self] } static var stages: [MigrationStage] { // V1's globally unique thread IDs already guarantee unique (board, thread) pairs. // No rows need to be discarded when replacing that constraint. [.custom(fromVersion: FavoritesSchemaV1.self, toVersion: FavoritesSchemaV2.self, - willMigrate: nil, didMigrate: nil)] + willMigrate: nil, didMigrate: nil), + .lightweight(fromVersion: FavoritesSchemaV2.self, toVersion: FavoritesSchemaV3.self)] } } @MainActor enum FavoritesStore { static func makeContainer(configuration: ModelConfiguration = ModelConfiguration()) throws -> ModelContainer { - try ModelContainer(for: Schema(versionedSchema: FavoritesSchemaV2.self), + try ModelContainer(for: Schema(versionedSchema: FavoritesSchemaV3.self), migrationPlan: FavoritesMigrationPlan.self, configurations: configuration) } } diff --git a/swiftchan/Models/SavedReply.swift b/swiftchan/Models/SavedReply.swift new file mode 100644 index 0000000..5b5dc2e --- /dev/null +++ b/swiftchan/Models/SavedReply.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftData + +@Model +final class SavedReply { + #Unique([\.boardName, \.postID]) + + var boardName: String + var threadID: Int + var postID: Int + var threadTitle: String + var text: String + var savedAt: Date + + init(boardName: String, threadID: Int, postID: Int, threadTitle: String, text: String, savedAt: Date = Date()) { + self.boardName = boardName + self.threadID = threadID + self.postID = postID + self.threadTitle = threadTitle + self.text = text + self.savedAt = savedAt + } + + var url: URL { + URL(string: "https://boards.4chan.org/\(boardName)/thread/\(threadID)#p\(postID)")! + } +} diff --git a/swiftchan/Services/FavoritesBackupStore.swift b/swiftchan/Services/FavoritesBackupStore.swift index 57ffb65..0a1862e 100644 --- a/swiftchan/Services/FavoritesBackupStore.swift +++ b/swiftchan/Services/FavoritesBackupStore.swift @@ -6,18 +6,23 @@ enum FavoritesBackupStore { static func export(from context: ModelContext) throws -> FavoritesBackup { let threads = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.savedAt)])) let generals = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)])) + let replies = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.savedAt)])) return FavoritesBackup(threads: threads.map { .init(board: $0.boardName, number: $0.threadId, title: $0.title, createdAt: $0.createdTime, savedAt: $0.savedAt) }, generals: generals.map { .init(board: $0.boardName, tag: $0.searchPattern, name: $0.displayName, createdAt: $0.createdAt) + }, replies: replies.map { + .init(board: $0.boardName, threadID: $0.threadID, postID: $0.postID, title: $0.threadTitle, text: $0.text, savedAt: $0.savedAt) }) } static func restore(_ backup: FavoritesBackup, in context: ModelContext) throws -> FavoritesBackup.ImportPlan { let existing = try export(from: context).validated() - let plan = try backup.importPlan(threadKeys: Set(existing.threads.map(\.key)), generalKeys: Set(existing.generals.map(\.key))) + let plan = try backup.importPlan(threadKeys: Set(existing.threads.map(\.key)), generalKeys: Set(existing.generals.map(\.key)), + replyKeys: Set((existing.replies ?? []).map(\.key))) var insertedThreads: [FavoriteThread] = [] var insertedGenerals: [RecurringFavorite] = [] + var insertedReplies: [SavedReply] = [] for record in plan.threads { let thread = FavoriteThread(threadId: record.number, boardName: record.board, title: record.title, createdTime: record.createdAt, savedAt: record.savedAt) @@ -30,13 +35,20 @@ enum FavoritesBackupStore { context.insert(general) insertedGenerals.append(general) } + for record in plan.replies { + let reply = SavedReply(boardName: record.board, threadID: record.threadID, postID: record.postID, + threadTitle: record.title, text: record.text, savedAt: record.savedAt) + context.insert(reply) + insertedReplies.append(reply) + } do { - if !insertedThreads.isEmpty || !insertedGenerals.isEmpty { try context.save() } + if !insertedThreads.isEmpty || !insertedGenerals.isEmpty || !insertedReplies.isEmpty { try context.save() } return plan } catch { // Remove only this import's inserts; preserve unrelated edits in the shared context. insertedThreads.forEach { context.delete($0) } insertedGenerals.forEach { context.delete($0) } + insertedReplies.forEach { context.delete($0) } throw error } } diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index e1228dc..cf854e9 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -19,6 +19,8 @@ struct ThreadView: View { @Environment(AppState.self) private var appState @Environment(\.modelContext) private var modelContext @Query private var followedGenerals: [RecurringFavorite] + @Query private var savedReplies: [SavedReply] + @State private var bookmarkError: String? @State private var presentationState = PresentationState() @State private var threadAutorefresher = ThreadAutoRefresher() @@ -349,6 +351,13 @@ struct ThreadView: View { if let post = appState.selectedBottomSheetPost, let index = viewModel.posts.firstIndex(of: post) { VStack(spacing: 24) { + let isSaved = savedReplies.contains { $0.boardName == viewModel.boardName && $0.postID == post.no } + Button(isSaved ? "Remove Saved Reply" : "Save Reply", systemImage: isSaved ? "bookmark.slash" : "bookmark") { + saveReply(index: index) + appState.showingBottomSheet = false + appState.selectedBottomSheetPost = nil + } + .accessibilityIdentifier("Save Selected Reply") ShareLink(item: viewModel.postURL(post.no)) { Label("Share Post", systemImage: "square.and.arrow.up") } @@ -362,7 +371,7 @@ struct ThreadView: View { .accessibilityIdentifier("Hide Selected Post") } .padding() - .presentationDetents([.height(180), .medium]) + .presentationDetents([.height(230), .medium]) .presentationDragIndicator(.visible) } } @@ -385,6 +394,9 @@ struct ThreadView: View { .background(.regularMaterial) } } + .alert("Couldn’t Save Reply", isPresented: Binding(get: { bookmarkError != nil }, set: { if !$0 { bookmarkError = nil } })) { + Button("OK", role: .cancel) { bookmarkError = nil } + } message: { Text(bookmarkError ?? "") } .toolbar(hideTabOnBoards ? .hidden : .automatic, for: .tabBar) case .error: let _ = print("DEBUG View: errorType=\(viewModel.errorType), canLoadFromArchive=\(viewModel.canLoadFromArchive), board=\(viewModel.boardName)") @@ -596,6 +608,18 @@ struct ThreadView: View { isFavorited = ((try? modelContext.fetchCount(descriptor)) ?? 0) > 0 } + private func saveReply(index: Int) { + let post = viewModel.posts[index] + if let existing = savedReplies.first(where: { $0.boardName == viewModel.boardName && $0.postID == post.no }) { + modelContext.delete(existing) + } else { + let text = post.com.map { String(CommentParser(comment: $0).getComment().characters) } ?? "" + modelContext.insert(SavedReply(boardName: viewModel.boardName, threadID: viewModel.id, + postID: post.no, threadTitle: viewModel.title, text: text)) + } + do { try modelContext.save() } catch { bookmarkError = error.localizedDescription } + } + private func toggleFavorite() { UIImpactFeedbackGenerator(style: .medium).impactOccurred() diff --git a/swiftchan/Views/Favorites/FavoritesBackupView.swift b/swiftchan/Views/Favorites/FavoritesBackupView.swift index df94067..f2a381e 100644 --- a/swiftchan/Views/Favorites/FavoritesBackupView.swift +++ b/swiftchan/Views/Favorites/FavoritesBackupView.swift @@ -6,6 +6,7 @@ struct FavoritesBackupView: View { @Environment(\.modelContext) private var context @Query private var threads: [FavoriteThread] @Query private var generals: [RecurringFavorite] + @Query private var replies: [SavedReply] @State private var document = FavoritesBackupDocument() @State private var exporting = false @State private var importing = false @@ -19,12 +20,13 @@ struct FavoritesBackupView: View { Section { LabeledContent("Saved threads", value: "\(threads.count)") LabeledContent("Named generals", value: "\(generals.count)") + LabeledContent("Saved replies", value: "\(replies.count)") } footer: { - Text("Back up saved thread links and named generals. Media files, settings, and reading progress are not included.") + Text("Back up saved thread links, named generals, and saved reply text. Media files, settings, and reading progress are not included.") } Section { Button("Export Favorites", systemImage: "square.and.arrow.up", action: exportFavorites) - .disabled(threads.isEmpty && generals.isEmpty) + .disabled(threads.isEmpty && generals.isEmpty && replies.isEmpty) .accessibilityIdentifier("Export Favorites") Button("Import Favorites", systemImage: "square.and.arrow.down") { importing = true } .accessibilityIdentifier("Import Favorites") @@ -57,7 +59,7 @@ struct FavoritesBackupView: View { Button("Cancel", role: .cancel) { pendingBackup = nil } } message: { if let backup = pendingBackup { - Text("This backup contains \(backup.threads.count) threads and \(backup.generals.count) named generals. Existing favorites will be kept.") + Text("This backup contains \(backup.threads.count) threads, \(backup.generals.count) named generals, and \(backup.replies?.count ?? 0) saved replies. Existing favorites will be kept.") } } .alert("Favorites Backup", isPresented: $showingMessage) { @@ -81,7 +83,7 @@ struct FavoritesBackupView: View { pendingBackup = nil do { let plan = try FavoritesBackupStore.restore(backup, in: context) - report("Imported \(plan.threads.count) threads and \(plan.generals.count) named generals. Skipped \(plan.skipped) duplicates.") + report("Imported \(plan.threads.count) threads, \(plan.generals.count) named generals, and \(plan.replies.count) saved replies. Skipped \(plan.skipped) duplicates.") } catch { report(error.localizedDescription) } diff --git a/swiftchan/Views/Favorites/FavoritesView.swift b/swiftchan/Views/Favorites/FavoritesView.swift index 8d66853..7551a55 100644 --- a/swiftchan/Views/Favorites/FavoritesView.swift +++ b/swiftchan/Views/Favorites/FavoritesView.swift @@ -51,6 +51,14 @@ struct FavoritesView: View { .navigationTitle("Favorites") .searchable(text: $searchText, prompt: "Search favorites") .toolbar { + ToolbarItem(placement: .topBarLeading) { + NavigationLink { + SavedRepliesView() + } label: { + Label("Saved Replies", systemImage: "bookmark") + } + .accessibilityIdentifier("Saved Replies") + } ToolbarItem(placement: .topBarTrailing) { Button("Follow a General", systemImage: "plus") { showAddGeneral = true } .accessibilityIdentifier("Follow General Button") diff --git a/swiftchan/Views/Favorites/SavedRepliesView.swift b/swiftchan/Views/Favorites/SavedRepliesView.swift new file mode 100644 index 0000000..c877d34 --- /dev/null +++ b/swiftchan/Views/Favorites/SavedRepliesView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import SwiftData + +struct SavedRepliesView: View { + @Environment(\.modelContext) private var context + @Query(sort: \SavedReply.savedAt, order: .reverse) private var replies: [SavedReply] + @State private var search = "" + @State private var errorMessage: String? + + private var matches: [SavedReply] { + let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + return replies.filter { + query.isEmpty || "\($0.boardName) \($0.postID) \($0.threadTitle) \($0.text)".localizedStandardContains(query) + } + } + + var body: some View { + List { + if replies.isEmpty { + ContentUnavailableView("No Saved Replies", systemImage: "bookmark", + description: Text("Use a post’s ••• menu to save its text and a link back to it.")) + } else if matches.isEmpty { + ContentUnavailableView.search(text: search) + } + ForEach(matches) { reply in + VStack(alignment: .leading, spacing: 10) { + Text("/\(reply.boardName)/ · #\(reply.postID)") + .font(.caption).foregroundStyle(.secondary) + Text(reply.threadTitle).font(.headline) + Text(reply.text.isEmpty ? "Media-only post" : reply.text) + .textSelection(.enabled) + HStack { + NavigationLink("Open Post") { + ThreadView(boardName: reply.boardName, postNumber: reply.threadID, postID: reply.postID) + } + .accessibilityIdentifier("Open Saved Reply \(reply.postID)") + Spacer() + ShareLink(item: reply.url) { Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share saved reply") + } + .buttonStyle(.borderless) + } + .padding(.vertical, 6) + .swipeActions { + Button("Remove", role: .destructive) { + context.delete(reply) + do { try context.save() } catch { errorMessage = error.localizedDescription } + } + } + } + } + .navigationTitle("Saved Replies") + .searchable(text: $search, prompt: "Search saved text, board, or post number") + .alert("Couldn’t Update Saved Replies", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) { + Button("OK", role: .cancel) { errorMessage = nil } + } message: { Text(errorMessage ?? "") } + } +} diff --git a/swiftchanTests/FavoritesBackupTests.swift b/swiftchanTests/FavoritesBackupTests.swift index c9e3c10..fc8fb45 100644 --- a/swiftchanTests/FavoritesBackupTests.swift +++ b/swiftchanTests/FavoritesBackupTests.swift @@ -39,7 +39,7 @@ final class FavoritesBackupTests: XCTestCase { func testRejectsUnknownFormatsVersionsAndOversizedFiles() throws { let data = try example().encoded() var object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - object["version"] = 2 + object["version"] = 3 XCTAssertThrowsError(try FavoritesBackup.decode(JSONSerialization.data(withJSONObject: object))) object["version"] = 1 object["format"] = "another.app" diff --git a/swiftchanTests/SavedReplyTests.swift b/swiftchanTests/SavedReplyTests.swift new file mode 100644 index 0000000..826ffd8 --- /dev/null +++ b/swiftchanTests/SavedReplyTests.swift @@ -0,0 +1,68 @@ +import XCTest +import SwiftData +@testable import swiftchan + +@MainActor +final class SavedReplyTests: XCTestCase { + func testBackupRoundTripKeepsReplyTextAndSkipsExistingBookmarks() throws { + let container = try FavoritesStore.makeContainer(configuration: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = ModelContext(container) + let date = Date(timeIntervalSince1970: 1_700_000_000) + context.insert(SavedReply(boardName: "biz", threadID: 100, postID: 105, threadTitle: "Metals", text: "Original", savedAt: date)) + try context.save() + let exported = try FavoritesBackupStore.export(from: context) + XCTAssertEqual(exported.version, 2) + let decoded = try FavoritesBackup.decode(exported.encoded()) + XCTAssertEqual(decoded.replies?.first?.text, "Original") + XCTAssertEqual(decoded.replies?.first?.savedAt, date) + let destination = try FavoritesStore.makeContainer(configuration: ModelConfiguration(isStoredInMemoryOnly: true)) + let target = ModelContext(destination) + XCTAssertEqual(try FavoritesBackupStore.restore(decoded, in: target).replies.count, 1) + XCTAssertEqual(try FavoritesBackupStore.restore(decoded, in: target).skipped, 1) + let saved = try XCTUnwrap(target.fetch(FetchDescriptor()).first) + XCTAssertEqual(saved.text, "Original") + XCTAssertEqual(saved.url.absoluteString, "https://boards.4chan.org/biz/thread/100#p105") + } + + func testBackupRejectsInvalidReplyBeforeInsertingAnything() throws { + let container = try FavoritesStore.makeContainer(configuration: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = ModelContext(container) + let backup = FavoritesBackup(threads: [], generals: [], replies: [ + .init(board: "biz", threadID: 100, postID: 105, title: "Valid", text: "Saved", savedAt: Date()), + .init(board: "biz", threadID: 100, postID: -1, title: "Invalid", text: "", savedAt: Date()) + ]) + XCTAssertThrowsError(try FavoritesBackupStore.restore(backup, in: context)) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + } + + func testV2MigrationPreservesFavoritesAndPersistsBoardScopedReplies() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("favorites.store") + try autoreleasepool { + let old = try ModelContainer(for: Schema(versionedSchema: FavoritesSchemaV2.self), configurations: ModelConfiguration(url: url)) + let context = ModelContext(old) + context.insert(FavoriteThread(threadId: 100, boardName: "biz", title: "Metals", createdTime: .distantPast)) + context.insert(RecurringFavorite(searchPattern: "/pmg/", boardName: "biz", displayName: "My metals")) + try context.save() + } + try autoreleasepool { + let container = try FavoritesStore.makeContainer(configuration: ModelConfiguration(url: url)) + let context = ModelContext(container) + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.title, "Metals") + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.displayName, "My metals") + for board in ["biz", "g"] { + context.insert(SavedReply(boardName: board, threadID: 100, postID: 105, threadTitle: "Title", text: "Snapshot")) + } + try context.save() + } + try autoreleasepool { + let container = try FavoritesStore.makeContainer(configuration: ModelConfiguration(url: url)) + let context = ModelContext(container) + let replies = try context.fetch(FetchDescriptor()) + XCTAssertEqual(Set(replies.map(\.boardName)), ["biz", "g"]) + XCTAssertTrue(replies.allSatisfy { $0.text == "Snapshot" }) + } + } +} diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index f14c352..d7688d9 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,27 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testSaveReplyAndReopenExactPostFromFavorites() { + let app = launchThreadFixture(rememberProgress: false) + openFixtureThread(in: app, anchor: "#p105") + app.buttons["Post Options 105"].tap() + app.buttons["Save Selected Reply"].tap() + app.navigationBars.buttons.element(boundBy: 0).tap() + app.buttons["Favorites"].tap() + app.buttons["Saved Replies"].tap() + XCTAssertTrue(app.staticTexts["/biz/ · #105"].waitForExistence(timeout: 5)) + attachScreenshot("Saved Reply Text") + app.buttons["Open Saved Reply 105"].tap() + XCTAssertTrue(app.staticTexts["#105"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["#105"].isHittable) + XCTAssertFalse(app.staticTexts["#100"].isHittable) + app.buttons["Post Options 105"].tap() + XCTAssertEqual(app.buttons["Save Selected Reply"].label, "Remove Saved Reply") + app.buttons["Save Selected Reply"].tap() + app.navigationBars.buttons.element(boundBy: 0).tap() + XCTAssertTrue(app.staticTexts["No Saved Replies"].waitForExistence(timeout: 5)) + } + func testJumpBetweenFirstPostAndLatestReply() { let app = launchThreadFixture(rememberProgress: false) openFixtureThread(in: app) @@ -11,7 +32,7 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.staticTexts["#114"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["#114"].isHittable) XCTAssertFalse(app.staticTexts["#100"].isHittable) - attachScreenshot("Jump To Latest Reply", app: app) + attachScreenshot("Jump To Latest Reply") app.buttons["Post Options 114"].tap() app.buttons["Hide Selected Post"].tap() XCTAssertTrue(app.buttons["Undo Hide"].waitForExistence(timeout: 5)) @@ -41,10 +62,10 @@ final class BrowsingUITests: XCTestCase { app.buttons["Settings"].tap() XCTAssertTrue(app.buttons["Export Favorites"].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons["Export Favorites"].isEnabled) - attachScreenshot("Favorites Backup", app: app) + attachScreenshot("Favorites Backup") app.buttons["Export Favorites"].tap() XCTAssertTrue(app.buttons["Save"].waitForExistence(timeout: 10)) - attachScreenshot("Export Favorites In Files", app: app) + attachScreenshot("Export Favorites In Files") } func testFollowNamedGeneralDirectlyFromFavoritesAndEditIt() { @@ -52,7 +73,7 @@ final class BrowsingUITests: XCTestCase { app.buttons["Favorites"].tap() app.buttons["Follow General Button"].tap() fillGeneral(in: app, name: "Precious Metals General") - attachScreenshot("Follow Precious Metals General", app: app) + attachScreenshot("Follow Precious Metals General") app.buttons["Save General"].tap() XCTAssertTrue(app.staticTexts["Precious Metals General"].waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts["/biz/ · /pmg/"].exists) @@ -73,14 +94,17 @@ final class BrowsingUITests: XCTestCase { XCTAssertEqual(nameField(in: app).value as? String, "Metals Watch") let name = nameField(in: app) name.tap() - name.typeText("Updated ") + // A literal marker avoids autocorrection and does not assume cursor placement. + name.typeText("12345") + let edited = NSPredicate(format: "value CONTAINS %@", "12345") + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: edited, object: name)], timeout: 10), .completed) let editedName = name.value as? String ?? "" - XCTAssertTrue(editedName.contains("Updated ")) + XCTAssertTrue(editedName.contains("12345")) XCTAssertNotEqual(editedName, "Metals Watch") app.buttons["Save General"].tap() XCTAssertTrue(app.staticTexts[editedName].waitForExistence(timeout: 5)) XCTAssertEqual(app.staticTexts.matching(identifier: "/biz/ · /pmg/").count, 1) - attachScreenshot("Followed General", app: app) + attachScreenshot("Followed General") } func testOpenLinkRejectsUnrelatedURLWithoutNavigating() { @@ -93,7 +117,7 @@ final class BrowsingUITests: XCTestCase { field.typeText("https://example.com") app.buttons["Open Link Confirm"].tap() XCTAssertTrue(app.staticTexts["Open Link Error"].waitForExistence(timeout: 5)) - attachScreenshot("Invalid Link Feedback", app: app) + attachScreenshot("Invalid Link Feedback") app.buttons["Cancel"].tap() XCTAssertTrue(app.buttons["Open Link Button"].waitForExistence(timeout: 5)) } @@ -110,7 +134,7 @@ final class BrowsingUITests: XCTestCase { name.typeText("My ") let customName = name.value as? String ?? "" XCTAssertTrue(customName.contains("My ")) - attachScreenshot("Follow From Thread", app: app) + attachScreenshot("Follow From Thread") app.buttons["Save General"].tap() app.buttons["Follow This General"].tap() XCTAssertTrue(app.navigationBars["Edit General"].waitForExistence(timeout: 5)) @@ -131,7 +155,7 @@ final class BrowsingUITests: XCTestCase { XCTAssertFalse(app.staticTexts["#100"].isHittable) let jump = app.buttons["Jump To Unread"] XCTAssertTrue(jump.waitForExistence(timeout: 5)) - attachScreenshot("Restored Position And Unread Replies", app: app) + attachScreenshot("Restored Position And Unread Replies") jump.tap() let scroll = app.scrollViews["Thread Posts"] for _ in 0..<10 where !app.staticTexts["#114"].isHittable { scroll.swipeUp() } @@ -142,7 +166,7 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.staticTexts["#114"].isHittable) XCTAssertFalse(app.staticTexts["#100"].isHittable) XCTAssertFalse(app.buttons["Jump To Unread"].exists) - attachScreenshot("Reopened At Saved Position", app: app) + attachScreenshot("Reopened At Saved Position") } func testPostLinkTakesPriorityOverRememberedPosition() { @@ -167,11 +191,11 @@ final class BrowsingUITests: XCTestCase { app.buttons["Post Options 105"].tap() XCTAssertTrue(app.buttons["Hide Selected Post"].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons["Share Selected Post"].isHittable) - attachScreenshot("Post Sharing Options", app: app) + attachScreenshot("Post Sharing Options") app.buttons["Hide Selected Post"].tap() XCTAssertTrue(app.buttons["Undo Hide"].waitForExistence(timeout: 5)) XCTAssertFalse(app.staticTexts["#105"].exists) - attachScreenshot("Hidden Reply With Undo", app: app) + attachScreenshot("Hidden Reply With Undo") app.buttons["Undo Hide"].tap() XCTAssertTrue(app.staticTexts["#105"].waitForExistence(timeout: 5)) XCTAssertFalse(app.buttons["Undo Hide"].exists) @@ -190,7 +214,7 @@ final class BrowsingUITests: XCTestCase { app.buttons["Manage Hidden Posts"].tap() let restore = app.buttons["Restore Hidden biz/100"] XCTAssertTrue(restore.waitForExistence(timeout: 5)) - attachScreenshot("Manage Hidden Threads", app: app) + attachScreenshot("Manage Hidden Threads") restore.tap() XCTAssertTrue(app.staticTexts["Nothing Hidden"].waitForExistence(timeout: 5)) app.buttons["Boards"].tap() @@ -245,8 +269,8 @@ final class BrowsingUITests: XCTestCase { return field.exists ? field : app.textViews["General Name"] } - private func attachScreenshot(_ name: String, app: XCUIApplication) { - let attachment = XCTAttachment(screenshot: app.screenshot()) + private func attachScreenshot(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) attachment.name = name attachment.lifetime = .keepAlways add(attachment)