diff --git a/README.md b/README.md index 3393917..c44dd70 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,13 @@ If you want to test it with me on testflight or think we can get it on the app s ## Usage +- Tap a `>>post` quote to preview it in a sheet. Follow quote chains inside the preview, then tap **Done** to return to your reading position. +- **Favorites → Thread Watcher** checks saved threads for unread replies and archived/unavailable status. Checks are foreground-only, throttled to once per 30 seconds with requests spaced apart; it does not send background notifications. +- **Settings → Filters & Highlights** saves literal keyword, poster ID, name, and tripcode rules for one board or all boards. Hide takes priority over highlight. Rules apply to catalog cards, thread posts, and quote previews, and stay on this device. +- **Settings → Appearance** offers System, Yotsuba, and Yotsuba B colors plus compact posts. +- An archived or unavailable thread offers **Find Next General** when you follow a general on that board. It searches for newer matching threads and keeps your saved name. +- **Post options → Reply on 4chan** starts a quoted draft. **Copy Draft & Open 4chan** opens the official website, where you paste the draft, attach media, complete CAPTCHA, and submit. Drafts remain local until you copy them; **Discard Draft** removes the local draft. This is a website handoff, not native posting through the read-only API. + 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. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 594e201..4a7391a 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + CA000006CCCC000100000002 /* ThreadWatcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000006CCCC000100000001 /* ThreadWatcherView.swift */; }; + CA000005CCCC000100000002 /* ThreadWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000005CCCC000100000001 /* ThreadWatcher.swift */; }; + CA000004CCCC000100000002 /* PostFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000004CCCC000100000001 /* PostFiltersView.swift */; }; + CA000003CCCC000100000002 /* ChanPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000003CCCC000100000001 /* ChanPreferences.swift */; }; + CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000002CCCC000100000001 /* ReplyDraftSheet.swift */; }; + CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000001CCCC000100000001 /* QuotePreviewSheet.swift */; }; 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 */; }; @@ -155,6 +161,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + CA000006CCCC000100000001 /* ThreadWatcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadWatcherView.swift; sourceTree = ""; }; + CA000005CCCC000100000001 /* ThreadWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadWatcher.swift; sourceTree = ""; }; + CA000004CCCC000100000001 /* PostFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFiltersView.swift; sourceTree = ""; }; + CA000003CCCC000100000001 /* ChanPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChanPreferences.swift; sourceTree = ""; }; + CA000002CCCC000100000001 /* ReplyDraftSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyDraftSheet.swift; sourceTree = ""; }; + CA000001CCCC000100000001 /* QuotePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePreviewSheet.swift; sourceTree = ""; }; 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 = ""; }; @@ -311,6 +323,7 @@ E087391199AA472B45F68D1C /* RecurringFavoriteRow.swift */, 9CBC04E821A80DD362D9DDF2 /* RecurringMatchSheet.swift */, BF000003EEEE000100000001 /* FavoritesBackupView.swift */, + CA000006CCCC000100000001 /* ThreadWatcherView.swift */, 9044AE15507088A1373984CA /* AddRecurringFavoriteSheet.swift */, ); path = Favorites; @@ -349,6 +362,7 @@ children = ( AF000001AAAA000100000001 /* SavedReply.swift */, BF000001EEEE000100000001 /* FavoritesBackup.swift */, + CA000003CCCC000100000001 /* ChanPreferences.swift */, EE000001EEEE000100000001 /* HiddenPostStore.swift */, DD000002DDDD000100000001 /* GeneralSuggestion.swift */, DD000001DDDD000100000001 /* ThreadReadingProgress.swift */, @@ -452,6 +466,7 @@ 958D2C98254D2DF200E54C41 /* ContentView.swift */, 95B7BE43256CBBCA00F75BC9 /* Boards */, 95C01FE827386D2B000D64B1 /* SettingsView.swift */, + CA000004CCCC000100000001 /* PostFiltersView.swift */, 950E3933255CD9BB00A00A74 /* Media */, 95D86F6F25EA301A005A7EBC /* Utility */, 2BA6BE71A1C0F2F67A42CF5E /* Favorites */, @@ -468,6 +483,7 @@ 9574CE06255E34E8000C99B6 /* CatalogViewModel.swift */, 9574CDFD255E30E8000C99B6 /* ThreadViewModel.swift */, 83D12254DE163971EE1E9188 /* RecurringFavoriteViewModel.swift */, + CA000005CCCC000100000001 /* ThreadWatcher.swift */, ); path = ViewModels; sourceTree = ""; @@ -540,6 +556,8 @@ isa = PBXGroup; children = ( 9599CA95254DF8C0002EB026 /* ThreadView.swift */, + CA000002CCCC000100000001 /* ReplyDraftSheet.swift */, + CA000001CCCC000100000001 /* QuotePreviewSheet.swift */, 9599CA9F254DFDA1002EB026 /* PostView.swift */, 95D804CD25677FF4005A3128 /* RepliesView.swift */, 9559F79228443E7D00F5803F /* ThreadAutoRefresher.swift */, @@ -810,6 +828,7 @@ AA000002AAAA000100000002 /* VideoPlayerControlsView.swift in Sources */, 9559F79328443E7D00F5803F /* ThreadAutoRefresher.swift in Sources */, BF000001EEEE000100000002 /* FavoritesBackup.swift in Sources */, + CA000003CCCC000100000002 /* ChanPreferences.swift in Sources */, EE000001EEEE000100000002 /* HiddenPostStore.swift in Sources */, EE000002EEEE000100000002 /* HiddenPostsView.swift in Sources */, DD000001DDDD000100000002 /* ThreadReadingProgress.swift in Sources */, @@ -827,6 +846,7 @@ 958D0998254D346A00AD4849 /* CatalogView.swift in Sources */, 95C73106256CA1F700F18257 /* UserSettings.swift in Sources */, 95C01FE927386D2B000D64B1 /* SettingsView.swift in Sources */, + CA000004CCCC000100000002 /* PostFiltersView.swift in Sources */, 951053712566018D002E4051 /* Board.swift in Sources */, 95105379256608E5002E4051 /* CommentParser.swift in Sources */, 95BF206B28C5A23D00DCB2D1 /* GIFView.swift in Sources */, @@ -834,6 +854,8 @@ 9524E1AF256F8E4D00D08075 /* Blur.swift in Sources */, 9559F78F2843B07D00F5803F /* NSFWTagView.swift in Sources */, 9599CA96254DF8C0002EB026 /* ThreadView.swift in Sources */, + CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */, + CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */, 95181791254CFB600032616E /* swiftchanApp.swift in Sources */, 95978F162CA7981600C520EF /* UINavigationController.swift in Sources */, 9587A51C255726D80028A143 /* CacheService.swift in Sources */, @@ -880,9 +902,11 @@ B3097C1AEE48110A2ECBCCA5 /* FavoriteThreadRow.swift in Sources */, 09B1DFCA9F650DAC27B6DB21 /* RecurringFavorite.swift in Sources */, 1A5F76AD0CB8E1E22917C335 /* RecurringFavoriteViewModel.swift in Sources */, + CA000005CCCC000100000002 /* ThreadWatcher.swift in Sources */, 6B83449B7D7B012156947080 /* RecurringFavoriteRow.swift in Sources */, 4B1F4B029F952735A2843BFF /* RecurringMatchSheet.swift in Sources */, BF000003EEEE000100000002 /* FavoritesBackupView.swift in Sources */, + CA000006CCCC000100000002 /* ThreadWatcherView.swift in Sources */, 2C999DFDE990B515FFB0B779 /* AddRecurringFavoriteSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/swiftchan/Models/ChanPreferences.swift b/swiftchan/Models/ChanPreferences.swift new file mode 100644 index 0000000..c5994da --- /dev/null +++ b/swiftchan/Models/ChanPreferences.swift @@ -0,0 +1,85 @@ +import SwiftUI +import FourChan +import Observation + +enum ChanTheme: String, CaseIterable, Identifiable { + case system = "System", yotsuba = "Yotsuba", yotsubaB = "Yotsuba B" + var id: String { rawValue } + var postBackground: Color { + switch self { + case .system: return Color(UIColor.systemBackground) + case .yotsuba: return Color(red: 0.94, green: 0.88, blue: 0.84) + case .yotsubaB: return Color(red: 0.84, green: 0.85, blue: 0.94) + } + } + var pageBackground: Color { + switch self { + case .system: return Color(UIColor.systemBackground) + case .yotsuba: return Color(red: 1, green: 1, blue: 0.93) + case .yotsubaB: return Color(red: 0.93, green: 0.95, blue: 1) + } + } +} + +struct PostFilterRule: Codable, Identifiable, Equatable { + enum Field: String, Codable, CaseIterable { case keyword = "Keyword", posterID = "Poster ID", name = "Name", tripcode = "Tripcode" } + enum Action: String, Codable, CaseIterable { case hide = "Hide", highlight = "Highlight" } + var id = UUID() + var board: String? + var field: Field + var pattern: String + var action: Action + + func matches(board: String, text: String, posterID: String?, name: String?, tripcode: String?) -> Bool { + guard self.board == nil || self.board == board.lowercased(), !pattern.isEmpty else { return false } + let value: String + switch field { + case .keyword: value = text + case .posterID: return posterID?.caseInsensitiveCompare(pattern) == .orderedSame + case .name: value = name ?? "" + case .tripcode: value = tripcode ?? "" + } + return value.localizedStandardContains(pattern) + } +} + +@Observable @MainActor +final class PostFilterStore { + static let shared = PostFilterStore(defaults: ProcessInfo.processInfo.arguments.contains("--ui-testing") ? nil : .standard) + private(set) var rules: [PostFilterRule] + @ObservationIgnored private let defaults: UserDefaults? + private let key = "postFilters.v1" + + init(defaults: UserDefaults? = .standard) { + self.defaults = defaults + rules = defaults?.data(forKey: key).flatMap { try? JSONDecoder().decode([PostFilterRule].self, from: $0) } ?? [] + } + + func add(_ rule: PostFilterRule) { + var rule = rule + rule.pattern = rule.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rule.pattern.isEmpty else { return } + if let board = rule.board { + guard let normalized = Deeplinker.normalizedBoard(board) else { return } + rule.board = normalized + } + rules.append(rule) + persist() + } + + func remove(_ rule: PostFilterRule) { + rules.removeAll { $0.id == rule.id } + persist() + } + + func effect(board: String, post: Post, text: String) -> PostFilterRule.Action? { + let matches = rules.filter { $0.matches(board: board, text: "\(post.sub?.clean ?? "") \(text)", + posterID: post.pid, name: post.name, tripcode: post.trip) } + if matches.contains(where: { $0.action == .hide }) { return .hide } + return matches.isEmpty ? nil : .highlight + } + + private func persist() { + if let data = try? JSONEncoder().encode(rules) { defaults?.set(data, forKey: key) } + } +} diff --git a/swiftchan/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 8f18137..62b0675 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -5,14 +5,27 @@ import FourChan /// Deterministic thread content for UI tests; never used in release builds or normal launches. enum ThreadUITestFixture { static func load(board: String, id: Int) throws -> ChanThread? { - guard ProcessInfo.processInfo.arguments.contains("--ui-thread-fixture"), board == "biz", id == 100 else { return nil } - let posts: [[String: Any]] = (100...114).map { number in + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("--ui-thread-fixture"), board == "biz", + id == 100 || (arguments.contains("--ui-general-rollover") && id == 200) else { return nil } + let posts: [[String: Any]] = (id...(id + 14)).map { number in var post: [String: Any] = ["no": number, "time": 1_700_000_000, "name": "Anonymous", "com": "Reply \(number). " + String(repeating: "A sample discussion about collecting coins and precious metals. ", count: 6)] - if number == 100 { post["sub"] = "/pmg/ - Precious Metals General" } + if number == id { + post["sub"] = "/pmg/ - Precious Metals General" + if arguments.contains("--ui-general-rollover"), id == 100 { post["archived"] = 1 } + } + if arguments.contains("--ui-quote-fixture"), number == 105 || number == 104 { + post["com"] = ">>\(number - 1)
Quoted reply \(number)" + } return post } return try JSONDecoder().decode(ChanThread.self, from: JSONSerialization.data(withJSONObject: ["posts": posts])) } + + static func catalog(board: String) throws -> Catalog? { + guard ProcessInfo.processInfo.arguments.contains("--ui-general-rollover"), board == "biz" else { return nil } + return try JSONDecoder().decode(Catalog.self, from: Data(#"[{"page":1,"threads":[{"no":100,"sub":"/pmg/ - Old"},{"no":200,"sub":"/pmg/ - New"}]}]"#.utf8)) + } } #endif diff --git a/swiftchan/ViewModels/RecurringFavoriteViewModel.swift b/swiftchan/ViewModels/RecurringFavoriteViewModel.swift index a50f024..09515d3 100644 --- a/swiftchan/ViewModels/RecurringFavoriteViewModel.swift +++ b/swiftchan/ViewModels/RecurringFavoriteViewModel.swift @@ -15,7 +15,12 @@ class RecurringFavoriteViewModel { @ObservationIgnored private let parseComment: (String) -> AttributedString @ObservationIgnored private var activeRequest: UUID? - init(fetchCatalog: @escaping CatalogLoader = { try await FourChanAsyncService.shared.getCatalog(boardName: $0) }, + init(fetchCatalog: @escaping CatalogLoader = { + #if DEBUG + if let catalog = try ThreadUITestFixture.catalog(board: $0) { return catalog } + #endif + return try await FourChanAsyncService.shared.getCatalog(boardName: $0) + }, parseComment: @escaping (String) -> AttributedString = { CommentParser(comment: $0).getComment() }) { self.fetchCatalog = fetchCatalog self.parseComment = parseComment @@ -47,7 +52,7 @@ class RecurringFavoriteViewModel { var state: MatchState = .idle - func findMatches(for favorite: RecurringFavorite) async { + func findMatches(for favorite: RecurringFavorite, excludingThreadID: Int? = nil) async { let request = UUID() activeRequest = request state = .loading @@ -60,7 +65,7 @@ class RecurringFavoriteViewModel { let pattern = favorite.searchPattern.lowercased() // Filter cheap title strings before parsing rich comments for the matching threads. let matchingThreads = catalog.flatMap(\.threads).enumerated().filter { - ($0.element.sub?.clean.lowercased() ?? "").contains(pattern) + $0.element.no > (excludingThreadID ?? 0) && ($0.element.sub?.clean.lowercased() ?? "").contains(pattern) }.sorted { $0.element.no > $1.element.no } let matches = matchingThreads.map { index, thread in SwiftchanPost(post: thread, boardName: favorite.boardName, diff --git a/swiftchan/ViewModels/ThreadViewModel.swift b/swiftchan/ViewModels/ThreadViewModel.swift index 6432c1b..3f3cd23 100644 --- a/swiftchan/ViewModels/ThreadViewModel.swift +++ b/swiftchan/ViewModels/ThreadViewModel.swift @@ -45,6 +45,7 @@ final class ThreadViewModel { private(set) var errorType = ErrorType.generic private(set) var refreshError: String? private(set) var isArchived = false + private(set) var isFromArchive = false private(set) var progressText = "" private(set) var downloadProgress = Progress() private var cancellables: Set = [] @@ -80,7 +81,7 @@ final class ThreadViewModel { } var archiveUrl: URL? { - FourplebsService.archiveUrl(board: boardName, threadNum: id) + isFromArchive ? FourplebsService.archiveUrl(board: boardName, threadNum: id) : nil } /// Lazily parses and caches the AttributedString for a post's comment. @@ -161,7 +162,7 @@ final class ThreadViewModel { LoadedPost(post: post, mediaURL: post.getMediaUrl(boardId: boardName), thumbnailURL: post.getMediaUrl(boardId: boardName, thumbnail: true)) } - apply(contents, archived: false) + apply(contents, archived: thread.posts.first?.archived == 1) return true } catch { handleFailure(error) @@ -188,7 +189,7 @@ final class ThreadViewModel { reportFailure(.notFound) return false } - apply(contents, archived: true) + apply(contents, archived: true, fromArchive: true) return true } catch { handleFailure(error) @@ -213,7 +214,7 @@ final class ThreadViewModel { } /// Install live and archived responses together so every index refers to the same snapshot. - private func apply(_ contents: [LoadedPost], archived: Bool) { + private func apply(_ contents: [LoadedPost], archived: Bool, fromArchive: Bool = false) { updateProgress(50, message: "Processing posts...") var mediaURLs: [URL] = [] var thumbnailURLs: [URL] = [] @@ -238,6 +239,7 @@ final class ThreadViewModel { buildPostIdIndex() setMedia(mediaUrls: mediaURLs, thumbnailMediaUrls: thumbnailURLs) isArchived = archived + isFromArchive = fromArchive errorType = .generic updateSearchResults() updateProgress(100, message: "Complete!") @@ -341,6 +343,7 @@ final class ThreadViewModel { let searchTextLowercased = searchText.lowercased() for (index, post) in posts.enumerated() { + if filterEffect(at: index) == .hide { continue } var matchesSearch = true if !searchText.isEmpty { @@ -380,6 +383,12 @@ final class ThreadViewModel { return filteredIndices } + func filterEffect(at index: Int) -> PostFilterRule.Action? { + guard posts.indices.contains(index) else { return nil } + return PostFilterStore.shared.effect(board: boardName, post: posts[index], + text: index < searchableComments.count ? searchableComments[index] : "") + } + func updateSearchResults() { searchResultIndices = getFilteredPostIndices() searchResultIndexSet = Set(searchResultIndices) diff --git a/swiftchan/ViewModels/ThreadWatcher.swift b/swiftchan/ViewModels/ThreadWatcher.swift new file mode 100644 index 0000000..7a7afed --- /dev/null +++ b/swiftchan/ViewModels/ThreadWatcher.swift @@ -0,0 +1,96 @@ +import Foundation +import Observation +import FourChan + +struct WatchTarget: Identifiable { + let board: String + let threadID: Int + var id: String { "\(board)/\(threadID)" } +} + +struct WatchedThread { + let replyIDs: [Int] + let archived: Bool + let missing: Bool + var lastModified: String? + + func unread(after highestReadID: Int?) -> Int? { + highestReadID.map { boundary in replyIDs.filter { $0 > boundary }.count } + } +} + +@Observable @MainActor +final class ThreadWatcher { + enum Update { case changed(WatchedThread), unchanged } + typealias Loader = (WatchTarget, String?) async throws -> Update + static let shared = ThreadWatcher() + private(set) var results: [String: WatchedThread] = [:] + private(set) var errors: [String: String] = [:] + private(set) var refreshing = false + private(set) var checkedAt: Date? + @ObservationIgnored private let loader: Loader + @ObservationIgnored private var lastAttempt: Date? + @ObservationIgnored private var lastRequest: Date? + @ObservationIgnored private let interval: TimeInterval + @ObservationIgnored private var cancelled = false + + init(interval: TimeInterval = 30, loader: @escaping Loader = ThreadWatcher.fetch) { + self.interval = interval + self.loader = loader + } + + func refresh(_ targets: [WatchTarget]) async { + guard !refreshing, lastAttempt.map({ Date().timeIntervalSince($0) >= interval }) ?? true else { return } + refreshing = true + cancelled = false + lastAttempt = Date() + defer { refreshing = false } + let keys = Set(targets.map(\.id)) + results = results.filter { keys.contains($0.key) } + errors = errors.filter { keys.contains($0.key) } + for target in targets { + do { + if interval > 0, let lastRequest { + let wait = max(0, 1.1 - Date().timeIntervalSince(lastRequest)) + try await Task.sleep(for: .seconds(wait)) + } + try Task.checkCancellation() + guard !cancelled else { return } + lastRequest = Date() + let update = try await loader(target, results[target.id]?.lastModified) + try Task.checkCancellation() + guard !cancelled else { return } + if case .changed(let value) = update { results[target.id] = value } + errors[target.id] = nil + } catch { + if Task.isCancelled || error is CancellationError { return } + errors[target.id] = "Couldn’t refresh" + } + } + checkedAt = Date() + } + + func cancel() { cancelled = true } + + private static func fetch(_ target: WatchTarget, lastModified: String?) async throws -> Update { + #if DEBUG + if let thread = try ThreadUITestFixture.load(board: target.board, id: target.threadID) { + return .changed(WatchedThread(replyIDs: thread.posts.dropFirst().map(\.no), archived: false, missing: false)) + } + #endif + guard let board = Deeplinker.normalizedBoard(target.board), target.threadID > 0, + let url = URL(string: "https://a.4cdn.org/\(board)/thread/\(target.threadID).json") else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.timeoutInterval = 15 + request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since") + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + if response.statusCode == 304 { return .unchanged } + if response.statusCode == 404 { return .changed(WatchedThread(replyIDs: [], archived: false, missing: true)) } + guard response.statusCode == 200 else { throw URLError(.badServerResponse) } + let thread = try JSONDecoder().decode(ChanThread.self, from: data) + guard let first = thread.posts.first else { throw URLError(.cannotParseResponse) } + return .changed(WatchedThread(replyIDs: thread.posts.dropFirst().map(\.no), archived: first.archived == 1, + missing: false, lastModified: response.value(forHTTPHeaderField: "Last-Modified"))) + } +} diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index 5e8a34a..d56f064 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -64,7 +64,8 @@ struct CatalogView: View { catalogViewModel.posts[$0].id } ForEach(Array(filteredPosts.enumerated()), id: \.element.id) { _, post in - if !post.post.isHidden(boardName: boardName) { + if !post.post.isHidden(boardName: boardName), + PostFilterStore.shared.effect(board: boardName, post: post.post, text: String(post.comment.characters)) != .hide { NavigationLink(value: post) { OPView( boardName: boardName, diff --git a/swiftchan/Views/Boards/Catalog/OPView.swift b/swiftchan/Views/Boards/Catalog/OPView.swift index 6c62e59..238483b 100644 --- a/swiftchan/Views/Boards/Catalog/OPView.swift +++ b/swiftchan/Views/Boards/Catalog/OPView.swift @@ -9,6 +9,8 @@ import SwiftUI import FourChan struct OPView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system + @AppStorage("compactPosts") private var compact = false @AppStorage("showOPPreview") var showOPPreview: Bool = false @Environment(AppState.self) var appState @Namespace var fullscreenNspace @@ -30,7 +32,8 @@ struct OPView: View { var body: some View { return ZStack(alignment: .topLeading) { Rectangle() - .fill(Colors.Op.background) + .fill(PostFilterStore.shared.effect(board: boardName, post: post, text: String(comment.characters)) == .highlight + ? Color.yellow.opacity(0.25) : theme.postBackground) .cornerRadius(Constants.backgroundCornerRadius) .border(Colors.Op.border) @@ -81,7 +84,7 @@ struct OPView: View { // comment Text(comment) .textSelection(.enabled) - .lineLimit(Constants.commentLineLimit) + .lineLimit(compact ? 3 : Constants.commentLineLimit) } .accessibilityIdentifier( AccessibilityIdentifiers.opButton(index) diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 31e48a8..14d83c4 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -9,6 +9,8 @@ import SwiftUI import FourChan struct PostView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system + @AppStorage("compactPosts") private var compact = false @Environment(ThreadViewModel.self) private var viewModel @Environment(AppState.self) private var appState @Environment(PresentationState.self) private var presentationState: PresentationState @@ -25,7 +27,7 @@ struct PostView: View { return ZStack(alignment: .topLeading) { Rectangle() - .fill(Colors.Post.background) + .fill(viewModel.filterEffect(at: index) == .highlight ? Color.yellow.opacity(0.25) : theme.postBackground) .cornerRadius(5) .border(Colors.Post.border) @@ -50,7 +52,7 @@ struct PostView: View { thumbnailUrl: media.thumbnailUrl ) .accessibilityIdentifier(AccessibilityIdentifiers.thumbnailMediaImage(index)) - .frame(width: UIScreen.halfWidth) + .frame(width: compact ? 100 : UIScreen.halfWidth) .scaledToFill() // VStack .galleryTransitionSource( id: mediaIndex, @@ -161,7 +163,8 @@ struct PostView: View { .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) - .padding(.vertical, 20) + .font(compact ? .callout : .body) + .padding(.vertical, compact ? 8 : 20) .accessibilityIdentifier(AccessibilityIdentifiers.postText(index)) // replies diff --git a/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift b/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift new file mode 100644 index 0000000..619a2ab --- /dev/null +++ b/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct QuotePreviewSheet: View { + let postID: Int + @Environment(ThreadViewModel.self) private var model + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var path: [Int] = [] + + var body: some View { + NavigationStack(path: $path) { + preview(postID) + .navigationDestination(for: Int.self) { preview($0) } + } + .environment(\.openURL, OpenURLAction { url in + if case .post(let id) = Deeplinker.getType(url: url), let number = Int(id) { + path.append(number) + return .handled + } + if let link = Deeplinker.getType(url: url), appState.openLink(link) { + dismiss() + return .handled + } + return .systemAction + }) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private func preview(_ number: Int) -> some View { + Group { + if let index = model.getPostIndexFromId(String(number)), + !model.posts[index].isHidden(boardName: model.boardName), model.filterEffect(at: index) != .hide { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(model.posts[index].name ?? "Anonymous").font(.headline) + Text(model.comment(at: index)).textSelection(.enabled) + ShareLink("Share Post", item: model.postURL(number)) + } + .frame(maxWidth: .infinity, alignment: .leading).padding() + } + .accessibilityIdentifier("Quote Preview \(number)") + } else { + ContentUnavailableView("Post Unavailable", systemImage: "text.bubble", + description: Text("This post is hidden or is not in the loaded thread.")) + } + } + .navigationTitle("#\(number)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() }.accessibilityIdentifier("Close Quote Preview") + } + } + } +} diff --git a/swiftchan/Views/Boards/Catalog/Thread/ReplyDraftSheet.swift b/swiftchan/Views/Boards/Catalog/Thread/ReplyDraftSheet.swift new file mode 100644 index 0000000..4f7f237 --- /dev/null +++ b/swiftchan/Views/Boards/Catalog/Thread/ReplyDraftSheet.swift @@ -0,0 +1,59 @@ +import SwiftUI +import SafariServices + +struct ReplyDraftSheet: View { + let board: String + let threadID: Int + let postID: Int + @Environment(\.dismiss) private var dismiss + @State private var text: String + @State private var showWebsite = false + private let key: String + private let defaults: UserDefaults? + + init(board: String, threadID: Int, postID: Int) { + self.board = board + self.threadID = threadID + self.postID = postID + key = "replyDraft.\(board).\(threadID)" + defaults = ProcessInfo.processInfo.arguments.contains("--ui-testing") ? nil : .standard + let quote = ">>\(postID)" + let saved = defaults?.string(forKey: key) ?? "" + _text = State(initialValue: saved.components(separatedBy: .newlines).contains(quote) + ? saved : saved + (saved.isEmpty ? "" : "\n") + quote + "\n") + } + + var body: some View { + NavigationStack { + Form { + Section { + TextEditor(text: $text).frame(minHeight: 180).accessibilityIdentifier("Reply Draft") + } header: { Text("Reply to /\(board)/ #\(postID)") } footer: { + Text("Your draft stays on this device. Copy it, then paste into 4chan’s reply box. Add attachments and complete CAPTCHA on the site.") + } + Button("Copy Draft & Open 4chan", systemImage: "arrow.up.right.square") { + UIPasteboard.general.string = text + showWebsite = true + } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Discard Draft", role: .destructive) { + defaults?.removeObject(forKey: key) + dismiss() + } + } + .navigationTitle("Reply Draft") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + .onChange(of: text) { defaults?.set(text, forKey: key) } + .sheet(isPresented: $showWebsite) { + PostingWebsite(url: URL(string: "https://boards.4chan.org/\(board)/thread/\(threadID)#p\(postID)")!) + } + } + } +} + +private struct PostingWebsite: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> SFSafariViewController { SFSafariViewController(url: url) } + func updateUIViewController(_ controller: SFSafariViewController, context: Context) { } +} diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index e5ddfb1..b1fcd23 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -12,6 +12,7 @@ import SpriteKit import SwiftData struct ThreadView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system @AppStorage("autoRefreshEnabled") private var autoRefreshEnabled = false @AppStorage("autoRefreshThreadTime") private var autoRefreshThreadTime = 10 @AppStorage("hideTabOnBoards") var hideTabOnBoards = true @@ -33,6 +34,9 @@ struct ThreadView: View { @State private var reading = ThreadReadingSession() @State private var visiblePostIDs: [Int] = [] @State private var showFollowGeneral = false + @State private var rolloverGeneral: RecurringFavorite? + @State private var nextGeneral: ThreadDestination? + @State private var draftPost: DraftPost? @State private var recentlyHidden: HiddenPost? private let initialPostID: Int? @State private var isThreadVisible = false @@ -84,7 +88,8 @@ struct ThreadView: View { spacing: 0 ) { ForEach(Array(viewModel.posts.enumerated()), id: \.element.no) { postIndex, post in - if !post.isHidden(boardName: viewModel.boardName) && viewModel.shouldShowPost(at: postIndex) { + if !post.isHidden(boardName: viewModel.boardName), viewModel.filterEffect(at: postIndex) != .hide, + viewModel.shouldShowPost(at: postIndex) { VStack(spacing: 0) { if rememberThreadPositions, post.no == newPostID { Text("New replies") @@ -289,6 +294,7 @@ struct ThreadView: View { .onChange(of: viewModel.searchFilters) { _, _ in viewModel.updateSearchResults() } + .onChange(of: PostFilterStore.shared.rules) { viewModel.updateSearchResults() } .sheet(isPresented: $showFollowGeneral) { if let suggestion = GeneralSuggestion(title: viewModel.title) { AddRecurringFavoriteSheet(searchPattern: suggestion.tag, boardName: viewModel.boardName, @@ -314,6 +320,8 @@ struct ThreadView: View { Image(systemName: isFavorited ? "heart.fill" : "heart") .foregroundColor(isFavorited ? .red : .primary) } + .accessibilityLabel(isFavorited ? "Remove Saved Thread" : "Save Thread") + .accessibilityIdentifier("Toggle Thread Favorite") } ToolbarItem(id: "toolbar-item-archive", placement: .navigationBarTrailing) { if viewModel.isArchived { @@ -341,16 +349,20 @@ struct ThreadView: View { updateAutoRefreshState() } } - .navigationDestination(isPresented: $showReply) { - PostView(index: replyId) + .sheet(isPresented: $showReply) { + QuotePreviewSheet(postID: viewModel.posts[replyId].no) .environment(viewModel) - .environment(presentationState) - .environment(\.openURL, postLinkAction) } .sheet(isPresented: $appState.showingBottomSheet) { if let post = appState.selectedBottomSheetPost, let index = viewModel.posts.firstIndex(of: post) { VStack(spacing: 24) { + Button("Reply on 4chan", systemImage: "arrowshape.turn.up.left") { + draftPost = DraftPost(id: post.no) + appState.showingBottomSheet = false + appState.selectedBottomSheetPost = nil + } + .accessibilityIdentifier("Draft Reply To Post") 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) @@ -371,7 +383,7 @@ struct ThreadView: View { .accessibilityIdentifier("Hide Selected Post") } .padding() - .presentationDetents([.height(230), .medium]) + .presentationDetents([.height(290), .medium]) .presentationDragIndicator(.visible) } } @@ -436,6 +448,34 @@ struct ThreadView: View { .padding() } } + .background(theme.pageBackground) + .safeAreaInset(edge: .top) { + if viewModel.isArchived || viewModel.errorType == .notFound { + let generals = followedGenerals.filter { $0.boardName == viewModel.boardName } + if !generals.isEmpty { + Menu { + ForEach(generals) { general in + Button(general.effectiveDisplayName) { rolloverGeneral = general } + } + } label: { + Label("Find Next General", systemImage: "arrow.right.circle") + .frame(maxWidth: .infinity).padding(10).background(.regularMaterial) + } + .accessibilityIdentifier("Find Next General") + } + } + } + .sheet(item: $rolloverGeneral) { general in + RecurringMatchSheet(favorite: general, excludingThreadID: viewModel.id) { post in + nextGeneral = ThreadDestination(board: post.boardName, id: post.id) + } + } + .navigationDestination(item: $nextGeneral) { destination in + ThreadView(boardName: destination.board, postNumber: destination.id) + } + .sheet(item: $draftPost) { post in + ReplyDraftSheet(board: viewModel.boardName, threadID: viewModel.id, postID: post.id) + } } @ViewBuilder @@ -544,7 +584,9 @@ struct ThreadView: View { } private var readablePostIDs: [Int] { - viewModel.posts.filter { !$0.isHidden(boardName: viewModel.boardName) }.map(\.no) + viewModel.posts.enumerated().filter { + !$0.element.isHidden(boardName: viewModel.boardName) && viewModel.filterEffect(at: $0.offset) != .hide + }.map { $0.element.no } } private var unreadPostIDs: [Int] { reading.unreadPostIDs(in: readablePostIDs) } @@ -651,6 +693,8 @@ struct ThreadView: View { } } +private struct DraftPost: Identifiable { let id: Int } + extension ThreadView { @ViewBuilder private var gallerySheetContent: some View { diff --git a/swiftchan/Views/ContentView.swift b/swiftchan/Views/ContentView.swift index c005136..ae95ff3 100644 --- a/swiftchan/Views/ContentView.swift +++ b/swiftchan/Views/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI import FourChan struct ContentView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system @Environment(\.scenePhase) private var scenePhase @AppStorage("biometricsEnabled") private var biometricsEnabled = false @AppStorage("didUnlockBiometrics") private var didUnlockBiometrics = true @@ -34,6 +35,7 @@ struct ContentView: View { } } .privacyView(enabled: $showPrivacyView) + .preferredColorScheme(theme == .system ? nil : .light) .environment(appState) .environment(\.openURL, OpenURLAction { url in appState.openLink(url) ? .handled : .systemAction diff --git a/swiftchan/Views/Favorites/AddRecurringFavoriteSheet.swift b/swiftchan/Views/Favorites/AddRecurringFavoriteSheet.swift index 5913e4a..1b4bbb5 100644 --- a/swiftchan/Views/Favorites/AddRecurringFavoriteSheet.swift +++ b/swiftchan/Views/Favorites/AddRecurringFavoriteSheet.swift @@ -47,6 +47,7 @@ struct AddRecurringFavoriteSheet: View { Section { TextField("Name (optional)", text: $displayName, prompt: Text("Precious Metals General"), axis: .vertical) + .autocorrectionDisabled() .lineLimit(1...3) .accessibilityIdentifier("General Name") } header: { diff --git a/swiftchan/Views/Favorites/FavoritesView.swift b/swiftchan/Views/Favorites/FavoritesView.swift index 7551a55..237db67 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 { + ThreadWatcherView() + } label: { + Label("Thread Watcher", systemImage: "eye") + } + .accessibilityIdentifier("Thread Watcher") + } ToolbarItem(placement: .topBarLeading) { NavigationLink { SavedRepliesView() diff --git a/swiftchan/Views/Favorites/RecurringMatchSheet.swift b/swiftchan/Views/Favorites/RecurringMatchSheet.swift index 29f6a49..db1763d 100644 --- a/swiftchan/Views/Favorites/RecurringMatchSheet.swift +++ b/swiftchan/Views/Favorites/RecurringMatchSheet.swift @@ -9,6 +9,7 @@ import SwiftUI struct RecurringMatchSheet: View { let favorite: RecurringFavorite + var excludingThreadID: Int? @State private var viewModel = RecurringFavoriteViewModel() let onSelect: (SwiftchanPost) -> Void @Environment(\.dismiss) private var dismiss @@ -43,7 +44,7 @@ struct RecurringMatchSheet: View { } .presentationDetents([.medium, .large]) .task(id: searchAttempt) { - await viewModel.findMatches(for: favorite) + await viewModel.findMatches(for: favorite, excludingThreadID: excludingThreadID) guard case .singleMatch(let post) = viewModel.state else { return } do { try await Task.sleep(for: .milliseconds(500)) } catch { return } guard !Task.isCancelled, case .singleMatch(let current) = viewModel.state, current.id == post.id else { return } diff --git a/swiftchan/Views/Favorites/ThreadWatcherView.swift b/swiftchan/Views/Favorites/ThreadWatcherView.swift new file mode 100644 index 0000000..1a89166 --- /dev/null +++ b/swiftchan/Views/Favorites/ThreadWatcherView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import SwiftData + +struct ThreadWatcherView: View { + @Query(sort: \FavoriteThread.savedAt, order: .reverse) private var favorites: [FavoriteThread] + @Environment(\.scenePhase) private var scenePhase + @State private var watcher = ThreadWatcher.shared + + private var targets: [WatchTarget] { favorites.map { WatchTarget(board: $0.boardName, threadID: $0.threadId) } } + + var body: some View { + List { + if favorites.isEmpty { + ContentUnavailableView("No Watched Threads", systemImage: "eye", + description: Text("Save a thread with the heart button to watch it here.")) + } + ForEach(favorites) { favorite in + let key = "\(favorite.boardName)/\(favorite.threadId)" + NavigationLink { + ThreadView(boardName: favorite.boardName, postNumber: favorite.threadId) + } label: { + VStack(alignment: .leading, spacing: 5) { + Text(favorite.title.isEmpty ? "Thread #\(favorite.threadId)" : favorite.title).lineLimit(2) + Text("/\(favorite.boardName)/ · \(status(key, favorite: favorite))") + .font(.caption).foregroundStyle(.secondary) + } + } + } + Section {} footer: { + Text("Pull to check saved threads. Checks run while this screen is open, at most once every 30 seconds. Unread counts use saved reading progress; unavailable threads may still be in an archive.") + } + } + .navigationTitle("Thread Watcher") + .overlay(alignment: .top) { if watcher.refreshing { ProgressView("Checking threads…").padding().background(.regularMaterial) } } + .task { await watcher.refresh(targets) } + .refreshable { await watcher.refresh(targets) } + .onDisappear { watcher.cancel() } + .onChange(of: scenePhase) { _, phase in + if phase != .active { watcher.cancel() } + } + } + + private func status(_ key: String, favorite: FavoriteThread) -> String { + if let error = watcher.errors[key] { return error } + guard let result = watcher.results[key] else { return "Not checked" } + if result.missing { return "Unavailable" } + let read = ThreadReadingStore.shared.progress(board: favorite.boardName, threadID: favorite.threadId)?.highestReadID + let count = result.unread(after: read).map { "\($0) unread" } ?? "\(result.replyIDs.count) replies" + return result.archived ? "Archived · \(count)" : count + } +} diff --git a/swiftchan/Views/PostFiltersView.swift b/swiftchan/Views/PostFiltersView.swift new file mode 100644 index 0000000..be68b2a --- /dev/null +++ b/swiftchan/Views/PostFiltersView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct PostFiltersView: View { + private enum Input: Hashable { case board, pattern } + @FocusState private var focused: Input? + @State private var board = "" + @State private var pattern = "" + @State private var field = PostFilterRule.Field.keyword + @State private var action = PostFilterRule.Action.hide + private let store = PostFilterStore.shared + + private var valid: Bool { + !pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (board.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || Deeplinker.normalizedBoard(board) != nil) + } + + var body: some View { + Form { + Section("Add Rule") { + TextField("Board (blank for all)", text: $board).accessibilityIdentifier("Filter Board") + .focused($focused, equals: .board) + Picker("Match", selection: $field) { ForEach(PostFilterRule.Field.allCases, id: \.self) { Text($0.rawValue) } } + TextField("Text to match", text: $pattern).accessibilityIdentifier("Filter Pattern") + .focused($focused, equals: .pattern) + Picker("Action", selection: $action) { ForEach(PostFilterRule.Action.allCases, id: \.self) { Text($0.rawValue) } } + Button("Add Filter") { + store.add(PostFilterRule(board: Deeplinker.normalizedBoard(board), field: field, + pattern: pattern.trimmingCharacters(in: .whitespacesAndNewlines), action: action)) + pattern = "" + focused = nil + }.disabled(!valid) + } + .textInputAutocapitalization(.never).autocorrectionDisabled() + Section { + ForEach(store.rules) { rule in + VStack(alignment: .leading) { + Text("\(rule.action.rawValue): \(rule.pattern)") + Text("\(rule.board.map { "/\($0)/" } ?? "All boards") · \(rule.field.rawValue)") + .font(.caption).foregroundStyle(.secondary) + } + .swipeActions { Button("Delete", role: .destructive) { store.remove(rule) } } + } + } header: { Text("Active Rules") } footer: { + Text("Matches ignore case. Poster IDs match exactly; other fields match part of the text. Hide takes priority over highlight. Swipe a rule to delete it. Rules stay on this device.") + } + } + .navigationTitle("Filters & Highlights") + } +} diff --git a/swiftchan/Views/SettingsView.swift b/swiftchan/Views/SettingsView.swift index 4cc7f0a..ffdb33a 100644 --- a/swiftchan/Views/SettingsView.swift +++ b/swiftchan/Views/SettingsView.swift @@ -8,6 +8,8 @@ import SwiftUI struct SettingsView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system + @AppStorage("compactPosts") private var compactPosts = false @AppStorage("fullImageForThumbnails") var fullImageForThumbnails = true @AppStorage("showGifThumbnails") var showGifThumbnails = true @AppStorage("showGalleryPreview") var showGalleryPreview = false @@ -27,6 +29,11 @@ struct SettingsView: View { NavigationStack { List { boardSection + Section("Appearance") { + Picker("Theme", selection: $theme) { ForEach(ChanTheme.allCases) { Text($0.rawValue).tag($0) } } + Toggle("Compact Posts", isOn: $compactPosts) + NavigationLink("Filters & Highlights") { PostFiltersView() } + } Section { NavigationLink { HiddenPostsView() diff --git a/swiftchanTests/RecurringFavoriteTests.swift b/swiftchanTests/RecurringFavoriteTests.swift index 5919a12..685c4b3 100644 --- a/swiftchanTests/RecurringFavoriteTests.swift +++ b/swiftchanTests/RecurringFavoriteTests.swift @@ -5,6 +5,16 @@ import FourChan @MainActor final class RecurringFavoriteTests: XCTestCase { + func testGeneralRolloverOnlyOffersNewerMatchesAndKeepsName() async throws { + let response = try catalog(#"[{"no":50,"sub":"/pmg/ old"},{"no":100,"sub":"/pmg/ current"},{"no":200,"sub":"/pmg/ next"},{"no":300,"sub":"Other"}]"#) + let favorite = RecurringFavorite(searchPattern: "/pmg/", boardName: "biz", displayName: "My Metals") + let model = RecurringFavoriteViewModel(fetchCatalog: { _ in response }) + await model.findMatches(for: favorite, excludingThreadID: 100) + guard case .singleMatch(let post) = model.state else { return XCTFail("Expected the newer general") } + XCTAssertEqual(post.id, 200) + XCTAssertEqual(favorite.displayName, "My Metals") + } + func testPreciousMetalsFormNormalizesOptionalSlashesAndWhitespace() throws { let draft = try XCTUnwrap(RecurringFavoriteDraft(boardName: " /BIZ/ ", searchPattern: " PMG ", displayName: " Precious Metals General ")) XCTAssertEqual(draft.boardName, "biz") diff --git a/swiftchanTests/ThreadViewModelTests.swift b/swiftchanTests/ThreadViewModelTests.swift index 3c3a680..ffb7076 100644 --- a/swiftchanTests/ThreadViewModelTests.swift +++ b/swiftchanTests/ThreadViewModelTests.swift @@ -5,6 +5,72 @@ import Observation @MainActor final class ThreadViewModelTests: XCTestCase { + func testNativeArchivedThreadDisablesLiveRefreshState() async throws { + let response = try thread(#"[{"no":100,"archived":1},{"no":105}]"#) + let model = ThreadViewModel(boardName: "biz", id: 100, fetchThread: { _, _, _ in response }) + await model.getPosts() + XCTAssertTrue(model.isArchived) + XCTAssertFalse(model.isFromArchive) + XCTAssertNil(model.archiveUrl) + } + + func testPersistentFiltersScopeAndHidePriority() throws { + let name = "PostFiltersTests.\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let store = PostFilterStore(defaults: defaults) + let post = try XCTUnwrap(thread(#"[{"no":100,"id":"abc123","name":"Alice","trip":"!trip"}]"#).posts.first) + store.add(PostFilterRule(board: "/BIZ/", field: .keyword, pattern: "Gold", action: .highlight)) + XCTAssertEqual(store.effect(board: "biz", post: post, text: "gold coins"), .highlight) + XCTAssertNil(store.effect(board: "g", post: post, text: "gold coins")) + let hide = PostFilterRule(board: nil, field: .posterID, pattern: "ABC123", action: .hide) + store.add(hide) + XCTAssertEqual(store.effect(board: "biz", post: post, text: "gold coins"), .hide) + let reloaded = PostFilterStore(defaults: defaults) + XCTAssertEqual(reloaded.rules.count, 2) + reloaded.remove(hide) + XCTAssertEqual(PostFilterStore(defaults: defaults).rules.count, 1) + XCTAssertFalse(PostFilterRule(board: nil, field: .posterID, pattern: "abc", action: .hide) + .matches(board: "biz", text: "", posterID: "abc123", name: nil, tripcode: nil)) + XCTAssertTrue(PostFilterRule(board: nil, field: .tripcode, pattern: "!trip", action: .hide) + .matches(board: "biz", text: "", posterID: nil, name: nil, tripcode: "!TRIP")) + } + + func testWatcherConditionalRefreshUnreadAndFailurePreserveLastResult() async { + let target = WatchTarget(board: "biz", threadID: 100) + var calls = 0 + let watcher = ThreadWatcher(interval: 0) { _, modified in + calls += 1 + if calls == 1 { + return .changed(WatchedThread(replyIDs: [103, 105, 110], archived: true, missing: false, lastModified: "date")) + } + XCTAssertEqual(modified, "date") + if calls == 2 { return .unchanged } + throw URLError(.notConnectedToInternet) + } + await watcher.refresh([target]) + XCTAssertEqual(watcher.results[target.id]?.unread(after: 105), 1) + XCTAssertNil(watcher.results[target.id]?.unread(after: nil)) + await watcher.refresh([target]) + XCTAssertEqual(watcher.results[target.id]?.replyIDs, [103, 105, 110]) + await watcher.refresh([target]) + XCTAssertNotNil(watcher.errors[target.id]) + XCTAssertEqual(watcher.results[target.id]?.replyIDs.count, 3) + } + + func testWatcherCooldownDoesNotRepeatRequests() async { + var calls = 0 + let watcher = ThreadWatcher(interval: 30) { _, _ in + calls += 1 + return .changed(WatchedThread(replyIDs: [], archived: false, missing: true)) + } + let targets = [WatchTarget(board: "biz", threadID: 100)] + await watcher.refresh(targets) + await watcher.refresh(targets) + XCTAssertEqual(calls, 1) + XCTAssertTrue(watcher.results["biz/100"]?.missing == true) + } + func testDiscardingTemporaryRefresherDoesNotInvalidatePresentingView() { let invalidation = expectation(description: "Temporary timer does not invalidate view construction") invalidation.isInverted = true diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 3593d5b..3bd6e13 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,63 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testQuotePreviewChainDismissesBackToReadingPosition() { + let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture"]) + openFixtureThread(in: app, anchor: "#p105") + app.links[">>104"].tap() + XCTAssertTrue(app.navigationBars["#104"].waitForExistence(timeout: 5)) + app.scrollViews["Quote Preview 104"].links[">>103"].tap() + XCTAssertTrue(app.navigationBars["#103"].waitForExistence(timeout: 5)) + attachScreenshot("Quote Chain Preview") + app.navigationBars["#103"].buttons["Close Quote Preview"].tap() + XCTAssertTrue(app.staticTexts["#105"].isHittable) + } + + func testArchivedGeneralFindsNewerThread() { + let app = launchThreadFixture(extraArguments: ["--ui-general-rollover"]) + openFixtureThread(in: app) + app.buttons["Follow This General"].tap() + app.buttons["Save General"].tap() + app.buttons["Find Next General"].tap() + app.buttons["Precious Metals General"].tap() + XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) + attachScreenshot("Next General") + } + + func testBoardFilterHidesMatchingPostInClassicCompactTheme() { + let app = launchThreadFixture(rememberProgress: false, extraArguments: ["-chanTheme", "Yotsuba B", "-compactPosts", "YES"]) + app.buttons["Settings"].tap() + app.buttons["Filters & Highlights"].tap() + app.textFields["Filter Board"].tap() + app.textFields["Filter Board"].typeText("biz") + app.textFields["Filter Pattern"].tap() + app.textFields["Filter Pattern"].typeText("Reply 100.") + app.buttons["Add Filter"].tap() + app.buttons["Boards"].tap() + openFixtureThread(in: app) + XCTAssertTrue(app.staticTexts["#101"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.staticTexts["#100"].exists) + attachScreenshot("Yotsuba B Compact Filtered Thread") + } + + func testReplyDraftQuotesPostAndWatcherChecksSavedThread() { + let app = launchThreadFixture(seedProgress: true) + openFixtureThread(in: app, anchor: "#p105") + app.buttons["Toggle Thread Favorite"].tap() + app.buttons["Post Options 105"].tap() + app.buttons["Draft Reply To Post"].tap() + let draft = app.textViews["Reply Draft"] + XCTAssertTrue(draft.waitForExistence(timeout: 5)) + XCTAssertTrue((draft.value as? String ?? "").contains(">>105")) + attachScreenshot("Quoted Reply Draft") + app.buttons["Done"].tap() + app.navigationBars.buttons.element(boundBy: 0).tap() + app.buttons["Favorites"].tap() + app.buttons["Thread Watcher"].tap() + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "unread")).firstMatch.waitForExistence(timeout: 5)) + attachScreenshot("Thread Watcher") + } + func testRecentThreadsResumePositionAndCanBeCleared() { let app = launchThreadFixture() openFixtureThread(in: app, anchor: "#p105") @@ -249,11 +306,12 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.staticTexts["#100"].isHittable) } - private func launchThreadFixture(seedProgress: Bool = false, rememberProgress: Bool = true) -> XCUIApplication { + private func launchThreadFixture(seedProgress: Bool = false, rememberProgress: Bool = true, extraArguments: [String] = []) -> XCUIApplication { continueAfterFailure = false let app = XCUIApplication() app.launchArguments = ["--ui-testing", "--ui-thread-fixture", "-biometricsEnabled", "NO", "-rememberThreadPositions", rememberProgress ? "YES" : "NO"] if seedProgress { app.launchArguments.append("--ui-reading-seed") } + app.launchArguments += extraArguments app.launch() return app } @@ -287,6 +345,8 @@ final class BrowsingUITests: XCTestCase { app.textFields["General Tag"].typeText("/pmg/") nameField(in: app).tap() nameField(in: app).typeText(name) + let completeName = NSPredicate(format: "value == %@", name) + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: completeName, object: nameField(in: app))], timeout: 10), .completed) XCTAssertTrue(app.buttons["Save General"].isEnabled) }