From f716a07537aa74c168c0efc0f603f4e70ab56a1f Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 27 Sep 2026 14:33:02 -0500 Subject: [PATCH 01/10] Add imageboard browsing tools and reply drafts --- README.md | 7 ++ swiftchan.xcodeproj/project.pbxproj | 24 +++++ swiftchan/Models/ChanPreferences.swift | 85 ++++++++++++++++ swiftchan/Services/ThreadUITestFixture.swift | 19 +++- .../RecurringFavoriteViewModel.swift | 11 ++- swiftchan/ViewModels/ThreadViewModel.swift | 17 +++- swiftchan/ViewModels/ThreadWatcher.swift | 96 +++++++++++++++++++ .../Views/Boards/Catalog/CatalogView.swift | 3 +- swiftchan/Views/Boards/Catalog/OPView.swift | 7 +- .../Boards/Catalog/Thread/PostView.swift | 9 +- .../Catalog/Thread/QuotePreviewSheet.swift | 57 +++++++++++ .../Catalog/Thread/ReplyDraftSheet.swift | 59 ++++++++++++ .../Boards/Catalog/Thread/ThreadView.swift | 58 +++++++++-- swiftchan/Views/ContentView.swift | 2 + .../Favorites/AddRecurringFavoriteSheet.swift | 1 + swiftchan/Views/Favorites/FavoritesView.swift | 8 ++ .../Views/Favorites/RecurringMatchSheet.swift | 3 +- .../Views/Favorites/ThreadWatcherView.swift | 51 ++++++++++ swiftchan/Views/PostFiltersView.swift | 49 ++++++++++ swiftchan/Views/SettingsView.swift | 7 ++ swiftchanTests/RecurringFavoriteTests.swift | 10 ++ swiftchanTests/ThreadViewModelTests.swift | 66 +++++++++++++ swiftchanUITests/BrowsingUITests.swift | 62 +++++++++++- 23 files changed, 686 insertions(+), 25 deletions(-) create mode 100644 swiftchan/Models/ChanPreferences.swift create mode 100644 swiftchan/ViewModels/ThreadWatcher.swift create mode 100644 swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift create mode 100644 swiftchan/Views/Boards/Catalog/Thread/ReplyDraftSheet.swift create mode 100644 swiftchan/Views/Favorites/ThreadWatcherView.swift create mode 100644 swiftchan/Views/PostFiltersView.swift 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) } From a11b40fcdf00db6b86058c5c708b8993e3d3d9d9 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Fri, 2 Oct 2026 22:07:21 -0500 Subject: [PATCH 02/10] Add adaptive board and thread browsing for iPhone Duo --- swiftchan.xcodeproj/project.pbxproj | 8 + swiftchan/Extensions/CoreExtensions.swift | 15 -- swiftchan/Services/ThreadUITestFixture.swift | 101 +++++++--- swiftchan/ViewModels/CatalogViewModel.swift | 5 +- .../Views/Boards/Catalog/CatalogView.swift | 143 ++++++++++---- .../Boards/Catalog/Thread/PostView.swift | 4 +- .../Media/Gallery/GalleryPreviewView.swift | 45 ++--- .../Views/Media/Gallery/GalleryView.swift | 184 ++++++++++-------- .../Views/Utility/RefreshProgressBar.swift | 37 +--- swiftchanUITests/BrowsingUITests.swift | 52 +++++ 10 files changed, 369 insertions(+), 225 deletions(-) diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 4a7391a..ea35c0b 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -1101,6 +1101,10 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; PRODUCT_BUNDLE_IDENTIFIER = vanities.swiftchan; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1129,6 +1133,10 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; PRODUCT_BUNDLE_IDENTIFIER = vanities.swiftchan; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/swiftchan/Extensions/CoreExtensions.swift b/swiftchan/Extensions/CoreExtensions.swift index 3e15daa..35091f9 100644 --- a/swiftchan/Extensions/CoreExtensions.swift +++ b/swiftchan/Extensions/CoreExtensions.swift @@ -296,21 +296,6 @@ extension CaseIterable where Self: Equatable { // https://stackoverflow.com/questions/31443645/simplest-way-to-throw-an-error-exception-with-a-custom-message-in-swift/40629365#40629365 extension String: @retroactive Error {} -extension UIScreen { - static var height: CGFloat { - self.main.bounds.height - } - static var halfHeight: CGFloat { - self.height / 2 - } - static var width: CGFloat { - self.main.bounds.width - } - static var halfWidth: CGFloat { - self.width / 2 - } -} - extension Collection where Element: Identifiable { func index(matching element: Element) -> Self.Index? { firstIndex(where: { $0.id == element.id }) diff --git a/swiftchan/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 62b0675..59c9419 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -1,31 +1,86 @@ #if DEBUG -import Foundation -import FourChan + import Foundation + import FourChan + import Kingfisher + import UIKit -/// 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? { - 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 == id { - post["sub"] = "/pmg/ - Precious Metals General" - if arguments.contains("--ui-general-rollover"), id == 100 { post["archived"] = 1 } + /// 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? { + let arguments = ProcessInfo.processInfo.arguments + guard arguments.contains("--ui-thread-fixture"), board == "biz", + id == 100 || ((arguments.contains("--ui-general-rollover") || arguments.contains("--ui-catalog-fixture")) && 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 == id { + post["sub"] = "/pmg/ - Precious Metals General" + if arguments.contains("--ui-general-rollover"), id == 100 { post["archived"] = 1 } + } + if arguments.contains("--ui-media-fixture"), number < id + 3 { + let timestamp = 9_000_000_000 + number + post["tim"] = timestamp + post["filename"] = "collection-study-\(number - id + 1)" + post["ext"] = ".png" + post["w"] = 1200 + post["h"] = 900 + post["tn_w"] = 250 + post["tn_h"] = 188 + post["fsize"] = 100_000 + seedMedia(timestamp: timestamp, index: number - id) + } + if arguments.contains("--ui-quote-fixture"), number == 105 || number == 104 { + post["com"] = ">>\(number - 1)
Quoted reply \(number)" + } + return post } - if arguments.contains("--ui-quote-fixture"), number == 105 || number == 104 { - post["com"] = ">>\(number - 1)
Quoted reply \(number)" + return try JSONDecoder().decode(ChanThread.self, from: JSONSerialization.data(withJSONObject: ["posts": posts])) + } + + /// Invented local artwork keeps gallery tests independent of live posts. + private static func seedMedia(timestamp: Int, index: Int) { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(size: CGSize(width: 1200, height: 900), format: format).image { renderer in + let context = renderer.cgContext + let backgrounds: [UIColor] = [.systemIndigo, .systemTeal, .systemBrown] + backgrounds[index % backgrounds.count].setFill() + context.fill(CGRect(x: 0, y: 0, width: 1200, height: 900)) + for coin in 0..<3 { + let rect = CGRect(x: 150 + coin * 310, y: 240 + (coin % 2) * 80, width: 260, height: 260) + context.setShadow(offset: CGSize(width: 8, height: 12), blur: 20, color: UIColor.black.withAlphaComponent(0.3).cgColor) + UIColor(red: 0.88, green: 0.72, blue: 0.35, alpha: 1).setFill() + context.fillEllipse(in: rect) + context.setShadow(offset: .zero, blur: 0) + UIColor(red: 0.98, green: 0.9, blue: 0.62, alpha: 1).setStroke() + context.setLineWidth(6) + context.strokeEllipse(in: rect.insetBy(dx: 18, dy: 18)) + } + ("COLLECTION STUDY \(index + 1)" as NSString).draw(at: CGPoint(x: 100, y: 100), withAttributes: [ + .font: UIFont.systemFont(ofSize: 44, weight: .semibold), .foregroundColor: UIColor.white + ]) + ("Invented gallery fixture" as NSString).draw(at: CGPoint(x: 100, y: 750), withAttributes: [ + .font: UIFont.systemFont(ofSize: 28), .foregroundColor: UIColor.white.withAlphaComponent(0.7) + ]) } - return post + ImageCache.default.store(image, forKey: "https://i.4cdn.org/biz/\(timestamp).png", toDisk: false) + ImageCache.default.store(image, forKey: "https://i.4cdn.org/biz/\(timestamp)s.jpg", toDisk: false) } - 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)) + static func catalog(board: String) throws -> Catalog? { + let arguments = ProcessInfo.processInfo.arguments + guard board == "biz" else { return nil } + if arguments.contains("--ui-catalog-fixture") { + let threads: [[String: Any]] = [100, 200].map { id in + seedMedia(timestamp: 9_000_000_000 + id, index: id == 100 ? 0 : 1) + return ["no": id, "sub": id == 100 ? "Coin collecting — this week's finds" : "Designing a display for a collection", + "com": "A sample discussion about collecting coins and precious metals.", "replies": 14, "images": 3, + "tim": 9_000_000_000 + id, "ext": ".png", "tn_w": 250, "tn_h": 188] + } + return try JSONDecoder().decode(Catalog.self, from: JSONSerialization.data(withJSONObject: [["page": 1, "threads": threads]])) + } + guard arguments.contains("--ui-general-rollover") 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/CatalogViewModel.swift b/swiftchan/ViewModels/CatalogViewModel.swift index f83ecd1..793f966 100644 --- a/swiftchan/ViewModels/CatalogViewModel.swift +++ b/swiftchan/ViewModels/CatalogViewModel.swift @@ -134,7 +134,10 @@ class CatalogViewModel { } init(boardName: String, fetchCatalog: @escaping CatalogLoader = { board, progress in - try await FourChanAsyncService.shared.getCatalog(boardName: board, progress: progress) +#if DEBUG + if let fixture = try ThreadUITestFixture.catalog(board: board) { return fixture } +#endif + return try await FourChanAsyncService.shared.getCatalog(boardName: board, progress: progress) }) { self.boardName = boardName self.fetchCatalog = fetchCatalog diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index d56f064..65fd791 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -13,6 +13,7 @@ struct CatalogView: View { @AppStorage("hideTabOnBoards") var hideTabOnBoards = false @Environment(AppState.self) var appState + @Environment(\.dismiss) private var dismiss var boardName: String @State var catalogViewModel: CatalogViewModel @@ -20,10 +21,8 @@ struct CatalogView: View { @State var isSearching: Bool = false @State var showAddRecurringSheet: Bool = false - let columns = [ - GridItem(.flexible(), spacing: 0, alignment: .top), - GridItem(.flexible(), spacing: 0, alignment: .top) - ] + @State private var selectedThread: SwiftchanPost? + @State private var usesThreadWorkspace = false @State private var scene: SKScene = { let s = SnowScene() @@ -53,42 +52,45 @@ struct CatalogView: View { } } case .loaded: - ScrollViewReader { reader in - ScrollView(.vertical, showsIndicators: true) { - LazyVGrid( - columns: columns, - alignment: .center, - spacing: 0 - ) { - let highlightedPostID = catalogViewModel.getCurrentSearchResultPostIndex().map { - catalogViewModel.posts[$0].id - } - ForEach(Array(filteredPosts.enumerated()), id: \.element.id) { _, post in - 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, - post: post - ) + GeometryReader { geometry in + if geometry.size.width >= 760 { + NavigationSplitView { + catalogPosts(filteredPosts, workspace: true) + .navigationSplitViewColumnWidth(min: 260, ideal: 320, max: 400) + } detail: { + NavigationStack { + Group { + if let selectedThread { + ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id) + .id(selectedThread.id) + .accessibilityIdentifier("CatalogThreadDetail") + } else { + ContentUnavailableView { + Label("Open a Thread", systemImage: "text.bubble") + } description: { + Text("Choose a discussion on the left. Browse the board while you read.") + } + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back to boards", systemImage: "chevron.left") { dismiss() } } - .buttonStyle(PlainButtonStyle()) - .id(post.id) - .opacity(isSearching && highlightedPostID != nil ? - (post.id == highlightedPostID ? 1.0 : 0.5) : 1.0) } } } + .navigationSplitViewStyle(.balanced) + .accessibilityIdentifier("BoardThreadWorkspace") + } else { + catalogPosts(filteredPosts, workspace: false) } - .onChange(of: catalogViewModel.currentSearchResultIndex) { _, _ in - if let postIndex = catalogViewModel.getCurrentSearchResultPostIndex(), - postIndex < catalogViewModel.posts.count { - let postId = catalogViewModel.posts[postIndex].id - withAnimation { - reader.scrollTo(postId, anchor: .center) - } - } - } + } + .onGeometryChange(for: Bool.self) { $0.size.width >= 760 } action: { usesThreadWorkspace = $0 } + .navigationDestination(item: Binding( + get: { usesThreadWorkspace ? nil : selectedThread }, + set: { if !usesThreadWorkspace { selectedThread = $0 } } + )) { post in + ThreadView(boardName: post.boardName, postNumber: post.id) } .overlay(alignment: .bottom) { if isSearching && !catalogViewModel.searchResultIndices.isEmpty { @@ -123,12 +125,6 @@ struct CatalogView: View { trailing: settingsButton ) - .navigationDestination(for: SwiftchanPost.self) { post in - ThreadView( - boardName: post.boardName, - postNumber: post.post.id - ) - } .searchable(text: $catalogViewModel.searchText, isPresented: $isSearching) .onChange(of: catalogViewModel.searchText) { _, _ in catalogViewModel.updateSearchResults() @@ -178,6 +174,71 @@ struct CatalogView: View { } } + private func catalogPosts(_ posts: [SwiftchanPost], workspace: Bool) -> some View { + ScrollViewReader { reader in + ScrollView(.vertical) { + LazyVGrid(columns: workspace ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 155), spacing: 0)], + alignment: .center, spacing: workspace ? 8 : 0) { + ForEach(posts) { post in + if !post.post.isHidden(boardName: boardName), + PostFilterStore.shared.effect(board: boardName, post: post.post, text: String(post.comment.characters)) != .hide { + Button { + selectedThread = post + } label: { + if workspace { + catalogRow(post) + } else { + OPView(boardName: boardName, post: post) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("CatalogThread\(post.id)") + .id(post.id) + .opacity(isSearching && !catalogViewModel.searchResultIndices.isEmpty ? + (catalogViewModel.getCurrentSearchResultPostIndex().map { catalogViewModel.posts[$0].id } == post.id ? 1 : 0.5) : 1) + } + } + } + .padding(workspace ? 10 : 0) + } + .accessibilityIdentifier("Catalog Threads") + .onChange(of: catalogViewModel.currentSearchResultIndex) { _, _ in + if let index = catalogViewModel.getCurrentSearchResultPostIndex(), catalogViewModel.posts.indices.contains(index) { + withAnimation { reader.scrollTo(catalogViewModel.posts[index].id, anchor: .center) } + } + } + } + } + + private func catalogRow(_ post: SwiftchanPost) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + if let url = post.post.getMediaUrl(boardId: boardName), + let thumbnail = post.post.getMediaUrl(boardId: boardName, thumbnail: true) { + ThumbnailMediaView(url: url, thumbnailUrl: thumbnail) + .frame(width: 72, height: 72) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .allowsHitTesting(false) + } + VStack(alignment: .leading, spacing: 5) { + Text(post.post.sub?.clean ?? "Thread #\(post.id)") + .font(.subheadline.weight(.semibold)) + .lineLimit(3) + Text("\(post.post.replies ?? 0) replies · \(post.post.images ?? 0) images") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text(post.comment).font(.subheadline).lineLimit(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(selectedThread?.id == post.id ? Color.accentColor.opacity(0.12) : Color(uiColor: .secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(selectedThread?.id == post.id ? Color.accentColor.opacity(0.5) : .clear)) + } + var settingsButton: some View { Button(action: { withAnimation { diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 14d83c4..80d6b5c 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -52,7 +52,9 @@ struct PostView: View { thumbnailUrl: media.thumbnailUrl ) .accessibilityIdentifier(AccessibilityIdentifiers.thumbnailMediaImage(index)) - .frame(width: compact ? 100 : UIScreen.halfWidth) + .containerRelativeFrame(.horizontal) { width, _ in + min(compact ? 100 : 240, width * 0.45) + } .scaledToFill() // VStack .galleryTransitionSource( id: mediaIndex, diff --git a/swiftchan/Views/Media/Gallery/GalleryPreviewView.swift b/swiftchan/Views/Media/Gallery/GalleryPreviewView.swift index c9aa34c..88d4c42 100644 --- a/swiftchan/Views/Media/Gallery/GalleryPreviewView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryPreviewView.swift @@ -12,35 +12,32 @@ struct GalleryPreviewView: View { @Binding var selection: Int var body: some View { - return ScrollViewReader { value in - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .center, - spacing: nil) { - ForEach(viewModel.media.indices, id: \.self) { index in - let media = viewModel.media[index] - let url = media.url - let thumbnailUrl = media.thumbnailUrl - - ThumbnailMediaView( - url: url, - thumbnailUrl: thumbnailUrl - ) - .onTapGesture { - selection = index + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.media.indices, id: \.self) { index in + let media = viewModel.media[index] + ThumbnailMediaView(url: media.url, thumbnailUrl: media.thumbnailUrl) + .frame(width: min(120, max(44, geometry.size.width / 5)), height: 88) + .clipped() + .border(selection == index ? Color.green : Color.clear, width: 2) + .contentShape(.rect) + .onTapGesture { selection = index } + .id(index) } - .id(index) - .border(selection == index ? Color.green : Color.clear, width: 2) - .frame(width: UIScreen.width/5) + } + .padding(.horizontal, 8) + } + .onChange(of: selection) { + withAnimation(.linear(duration: 0.2)) { + proxy.scrollTo(selection, anchor: .center) } } - .onChange(of: selection) { - withAnimation(.linear(duration: 0.2)) { - value.scrollTo(selection) - } - } + .onAppear { proxy.scrollTo(selection, anchor: .center) } } } - .frame(width: UIScreen.width, height: UIScreen.height / 10) + .frame(height: 96) } } diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index 53e49bb..4eb8d13 100644 --- a/swiftchan/Views/Media/Gallery/GalleryView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryView.swift @@ -35,67 +35,86 @@ struct GalleryView: View { } var body: some View { - @Bindable var state = state - - return ZStack { + ZStack { Color.black.ignoresSafeArea() - VerticalPagerView( - selection: $selection, - pageCount: viewModel.media.count, - canScroll: canPage, - onPageChanged: { index in - updateActiveMedia(to: index) - }, - onDragChanged: { translation in - handlePagerDragChanged(translation) - }, - onDragEnded: { - handlePagerDragEnded() - }, - onScrollViewCaptured: { scrollView in - guard pagerScrollView !== scrollView else { return } - DispatchQueue.main.async { - pagerScrollView = scrollView - scrollView.alwaysBounceVertical = true - scrollView.alwaysBounceHorizontal = false - } - }, - content: { pageIndex in - mediaView(for: pageIndex) + gallerySurface + } + .onDisappear { + restorePagerScrolling() + } + .gesture(canShowPreview && showGalleryPreview ? showPreviewTap() : nil) + // Block the zoom transition's pan-to-dismiss while pinch-zoomed into + // media or scrubbing video, so those gestures keep priority. + .interactiveDismissDisabled(isZoomed || isSeeking) + .statusBar(hidden: true) + } + + private var gallerySurface: some View { + ZStack { galleryPager; galleryChrome } + } + + private var galleryPager: some View { + VerticalPagerView( + selection: $selection, + pageCount: viewModel.media.count, + canScroll: canPage, + onPageChanged: { index in + updateActiveMedia(to: index) + }, + onDragChanged: { translation in + handlePagerDragChanged(translation) + }, + onDragEnded: { + handlePagerDragEnded() + }, + onScrollViewCaptured: { scrollView in + guard pagerScrollView !== scrollView else { return } + DispatchQueue.main.async { + pagerScrollView = scrollView + scrollView.alwaysBounceVertical = true + scrollView.alwaysBounceHorizontal = false } - ) - .onChange(of: state.galleryIndex) { _, newValue in - guard selection != newValue, - viewModel.media.indices.contains(newValue) else { return } - // Only move the pager here. Activation (and video playback) is - // driven by onPageChanged, which fires when the transition ends. - selection = newValue + }, + content: { pageIndex in + mediaView(for: pageIndex) } - .onChange(of: viewModel.media) { _, newMedia in - guard newMedia.indices.contains(selection) else { - let newIndex = max(0, min(selection, max(newMedia.count - 1, 0))) - if newIndex != selection { - selection = newIndex - } - updateActiveMedia(to: newIndex) - return + ) + .onChange(of: state.galleryIndex) { _, newValue in + guard selection != newValue, + viewModel.media.indices.contains(newValue) else { return } + // Only move the pager here. Activation (and video playback) is + // driven by onPageChanged, which fires when the transition ends. + selection = newValue + } + .onChange(of: viewModel.media) { _, newMedia in + guard newMedia.indices.contains(selection) else { + let newIndex = max(0, min(selection, max(newMedia.count - 1, 0))) + if newIndex != selection { + selection = newIndex } + updateActiveMedia(to: newIndex) + return } - .onAppear { - let resolvedIndex: Int - if viewModel.media.indices.contains(index) { - resolvedIndex = index - } else { - resolvedIndex = max(0, min(index, max(viewModel.media.count - 1, 0))) - } - selection = resolvedIndex - updateActiveMedia(to: resolvedIndex) - isZoomed = false - isSeeking = false - refreshPagingState() + } + .onAppear { + let resolvedIndex: Int + if viewModel.media.indices.contains(index) { + resolvedIndex = index + } else { + resolvedIndex = max(0, min(index, max(viewModel.media.count - 1, 0))) } + selection = resolvedIndex + updateActiveMedia(to: resolvedIndex) + isZoomed = false + isSeeking = false + refreshPagingState() + } + } + private var galleryChrome: some View { + @Bindable var state = state + return ZStack { if showPreview { VStack { Spacer() @@ -105,17 +124,8 @@ struct GalleryView: View { } } } - .overlay(alignment: .topLeading) { - closeButton - } - .onDisappear { - restorePagerScrolling() - } - .gesture(canShowPreview && showGalleryPreview ? showPreviewTap() : nil) - // Block the zoom transition's pan-to-dismiss while pinch-zoomed into - // media or scrubbing video, so those gestures keep priority. - .interactiveDismissDisabled(isZoomed || isSeeking) - .statusBar(hidden: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topLeading) { closeButton } } private var closeButton: some View { @@ -127,10 +137,12 @@ struct GalleryView: View { .symbolRenderingMode(.hierarchical) .foregroundStyle(.white) .shadow(radius: 4) + .frame(width: 44, height: 44) } .padding(16) .opacity(isZoomed ? 0 : 1) .animation(.easeInOut(duration: 0.15), value: isZoomed) + .accessibilityLabel("Close gallery") .accessibilityIdentifier(AccessibilityIdentifiers.galleryCloseButton) } @@ -237,28 +249,28 @@ extension GalleryView: Buildable { } #if DEBUG -#Preview { - let viewModel = ThreadViewModel(boardName: "pol", id: 0) - let urls = [ - URLExamples.image, - URLExamples.gif, - URLExamples.webm - ] - viewModel.setMedia(mediaUrls: urls, thumbnailMediaUrls: urls) + #Preview { + let viewModel = ThreadViewModel(boardName: "pol", id: 0) + let urls = [ + URLExamples.image, + URLExamples.gif, + URLExamples.webm + ] + viewModel.setMedia(mediaUrls: urls, thumbnailMediaUrls: urls) - return Group { - GalleryView(index: 0) - .environment(viewModel) - .environment(PresentationState()) - .environment(AppState()) - GalleryView(index: 1) - .environment(viewModel) - .environment(PresentationState()) - .environment(AppState()) - GalleryView(index: 2) - .environment(viewModel) - .environment(PresentationState()) - .environment(AppState()) + return Group { + GalleryView(index: 0) + .environment(viewModel) + .environment(PresentationState()) + .environment(AppState()) + GalleryView(index: 1) + .environment(viewModel) + .environment(PresentationState()) + .environment(AppState()) + GalleryView(index: 2) + .environment(viewModel) + .environment(PresentationState()) + .environment(AppState()) + } } -} #endif diff --git a/swiftchan/Views/Utility/RefreshProgressBar.swift b/swiftchan/Views/Utility/RefreshProgressBar.swift index 9e59ca5..476c2ba 100644 --- a/swiftchan/Views/Utility/RefreshProgressBar.swift +++ b/swiftchan/Views/Utility/RefreshProgressBar.swift @@ -25,48 +25,17 @@ struct RefreshProgressBar: View { ZStack(alignment: .leading) { // Background track - CornerWrapShape(isIPhone: UIDevice.current.userInterfaceIdiom == .phone) + Capsule() .fill(Color.gray.opacity(0.15)) .frame(width: width, height: 4) // Progress fill - CornerWrapShape(isIPhone: UIDevice.current.userInterfaceIdiom == .phone) + Capsule() .fill(barColor) - .frame(width: width * progress, height: 4) + .frame(width: width * min(1, max(0, progress)), height: 4) .animation(.linear(duration: 1), value: progress) } } .frame(height: 4) } } - -struct CornerWrapShape: Shape { - let isIPhone: Bool - - func path(in rect: CGRect) -> Path { - var path = Path() - - if isIPhone { - let cornerRadius: CGFloat = 75 - let barHeight: CGFloat = rect.height - - path.move(to: CGPoint(x: 0, y: rect.height)) - path.addLine(to: CGPoint(x: 0, y: rect.height - cornerRadius)) - path.addQuadCurve( - to: CGPoint(x: cornerRadius, y: rect.height - barHeight), - control: CGPoint(x: 0, y: rect.height - barHeight) - ) - path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: rect.height - barHeight)) - path.addQuadCurve( - to: CGPoint(x: rect.width, y: rect.height - cornerRadius), - control: CGPoint(x: rect.width, y: rect.height - barHeight) - ) - path.addLine(to: CGPoint(x: rect.width, y: rect.height)) - path.addLine(to: CGPoint(x: 0, y: rect.height)) - } else { - path.addRect(CGRect(x: 0, y: rect.height - rect.height, width: rect.width, height: rect.height)) - } - - return path - } -} diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 3bd6e13..cdf9926 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,58 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testDuoWalkthroughAndGallery() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", "Run on an open Duo with TEST_RUNNER_DUO_CAPTURE=1.") + XCUIDevice.shared.orientation = .landscapeLeft + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture", "-showGalleryPreview", "YES"]) + captureDuo(app, "01-boards") + app.buttons["Favorites"].tap() + captureDuo(app, "02-favorites") + app.buttons["Settings"].tap() + captureDuo(app, "03-settings") + app.buttons["Filters & Highlights"].tap() + captureDuo(app, "04-filters") + app.buttons["Boards"].tap() + app.buttons["Open Link Button"].tap() + let boardLink = app.textFields["Open Link URL"] + XCTAssertTrue(boardLink.waitForExistence(timeout: 5)) + boardLink.tap() + boardLink.typeText("https://boards.4chan.org/biz/") + app.buttons["Open Link Confirm"].tap() + XCTAssertTrue(app.buttons["CatalogThread100"].waitForExistence(timeout: 10)) + app.buttons["CatalogThread100"].tap() + XCTAssertTrue(app.staticTexts["#100"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch.exists) + captureDuo(app, "04b-board-and-thread") + app.buttons["CatalogThread200"].tap() + XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) + captureDuo(app, "04c-switch-thread") + app.buttons["Back to boards"].tap() + openFixtureThread(in: app) + captureDuo(app, "05-thread") + app.tapThumbnailMedia(0) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 10)) + captureDuo(app, "06-gallery") + app.galleryMediaImage(0).tap() + captureDuo(app, "07-gallery-filmstrip") + app.galleryMediaImage(0).swipeUp() + XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 10)) + XCTAssertTrue(app.galleryMediaImage(1).isHittable) + captureDuo(app, "08-gallery-next-image") + app.buttons["Close gallery"].tap() + app.buttons["Follow This General"].tap() + captureDuo(app, "09-follow-general") + app.buttons["Cancel"].tap() + app.buttons["Post Options 100"].tap() + captureDuo(app, "10-post-actions") + } + + private func captureDuo(_ app: XCUIApplication, _ name: String) { + print("DUO_CAPTURE:swiftchan-\(name)") + Thread.sleep(forTimeInterval: 4) + attachScreenshot("Duo \(name)") + } + func testQuotePreviewChainDismissesBackToReadingPosition() { let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture"]) openFixtureThread(in: app, anchor: "#p105") From 661abbc82672cc8c30fccdcd1cbc3d0408b108d0 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sat, 3 Oct 2026 11:48:40 -0500 Subject: [PATCH 03/10] Keep Duo workspace clear of hinge and bump to 1.3 --- swiftchan.xcodeproj/project.pbxproj | 8 +++--- swiftchan/Info.plist | 4 +-- .../Views/Boards/Catalog/CatalogView.swift | 27 +++++++++++++++++-- .../Boards/Catalog/Thread/ThreadView.swift | 6 +++-- swiftchanUITests/BrowsingUITests.swift | 27 +++++++++++++++++++ 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index ea35c0b..e979859 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -1089,7 +1089,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1100,7 +1100,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; @@ -1121,7 +1121,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1132,7 +1132,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1]" = "$(inherited) IPHONE_DUO_LAYOUTS"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; diff --git a/swiftchan/Info.plist b/swiftchan/Info.plist index 1724d2d..7842ab8 100644 --- a/swiftchan/Info.plist +++ b/swiftchan/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.1 + $(MARKETING_VERSION) CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 1.1 + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index 65fd791..c24b4cc 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -53,15 +53,21 @@ struct CatalogView: View { } case .loaded: GeometryReader { geometry in + let layout = workspaceLayout(in: geometry) if geometry.size.width >= 760 { NavigationSplitView { catalogPosts(filteredPosts, workspace: true) - .navigationSplitViewColumnWidth(min: 260, ideal: 320, max: 400) + .navigationSplitViewColumnWidth( + min: layout.sidebarWidth ?? 260, + ideal: layout.sidebarWidth ?? 320, + max: layout.sidebarWidth ?? 400 + ) } detail: { NavigationStack { Group { if let selectedThread { - ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id) + ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id, + showsNavigationTitle: false) .id(selectedThread.id) .accessibilityIdentifier("CatalogThreadDetail") } else { @@ -78,6 +84,7 @@ struct CatalogView: View { } } } + .padding(.leading, layout.hingeGap) } .navigationSplitViewStyle(.balanced) .accessibilityIdentifier("BoardThreadWorkspace") @@ -174,6 +181,22 @@ struct CatalogView: View { } } + private func workspaceLayout(in geometry: GeometryProxy) -> (sidebarWidth: CGFloat?, hingeGap: CGFloat) { + #if IPHONE_DUO_LAYOUTS + if #available(iOS 27.1, *), + let fold = geometry.reservedRegions(kind: .division).first(where: { + $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < geometry.size.width + }) { + // Preserve the same navigation containers across pose changes, + // while moving both columns' content clear of the physical hinge. + let leadingWidth = fold.frame.minX - fold.margins.leading + let gap = fold.frame.width + fold.margins.leading + fold.margins.trailing + return (leadingWidth, gap) + } + #endif + return (nil, 0) + } + private func catalogPosts(_ posts: [SwiftchanPost], workspace: Bool) -> some View { ScrollViewReader { reader in ScrollView(.vertical) { diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index b1fcd23..4a762c4 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -39,6 +39,7 @@ struct ThreadView: View { @State private var draftPost: DraftPost? @State private var recentlyHidden: HiddenPost? private let initialPostID: Int? + private let showsNavigationTitle: Bool @State private var isThreadVisible = false @State private var isSearching: Bool = false @Namespace private var galleryNamespace @@ -54,8 +55,9 @@ struct ThreadView: View { @State private var isFavorited: Bool = false - init(boardName: String, postNumber: PostNumber, postID: Int? = nil) { + init(boardName: String, postNumber: PostNumber, postID: Int? = nil, showsNavigationTitle: Bool = true) { initialPostID = postID + self.showsNavigationTitle = showsNavigationTitle self._viewModel = State( wrappedValue: ThreadViewModel( boardName: boardName, @@ -286,7 +288,7 @@ struct ThreadView: View { } .environment(presentationState) .environment(\.galleryNamespace, galleryNamespace) - .navigationTitle(viewModel.title) + .navigationTitle(showsNavigationTitle ? viewModel.title : "") .searchable(text: $viewModel.searchText, isPresented: $isSearching) .onChange(of: viewModel.searchText) { _, _ in viewModel.updateSearchResults() diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index cdf9926..dbd6646 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,33 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testFoldedWorkspaceKeepsThreadBeyondHinge() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" + && ProcessInfo.processInfo.environment["DUO_FOLDED"] == "1", + "Run with the Duo's active vertical 40-point hinge region.") + continueAfterFailure = false + XCUIDevice.shared.orientation = .landscapeLeft + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture"]) + app.buttons["Open Link Button"].tap() + let link = app.textFields["Open Link URL"] + XCTAssertTrue(link.waitForExistence(timeout: 5)) + link.tap() + link.typeText("https://boards.4chan.org/biz/") + app.buttons["Open Link Confirm"].tap() + let boardRow = app.buttons["CatalogThread100"] + XCTAssertTrue(boardRow.waitForExistence(timeout: 10)) + boardRow.tap() + XCTAssertTrue(app.staticTexts["#100"].waitForExistence(timeout: 10)) + let detail = app.thumbnailMediaImage(0) + XCTAssertTrue(detail.exists) + print("DUO_WORKSPACE_FRAMES: board=\(boardRow.frame); detail=\(detail.frame)") + captureDuo(app, "folded-hinge-workspace") + XCTAssertLessThanOrEqual(boardRow.frame.maxX, 455.5, + "Keep board rows before the active hinge region.") + XCTAssertGreaterThanOrEqual(detail.frame.minX, 495.5, + "The selected thread must begin beyond the active hinge region.") + } + func testDuoWalkthroughAndGallery() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", "Run on an open Duo with TEST_RUNNER_DUO_CAPTURE=1.") XCUIDevice.shared.orientation = .landscapeLeft From 3132284b0b4707a2ab8ac8d271c0ff7559fd69dd Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 08:18:22 -0500 Subject: [PATCH 04/10] Preserve thread workspace across background snapshots --- .../Views/Boards/Catalog/CatalogView.swift | 72 +++++++++++-------- swiftchanUITests/BrowsingUITests.swift | 32 +++++++++ 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index c24b4cc..87fe2f7 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -14,6 +14,7 @@ struct CatalogView: View { @Environment(AppState.self) var appState @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase var boardName: String @State var catalogViewModel: CatalogViewModel @@ -54,45 +55,54 @@ struct CatalogView: View { case .loaded: GeometryReader { geometry in let layout = workspaceLayout(in: geometry) - if geometry.size.width >= 760 { - NavigationSplitView { - catalogPosts(filteredPosts, workspace: true) - .navigationSplitViewColumnWidth( - min: layout.sidebarWidth ?? 260, - ideal: layout.sidebarWidth ?? 320, - max: layout.sidebarWidth ?? 400 - ) - } detail: { - NavigationStack { - Group { - if let selectedThread { - ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id, - showsNavigationTitle: false) - .id(selectedThread.id) - .accessibilityIdentifier("CatalogThreadDetail") - } else { - ContentUnavailableView { - Label("Open a Thread", systemImage: "text.bubble") - } description: { - Text("Choose a discussion on the left. Browse the board while you read.") + Group { + if geometry.size.width >= 760 { + NavigationSplitView { + catalogPosts(filteredPosts, workspace: true) + .navigationSplitViewColumnWidth( + min: layout.sidebarWidth ?? 260, + ideal: layout.sidebarWidth ?? 320, + max: layout.sidebarWidth ?? 400 + ) + } detail: { + NavigationStack { + Group { + if let selectedThread { + ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id, + showsNavigationTitle: false) + .id(selectedThread.id) + .accessibilityIdentifier("CatalogThreadDetail") + } else { + ContentUnavailableView { + Label("Open a Thread", systemImage: "text.bubble") + } description: { + Text("Choose a discussion on the left. Browse the board while you read.") + } } } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Back to boards", systemImage: "chevron.left") { dismiss() } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back to boards", systemImage: "chevron.left") { dismiss() } + } } } + .padding(.leading, layout.hingeGap) } - .padding(.leading, layout.hingeGap) + .navigationSplitViewStyle(.balanced) + .accessibilityIdentifier("BoardThreadWorkspace") + } else { + catalogPosts(filteredPosts, workspace: false) } - .navigationSplitViewStyle(.balanced) - .accessibilityIdentifier("BoardThreadWorkspace") - } else { - catalogPosts(filteredPosts, workspace: false) } + .onChange(of: scenePhase) { _, phase in + if phase == .active { usesThreadWorkspace = geometry.size.width >= 760 } + } + } + .onGeometryChange(for: Bool.self) { $0.size.width >= 760 } action: { isWide in + // Background snapshots can briefly use compact dimensions. Keep + // them from pushing the selected thread onto the navigation stack. + if scenePhase == .active { usesThreadWorkspace = isWide } } - .onGeometryChange(for: Bool.self) { $0.size.width >= 760 } action: { usesThreadWorkspace = $0 } .navigationDestination(item: Binding( get: { usesThreadWorkspace ? nil : selectedThread }, set: { if !usesThreadWorkspace { selectedThread = $0 } } diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index dbd6646..97f7827 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,38 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testDuoWorkspaceSurvivesBackgroundActivation() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", + "Run on Duo's open inner display.") + XCUIDevice.shared.orientation = .landscapeLeft + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture"]) + app.buttons["Open Link Button"].tap() + let link = app.textFields["Open Link URL"] + XCTAssertTrue(link.waitForExistence(timeout: 5)) + link.tap() + link.typeText("https://boards.4chan.org/biz/") + app.buttons["Open Link Confirm"].tap() + XCTAssertTrue(app.buttons["CatalogThread200"].waitForExistence(timeout: 10)) + app.buttons["CatalogThread200"].tap() + XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) + let workspace = app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch + XCTAssertTrue(workspace.exists) + let readingAnchor = app.staticTexts["#205"] + for _ in 0..<12 { + if readingAnchor.isHittable { break } + app.scrollViews["CatalogThreadDetail"].swipeUp() + } + XCTAssertTrue(readingAnchor.isHittable) + captureDuo(app, "workspace-before-background") + XCUIDevice.shared.press(.home) + app.activate() + captureDuo(app, "workspace-after-background") + XCTAssertTrue(workspace.waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["CatalogThread200"].isHittable) + XCTAssertTrue(readingAnchor.waitForExistence(timeout: 10)) + XCTAssertTrue(readingAnchor.isHittable) + } + func testFoldedWorkspaceKeepsThreadBeyondHinge() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" && ProcessInfo.processInfo.environment["DUO_FOLDED"] == "1", From f281a398cbb42e4d6b2b3a62dc1802720d38653c Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 16:38:53 -0500 Subject: [PATCH 05/10] Restore nested reply navigation and unify thread jumps --- swiftchan.xcodeproj/project.pbxproj | 8 +-- swiftchan/Models/PresentationState.swift | 8 +-- swiftchan/Services/ThreadUITestFixture.swift | 5 +- .../Catalog/Thread/PostDetailView.swift | 25 ++++++++ .../Boards/Catalog/Thread/PostView.swift | 15 ++--- .../Catalog/Thread/QuotePreviewSheet.swift | 57 ------------------ .../Boards/Catalog/Thread/RepliesView.swift | 35 +++++++---- .../Boards/Catalog/Thread/ThreadView.swift | 59 ++++++++++--------- swiftchanUITests/BrowsingUITests.swift | 56 ++++++++++++++++-- 9 files changed, 145 insertions(+), 123 deletions(-) create mode 100644 swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift delete mode 100644 swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index e979859..d0f5ca7 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ 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 */; }; + CA000001CCCC000100000002 /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000001CCCC000100000001 /* PostDetailView.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 */; }; @@ -166,7 +166,7 @@ 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 = ""; }; + CA000001CCCC000100000001 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.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 = ""; }; @@ -557,7 +557,7 @@ children = ( 9599CA95254DF8C0002EB026 /* ThreadView.swift */, CA000002CCCC000100000001 /* ReplyDraftSheet.swift */, - CA000001CCCC000100000001 /* QuotePreviewSheet.swift */, + CA000001CCCC000100000001 /* PostDetailView.swift */, 9599CA9F254DFDA1002EB026 /* PostView.swift */, 95D804CD25677FF4005A3128 /* RepliesView.swift */, 9559F79228443E7D00F5803F /* ThreadAutoRefresher.swift */, @@ -855,7 +855,7 @@ 9559F78F2843B07D00F5803F /* NSFWTagView.swift in Sources */, 9599CA96254DF8C0002EB026 /* ThreadView.swift in Sources */, CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */, - CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */, + CA000001CCCC000100000002 /* PostDetailView.swift in Sources */, 95181791254CFB600032616E /* swiftchanApp.swift in Sources */, 95978F162CA7981600C520EF /* UINavigationController.swift in Sources */, 9587A51C255726D80028A143 /* CacheService.swift in Sources */, diff --git a/swiftchan/Models/PresentationState.swift b/swiftchan/Models/PresentationState.swift index 6b2f882..cdf556a 100644 --- a/swiftchan/Models/PresentationState.swift +++ b/swiftchan/Models/PresentationState.swift @@ -12,15 +12,15 @@ class PresentationState { var presentingGallery: Bool = false var galleryIndex: Int = 0 var presentingIndex: Int = 0 - var presentingReplies: Bool = false + var activePostContext: UUID? + var presentingReplies: Bool { activePostContext != nil } } extension EnvironmentValues { /// Namespace used for the thumbnail → gallery zoom transition. @Entry var galleryNamespace: Namespace.ID? - /// True for PostViews rendered inside RepliesView, so only one context - /// registers a matchedTransitionSource for a given media index at a time. - @Entry var inRepliesContext: Bool = false + /// Identifies each pushed post/replies page; nil identifies the main thread. + @Entry var postContextID: UUID? } extension View { diff --git a/swiftchan/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 59c9419..416874e 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -17,7 +17,8 @@ post["sub"] = "/pmg/ - Precious Metals General" if arguments.contains("--ui-general-rollover"), id == 100 { post["archived"] = 1 } } - if arguments.contains("--ui-media-fixture"), number < id + 3 { + if arguments.contains("--ui-media-fixture"), + number < id + 3 || (arguments.contains("--ui-quote-fixture") && number == 104) { let timestamp = 9_000_000_000 + number post["tim"] = timestamp post["filename"] = "collection-study-\(number - id + 1)" @@ -29,7 +30,7 @@ post["fsize"] = 100_000 seedMedia(timestamp: timestamp, index: number - id) } - if arguments.contains("--ui-quote-fixture"), number == 105 || number == 104 { + if arguments.contains("--ui-quote-fixture"), (103...105).contains(number) { post["com"] = ">>\(number - 1)
Quoted reply \(number)" } return post diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift new file mode 100644 index 0000000..927ba05 --- /dev/null +++ b/swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct PostDestination: Identifiable, Hashable { + let id: Int +} + +struct PostDetailView: View { + let postID: Int + @Environment(ThreadViewModel.self) private var model + + var body: some View { + Group { + if let index = model.getPostIndexFromId(String(postID)), + !model.posts[index].isHidden(boardName: model.boardName), model.filterEffect(at: index) != .hide { + RepliesView(replies: [index]) + .accessibilityIdentifier("Post Detail \(postID)") + } else { + ContentUnavailableView("Post Unavailable", systemImage: "text.bubble", + description: Text("This post is hidden or is not in the loaded thread.")) + } + } + .navigationTitle("#\(postID)") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 80d6b5c..9b0ff5b 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -15,7 +15,7 @@ struct PostView: View { @Environment(AppState.self) private var appState @Environment(PresentationState.self) private var presentationState: PresentationState @Environment(\.galleryNamespace) private var galleryNamespace - @Environment(\.inRepliesContext) private var inRepliesContext + @Environment(\.postContextID) private var postContextID let index: Int @@ -59,9 +59,8 @@ struct PostView: View { .galleryTransitionSource( id: mediaIndex, namespace: galleryNamespace, - // Only one context may own a source id: the thread - // list normally, RepliesView while it is pushed. - isActive: inRepliesContext == presentationState.presentingReplies + // Only the visible thread, post, or replies page owns the source. + isActive: postContextID == presentationState.activePostContext ) .onTapGesture { viewModel.media[mediaIndex].isSelected = true @@ -177,12 +176,8 @@ struct PostView: View { RepliesView(replies: replies) .environment(viewModel) .environment(presentationState) - .onAppear { - presentationState.presentingReplies = true - } - .onDisappear { - presentationState.presentingReplies = false - } + .navigationTitle("Replies to #\(post.no)") + .navigationBarTitleDisplayMode(.inline) } }, label: { diff --git a/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift b/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift deleted file mode 100644 index 619a2ab..0000000 --- a/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift +++ /dev/null @@ -1,57 +0,0 @@ -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/RepliesView.swift b/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift index 9f8e32c..ac712e0 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift @@ -8,14 +8,15 @@ import SwiftUI struct RepliesView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system let replies: [Int] let columns = [GridItem(.flexible(), spacing: 0, alignment: .center)] - @State private var showReply: Bool = false - @State private var replyId: Int = 0 + @State private var linkedPost: PostDestination? + @State private var contextID = UUID() @State private var showPostUnavailable = false - @Environment(\.openURL) private var openURL + @Environment(AppState.self) private var appState @Environment(PresentationState.self) private var presentationState: PresentationState @Environment(ThreadViewModel.self) private var viewModel @@ -26,33 +27,43 @@ struct RepliesView: View { alignment: .center, spacing: 0) { ForEach(replies, id: \.self) { index in - PostView(index: index) + if viewModel.posts.indices.contains(index), + !viewModel.posts[index].isHidden(boardName: viewModel.boardName), viewModel.filterEffect(at: index) != .hide { + PostView(index: index) + } } } + .padding(3) } - .environment(\.inRepliesContext, true) + .background(theme.pageBackground) + .environment(\.postContextID, contextID) .environment(\.openURL, postLinkAction) + .onAppear { + presentationState.activePostContext = contextID + } + .onDisappear { + if !presentationState.presentingGallery, presentationState.activePostContext == contextID { + presentationState.activePostContext = nil + } + } .alert("Post Unavailable", isPresented: $showPostUnavailable) { Button("OK", role: .cancel) { } } message: { Text("This post is not in the loaded thread. It may have been deleted.") } - .navigationDestination(isPresented: $showReply) { - PostView(index: replyId) + .navigationDestination(item: $linkedPost) { destination in + PostDetailView(postID: destination.id) .environment(viewModel) .environment(presentationState) - .environment(\.openURL, postLinkAction) } } private var postLinkAction: OpenURLAction { OpenURLAction { url in guard case .post(let id) = Deeplinker.getType(url: url) else { - openURL(url) - return .handled + return appState.openLink(url) ? .handled : .systemAction } if let index = viewModel.getPostIndexFromId(id) { - replyId = index - showReply = true + linkedPost = PostDestination(id: viewModel.posts[index].no) } else { showPostUnavailable = true } diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index 4a762c4..f8085e5 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -27,8 +27,8 @@ struct ThreadView: View { @State private var threadAutorefresher = ThreadAutoRefresher() @State var viewModel: ThreadViewModel @State private var opacity: Double = 1 - @State private var showReply: Bool = false - @State private var replyId: Int = 0 + @State private var linkedPost: PostDestination? + @State private var replyReturnPostID: Int? @State private var showPostUnavailable = false @AppStorage("rememberThreadPositions") private var rememberThreadPositions = true @State private var reading = ThreadReadingSession() @@ -124,7 +124,7 @@ struct ThreadView: View { recordVisiblePosts() } .onChange(of: presentationState.galleryIndex) { _, _ in - if !presentationState.presentingReplies && !showReply { + if !presentationState.presentingReplies && linkedPost == nil { scrollToPost(reader: reader) } } @@ -138,6 +138,14 @@ struct ThreadView: View { } } .accessibilityIdentifier("Thread Posts") + .task(id: linkedPost) { + if linkedPost == nil, let postID = replyReturnPostID { + // Restore the reading anchor after navigation and gallery layout changes. + await Task.yield() + reader.scrollTo(postID, anchor: .top) + replyReturnPostID = nil + } + } .onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.1) { ids in visiblePostIDs = ids recordVisiblePosts() @@ -151,6 +159,12 @@ struct ThreadView: View { HStack { Spacer() Menu { + if rememberThreadPositions, let firstUnread = unreadPostIDs.first { + Button("\(unreadPostIDs.count) Unread \(unreadPostIDs.count == 1 ? "Reply" : "Replies")", systemImage: "arrow.down") { + reader.scrollTo(firstUnread, anchor: .top) + } + .accessibilityIdentifier("Jump To Unread") + } Button("First Post", systemImage: "arrow.up.to.line") { if let first = readablePostIDs.first { reader.scrollTo(first, anchor: .top) } } @@ -172,24 +186,6 @@ struct ThreadView: View { } } } - .safeAreaInset(edge: .top, spacing: 0) { - if rememberThreadPositions, !isSearching, viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), - let firstUnread = unreadPostIDs.first { - Button { - reader.scrollTo(firstUnread, anchor: .top) - } label: { - HStack { - Text("\(unreadPostIDs.count) unread \(unreadPostIDs.count == 1 ? "reply" : "replies")") - Spacer() - Label("Jump", systemImage: "arrow.down") - } - .font(.subheadline) - .padding(10) - .background(.regularMaterial) - } - .accessibilityIdentifier("Jump To Unread") - } - } } } .overlay(alignment: .bottom) { @@ -275,6 +271,9 @@ struct ThreadView: View { .onChange(of: autoRefreshThreadTime) { updateAutoRefreshState() } .onChange(of: presentationState.presentingReplies) { if presentationState.presentingReplies { + if replyReturnPostID == nil { + replyReturnPostID = reading.postID ?? visiblePostIDs.min() + } threadAutorefresher.cancelTimer() } else { updateAutoRefreshState() @@ -344,16 +343,18 @@ struct ThreadView: View { } .defaultCustomization(.hidden) } - .onChange(of: showReply) { - if showReply { + .onChange(of: linkedPost) { + if linkedPost != nil { threadAutorefresher.cancelTimer() } else { updateAutoRefreshState() } } - .sheet(isPresented: $showReply) { - QuotePreviewSheet(postID: viewModel.posts[replyId].no) + .navigationDestination(item: $linkedPost) { destination in + PostDetailView(postID: destination.id) .environment(viewModel) + .environment(presentationState) + .environment(\.galleryNamespace, galleryNamespace) } .sheet(isPresented: $appState.showingBottomSheet) { if let post = appState.selectedBottomSheetPost, @@ -595,7 +596,7 @@ struct ThreadView: View { private func recordVisiblePosts() { guard rememberThreadPositions, isThreadVisible, scenePhase == .active, !isSearching, - viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), !showReply, + viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), linkedPost == nil, !presentationState.presentingGallery, !presentationState.presentingReplies, !showFollowGeneral else { return } reading.observe(visiblePostIDs: visiblePostIDs) if let postID = reading.postID { @@ -608,8 +609,8 @@ struct ThreadView: View { OpenURLAction { url in if case .post(let id) = Deeplinker.getType(url: url) { if let index = viewModel.getPostIndexFromId(id) { - replyId = index - showReply = true + replyReturnPostID = reading.postID ?? visiblePostIDs.min() + linkedPost = PostDestination(id: viewModel.posts[index].no) } else { showPostUnavailable = true } @@ -628,7 +629,7 @@ struct ThreadView: View { private func updateAutoRefreshState() { if isThreadVisible, scenePhase == .active, !viewModel.isArchived, !presentationState.presentingGallery, !presentationState.presentingReplies, - !showReply { + linkedPost == nil { threadAutorefresher.startTimer() } else { threadAutorefresher.cancelTimer() diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 97f7827..0b59cc6 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -113,16 +113,60 @@ final class BrowsingUITests: XCTestCase { attachScreenshot("Duo \(name)") } - func testQuotePreviewChainDismissesBackToReadingPosition() { - let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture"]) + func testQuotedPostsPushWithImagesAndNestedRepliesThenReturnToReadingPosition() { + let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture", "--ui-media-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() + XCTAssertFalse(app.sheets.firstMatch.exists) + XCTAssertFalse(app.buttons["Close Quote Preview"].exists) + XCTAssertTrue(app.thumbnailMediaImage(4).isHittable) + attachScreenshot("Quoted Post With Image") + app.tapThumbnailMedia(4) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.galleryMediaImage(3).isHittable) + app.buttons["Close gallery"].tap() + app.scrollViews["Post Detail 104"].links[">>103"].tap() + XCTAssertTrue(app.navigationBars["#103"].waitForExistence(timeout: 5)) + app.scrollViews["Post Detail 103"].links[">>102"].tap() + XCTAssertTrue(app.navigationBars["#102"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.thumbnailMediaImage(2).isHittable) + app.buttons["1 REPLY"].tap() + XCTAssertTrue(app.staticTexts["#103"].waitForExistence(timeout: 5)) + app.buttons["1 REPLY"].tap() + XCTAssertTrue(app.staticTexts["#104"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.thumbnailMediaImage(4).isHittable) + attachScreenshot("Nested Replies With Images") + app.tapThumbnailMedia(4) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.galleryMediaImage(3).isHittable) + app.buttons["Close gallery"].tap() + XCTAssertTrue(app.navigationBars["Replies to #103"].waitForExistence(timeout: 5)) + app.links[">>103"].tap() XCTAssertTrue(app.navigationBars["#103"].waitForExistence(timeout: 5)) - attachScreenshot("Quote Chain Preview") - app.navigationBars["#103"].buttons["Close Quote Preview"].tap() + XCTAssertFalse(app.sheets.firstMatch.exists) + for _ in 0..<6 { app.navigationBars.buttons.element(boundBy: 0).tap() } XCTAssertTrue(app.staticTexts["#105"].isHittable) + attachScreenshot("Returned To Thread Reading Position") + } + + func testUnreadNavigationSharesTheBottomJumpMenu() { + let app = launchThreadFixture(seedProgress: true) + openFixtureThread(in: app) + XCTAssertTrue(app.buttons["Thread Jump Menu"].isHittable) + XCTAssertFalse(app.buttons["Jump To Unread"].exists) + app.buttons["Thread Jump Menu"].tap() + XCTAssertTrue(app.buttons["Jump To First Post"].isHittable) + XCTAssertTrue(app.buttons["Jump To Latest Reply"].isHittable) + XCTAssertTrue(app.buttons["Jump To Unread"].isHittable) + attachScreenshot("One Menu For Thread Navigation") + app.buttons["Jump To Latest Reply"].tap() + XCTAssertTrue(app.staticTexts["#114"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["#114"].isHittable) + app.buttons["Thread Jump Menu"].tap() + XCTAssertFalse(app.buttons["Jump To Unread"].exists) + app.buttons["Jump To First Post"].tap() + XCTAssertTrue(app.staticTexts["#100"].isHittable) } func testArchivedGeneralFindsNewerThread() { @@ -347,6 +391,8 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(savedPost.waitForExistence(timeout: 5)) XCTAssertTrue(savedPost.isHittable) XCTAssertFalse(app.staticTexts["#100"].isHittable) + XCTAssertFalse(app.buttons["Jump To Unread"].exists) + app.buttons["Thread Jump Menu"].tap() let jump = app.buttons["Jump To Unread"] XCTAssertTrue(jump.waitForExistence(timeout: 5)) attachScreenshot("Restored Position And Unread Replies") From f54534635c3c5e1aa4500d228e4034941d62ff82 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 16:48:32 -0500 Subject: [PATCH 06/10] Document verified iPhone Duo pose automation --- AGENTS.md | 4 ++ docs/iphone-duo-testing.md | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/iphone-duo-testing.md diff --git a/AGENTS.md b/AGENTS.md index f71a5c4..8329aa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,10 @@ Swiftchan is a SwiftUI imageboard app using MVVM, SwiftData, and Swift Package Manager. The app requires iOS 18.0 or later. Video playback uses KSPlayer and FFmpegKit; CocoaPods and MobileVLCKit are no longer used. +## iPhone Duo testing + +Read [docs/iphone-duo-testing.md](docs/iphone-duo-testing.md) before Duo layout, pose, or recording work. Native `agent-device@0.21.20` hinge control is verified for open, book, and closed; use the scoped Duo toolchain and confirm angle plus app-visible geometry. Automated tabletop rotation remains unverified. Capture the lit panel explicitly and distinguish live continuity from saved-state reopening. Use `uv run` for Python helpers. Keep `CLAUDE.md` as the compatibility symlink to this file. + ## Setup and validation - Use Xcode 26.6 (CI uses this version); updated packages require at least Swift 6.2 / Xcode 26. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md new file mode 100644 index 0000000..f080360 --- /dev/null +++ b/docs/iphone-duo-testing.md @@ -0,0 +1,88 @@ +# iPhone Duo simulator testing + +Verified locally on 2026-10-04 with Xcode Duo 27.1 (24A94403), the iOS 27.1 simulator (24A94401), and `agent-device@0.21.20`. This is local simulator evidence; physical-device and hosted-CI pose control have not been verified. + +## Toolchain and tool versions + +Scope the Duo toolchain to each command. Keep the normal Xcode selection intact: + +```sh +export DEVELOPER_DIR=/Applications/Xcode-Duo-beta.app/Contents/Developer +xcrun simctl list devices available +``` + +Select the intended simulator by its full UDID. Our review simulator is `3C2C0377-69D7-432C-A82A-0DEA70E77A5B`; another machine must use its own. Swiftchan's current dependency build additionally needs the installed 27.2 beta toolchain; pose commands use the Duo 27.1 toolchain. + +The previously installed XcodeBuildMCP CLI was 2.6.2. Upstream 2.7.0 adds Xcode 27 Device Hub support; 2.7.1 renames the package to **MobileBuildMCP**. Pinned `npx --yes mobilebuildmcp@2.7.1 --version` and its simulator inventory worked here. Its inspected tool inventory has no fold/pose setter. Updating that package alone does not provide hinge control. No global MCP configuration change is required for the commands below. + +## Working native hinge control + +`agent-device@0.21.20` sends a simulator HID hinge event and verifies the resulting angle with CoreDevice. It does not need Device Hub accessibility or host UI scripting. The upstream headless amendment supersedes its older Device Hub accessibility instructions. + +First open an installed app to establish the session. A fold request without an active session was refused even when supplied a UDID: + +```sh +npx --yes agent-device@0.21.20 open APP_BUNDLE_ID \ + --session duo-review --platform ios --udid SIMULATOR_UDID --foreground --json +npx --yes agent-device@0.21.20 fold open --session duo-review --json +npx --yes agent-device@0.21.20 fold half-open --session duo-review --json +npx --yes agent-device@0.21.20 fold closed --session duo-review --json +``` + +Replace both uppercase placeholders. Run mutations sequentially. A successful local readback was: + +| Command | Measured hinge angle | Lit panel | App viewport observed by the native probe | +| --- | --- | --- | --- | +| `fold open` | 180 degrees | `LCD-1` / display 3 | 951 x 669 pt; division inactive | +| `fold half-open` | 130 degrees | `LCD-1` / display 3 | 951 x 669 pt; active vertical division | +| `fold closed` | 0 degrees | `LCD` / display 1 | 466 x 678 pt; no division | + +The book-pose division measured x=455.5, width=40, height=669 pt, with 20 pt leading/trailing margins. These numbers describe this test device, not layout constants. Production layouts must use the native reserved-region frame and margins. Re-query app window geometry and accessibility elements after every pose change; old coordinates and references are stale. CoreDevice's native panel size (669 x 951 pt here) is not the oriented app viewport. + +When finished, end the session without shutting down the simulator: + +```sh +npx --yes agent-device@0.21.20 close --session duo-review --json +``` + +The hinge helper depends on private simulator HID behavior and the selected simulator SDK. Keep the version pinned and verify readback after toolchain updates. Do not substitute `simctl io screenConfig power`: switching display power does not change the actual hinge state. + +## Book, flat, and tabletop + +**Flat** means fully open. **Book** means partially open with the hinge vertical in the app. **Tabletop** is the laptop-like pose with the lower half lying flat, the upper half raised, and the hinge horizontal. It is not a separate verified CLI preset: it needs a partial fold plus the corresponding physical quarter-turn. + +`orientation portrait` and `orientation landscape-right` returned success here, but the live native probe still reported the same 951 x 669 pt viewport and active vertical division. Therefore automated tabletop rotation is **unverified**. Do not label a book capture as tabletop, or treat an orientation command's success message as proof. A tabletop check must observe an active horizontal division in the app and inspect the actual rendered controls. Use the simulator's physical rotation control when available, and retain this manual requirement until app-visible geometry confirms a working automation path. + +## Screenshots, video, and continuity evidence + +Always name the lit display. On this simulator: + +```sh +# Inner display: fully open or book. +xcrun simctl io SIMULATOR_UDID screenshot --display=3 inner.png +xcrun simctl io SIMULATOR_UDID recordVideo --codec=h264 --display=3 inner.mp4 +# Outer display: closed. +xcrun simctl io SIMULATOR_UDID screenshot --display=1 outer.png +``` + +Stop `recordVideo` with SIGINT so the file is finalized. An implicit screenshot can capture the dark inner panel while closed and still exit successfully. Inspect actual image content and decode the video before delivering it. Use `uv run` for Python capture and validation helpers. + +A single-panel recording can show a live flat/book transition, but does not prove a continuous inner/outer transition after the active panel switches. Record the correct panel(s) and disclose any recording gap. Verify a real passing test case and the final test suite, not just a zero exit code or a preparation screenshot. If a timed test has already ended, a later pose change cannot make its earlier video a continuity pass. + +Distinguish these outcomes: a prepared state; saved state restored after reopening; an existing process reactivated after a pose change; and the same app staying foreground throughout a recorded fold. Assert visible content and interaction, not only an offscreen DOM title or a retained model identifier. + +## App-specific checks + +Use the workspace. Verify the board catalog beside the selected thread, a visible scrolled reply, and background/foreground restoration. Background snapshot geometry must not push a standalone thread route. + +Installed app bundle ID: `vanities.swiftchan`. + +## Sources + +Read the complete relevant Apple documents and upstream tool instructions when changing this workflow: + +- [Apple: Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo) +- [Apple: Configuring the environment of a simulated device](https://developer.apple.com/documentation/xcode/configuring-the-environment-of-a-simulated-device) +- [Apple: Interacting with your app in the iOS or iPadOS Simulator](https://developer.apple.com/documentation/xcode/interacting-with-your-app-in-the-ios-or-ipados-simulator) +- [MobileBuildMCP 2.7.0](https://github.com/getsentry/MobileBuildMCP/releases/tag/v2.7.0) and [2.7.1](https://github.com/getsentry/MobileBuildMCP/releases/tag/v2.7.1) +- [Agent Device foldable-panel ADR](https://github.com/callstack/agent-device/blob/main/docs/adr/0025-foldable-apple-panels.md), especially the 2026-09-22 headless amendment and the accepted quarter-turn evidence gap From bdb9b6bf75315659ca530d0ad0e89645f53c6f3a Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 17:14:36 -0500 Subject: [PATCH 07/10] Add reverse image search and media actions --- swiftchan.xcodeproj/project.pbxproj | 12 +++ swiftchan/Models/ReverseImageSearch.swift | 56 ++++++++++++++ .../Boards/Catalog/Thread/PostView.swift | 4 +- .../Boards/Catalog/Thread/ThreadView.swift | 9 ++- .../Views/Media/Gallery/GalleryView.swift | 36 ++++++++- .../Media/Gallery/MediaContextMenu.swift | 8 ++ .../Media/Gallery/MediaDownloadMenu.swift | 6 +- .../Views/Media/ReverseImageSearchMenu.swift | 36 +++++++++ swiftchanTests/ReverseImageSearchTests.swift | 65 +++++++++++++++++ swiftchanUITests/BrowsingUITests.swift | 73 +++++++++++++++++++ 10 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 swiftchan/Models/ReverseImageSearch.swift create mode 100644 swiftchan/Views/Media/ReverseImageSearchMenu.swift create mode 100644 swiftchanTests/ReverseImageSearchTests.swift diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index d0f5ca7..475d9cd 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + DA000003DDDD000100000002 /* ReverseImageSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA000003DDDD000100000001 /* ReverseImageSearchTests.swift */; }; + DA000002DDDD000100000002 /* ReverseImageSearchMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA000002DDDD000100000001 /* ReverseImageSearchMenu.swift */; }; + DA000001DDDD000100000002 /* ReverseImageSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA000001DDDD000100000001 /* ReverseImageSearch.swift */; }; 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 */; }; @@ -161,6 +164,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + DA000003DDDD000100000001 /* ReverseImageSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseImageSearchTests.swift; sourceTree = ""; }; + DA000002DDDD000100000001 /* ReverseImageSearchMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseImageSearchMenu.swift; sourceTree = ""; }; + DA000001DDDD000100000001 /* ReverseImageSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseImageSearch.swift; sourceTree = ""; }; 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 = ""; }; @@ -339,6 +345,7 @@ 950E3933255CD9BB00A00A74 /* Media */ = { isa = PBXGroup; children = ( + DA000002DDDD000100000001 /* ReverseImageSearchMenu.swift */, 959E6152255CD4750003638E /* Gallery */, 950E393A255CD9EE00A00A74 /* Image */, AA000004AAAA000000000004 /* Video */, @@ -360,6 +367,7 @@ 9510536725660071002E4051 /* Models */ = { isa = PBXGroup; children = ( + DA000001DDDD000100000001 /* ReverseImageSearch.swift */, AF000001AAAA000100000001 /* SavedReply.swift */, BF000001EEEE000100000001 /* FavoritesBackup.swift */, CA000003CCCC000100000001 /* ChanPreferences.swift */, @@ -430,6 +438,7 @@ 951817A1254CFB620032616E /* swiftchanTests */ = { isa = PBXGroup; children = ( + DA000003DDDD000100000001 /* ReverseImageSearchTests.swift */, AF000003AAAA000100000001 /* SavedReplyTests.swift */, BF000004EEEE000100000001 /* FavoritesBackupTests.swift */, EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */, @@ -814,6 +823,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA000002DDDD000100000002 /* ReverseImageSearchMenu.swift in Sources */, + DA000001DDDD000100000002 /* ReverseImageSearch.swift in Sources */, AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */, AF000001AAAA000100000002 /* SavedReply.swift in Sources */, 954FB5832730BDF0009CC52F /* RoundedCorners.swift in Sources */, @@ -915,6 +926,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA000003DDDD000100000002 /* ReverseImageSearchTests.swift in Sources */, AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */, BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */, EE000003EEEE000100000002 /* HiddenPostStoreTests.swift in Sources */, diff --git a/swiftchan/Models/ReverseImageSearch.swift b/swiftchan/Models/ReverseImageSearch.swift new file mode 100644 index 0000000..d99fa9f --- /dev/null +++ b/swiftchan/Models/ReverseImageSearch.swift @@ -0,0 +1,56 @@ +import Foundation + +enum ReverseImageSearchProvider: String, CaseIterable, Identifiable { + case googleLens, yandex, tineye, sauceNAO, iqdb, traceMoe + + var id: String { rawValue } + + var name: String { + switch self { + case .googleLens: "Google Lens" + case .yandex: "Yandex" + case .tineye: "TinEye" + case .sauceNAO: "SauceNAO" + case .iqdb: "IQDB" + case .traceMoe: "trace.moe" + } + } + + func searchURL(for media: Media) -> URL? { + guard let imageURL = Self.sourceURL(for: media) else { return nil } + let endpoint: String + switch self { + case .googleLens: endpoint = "https://lens.google.com/uploadbyurl" + case .yandex: endpoint = "https://yandex.com/images/search" + case .tineye: endpoint = "https://tineye.com/search" + case .sauceNAO: endpoint = "https://saucenao.com/search.php" + case .iqdb: endpoint = "https://iqdb.org/" + case .traceMoe: endpoint = "https://trace.moe/" + } + var components = URLComponents(string: endpoint) + var items: [URLQueryItem] = [] + if self == .yandex { items.append(URLQueryItem(name: "rpt", value: "imageview")) } + items.append(URLQueryItem(name: "url", value: imageURL.absoluteString)) + components?.queryItems = items + // These providers parse form-style queries, where an unescaped + means a space. + let encodedQuery = components?.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + components?.percentEncodedQuery = encodedQuery + return components?.url + } + + static func sourceURL(for media: Media) -> URL? { + let source: URL + switch media.url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": source = media.url + case "webm", "mp4": source = media.thumbnailUrl + default: return nil + } + guard ["http", "https"].contains(source.scheme?.lowercased() ?? ""), + let host = source.host, !host.isEmpty, source.user == nil, source.password == nil else { return nil } + return source + } + + static func usesThumbnail(for media: Media) -> Bool { + ["webm", "mp4"].contains(media.url.pathExtension.lowercased()) + } +} diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 9b0ff5b..54e4fac 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -72,7 +72,9 @@ struct PostView: View { Text(filename + fileExtension) .font(Font.system(size: 8)) } - + ReverseImageSearchMenu(media: media, identifier: "Image Search Post \(post.no)") + .font(.caption) + .padding(.top, 4) } .padding(.leading, -5) } diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index f8085e5..aa05123 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -366,6 +366,13 @@ struct ThreadView: View { appState.selectedBottomSheetPost = nil } .accessibilityIdentifier("Draft Reply To Post") + Button("Copy Quote", systemImage: "quote.bubble") { + UIPasteboard.general.string = ">>\(post.no)" + UINotificationFeedbackGenerator().notificationOccurred(.success) + appState.showingBottomSheet = false + appState.selectedBottomSheetPost = nil + } + .accessibilityIdentifier("Copy Selected Post Quote") 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) @@ -386,7 +393,7 @@ struct ThreadView: View { .accessibilityIdentifier("Hide Selected Post") } .padding() - .presentationDetents([.height(290), .medium]) + .presentationDetents([.height(350), .medium]) .presentationDragIndicator(.visible) } } diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index 4eb8d13..7c4bb39 100644 --- a/swiftchan/Views/Media/Gallery/GalleryView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryView.swift @@ -126,6 +126,40 @@ struct GalleryView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .topLeading) { closeButton } + .overlay(alignment: .topTrailing) { + if viewModel.media.indices.contains(selection) { + ReverseImageSearchMenu(media: viewModel.media[selection], identifier: "Gallery Image Search") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.65), in: Capsule()) + .padding(16) + .opacity(isZoomed || isSeeking ? 0 : 1) + .allowsHitTesting(!isZoomed && !isSeeking) + } + } + .overlay(alignment: .bottomTrailing) { + if viewModel.media.indices.contains(selection) { + Text("\(selection + 1) of \(viewModel.media.count)") + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.black.opacity(0.65), in: Capsule()) + .padding(16) + .padding(.bottom, positionBottomPadding) + .opacity(isZoomed || isSeeking ? 0 : 1) + .allowsHitTesting(false) + .accessibilityLabel("Media \(selection + 1) of \(viewModel.media.count)") + .accessibilityIdentifier("Gallery Position") + } + } + } + + private var positionBottomPadding: CGFloat { + if showPreview { return 100 } + guard viewModel.media.indices.contains(selection) else { return 0 } + return ReverseImageSearchProvider.usesThumbnail(for: viewModel.media[selection]) ? 80 : 0 } private var closeButton: some View { @@ -167,7 +201,7 @@ struct GalleryView: View { canShowPreview = !seeking canShowContextMenu = !seeking } - .mediaDownloadMenu(url: media.url, canShowContextMenu: $canShowContextMenu) + .mediaDownloadMenu(url: media.url, thumbnailURL: media.thumbnailUrl, canShowContextMenu: $canShowContextMenu) .accessibilityIdentifier( AccessibilityIdentifiers.galleryMediaImage(media.index) ) diff --git a/swiftchan/Views/Media/Gallery/MediaContextMenu.swift b/swiftchan/Views/Media/Gallery/MediaContextMenu.swift index 4b45de0..92a037f 100644 --- a/swiftchan/Views/Media/Gallery/MediaContextMenu.swift +++ b/swiftchan/Views/Media/Gallery/MediaContextMenu.swift @@ -10,6 +10,7 @@ import UIKit struct MediaContextMenu: View { let url: URL + let thumbnailURL: URL @State private var notificationGenerator = UINotificationFeedbackGenerator() @Binding var isExportingDocument: Bool @@ -21,6 +22,13 @@ struct MediaContextMenu: View { @ViewBuilder var body: some View { if canShowContextMenu { + let media = Media(index: 0, url: url, thumbnailUrl: thumbnailURL) + if ReverseImageSearchProvider.sourceURL(for: media) != nil { + Menu("Reverse Image Search", systemImage: "photo.badge.magnifyingglass") { + ReverseImageSearchLinks(media: media) + } + Link("Open Original", destination: url) + } Button { UIPasteboard.general.string = url.absoluteString debugPrint("📋 Copied URL to pasteboard: \(url.absoluteString)") diff --git a/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift b/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift index 7df6064..aa672f5 100644 --- a/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift +++ b/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift @@ -8,13 +8,14 @@ import SwiftUI extension View { - func mediaDownloadMenu(url: URL, canShowContextMenu: Binding) -> some View { - modifier(MediaDownloadMenuModifier(url: url, canShowContextMenu: canShowContextMenu)) + func mediaDownloadMenu(url: URL, thumbnailURL: URL, canShowContextMenu: Binding) -> some View { + modifier(MediaDownloadMenuModifier(url: url, thumbnailURL: thumbnailURL, canShowContextMenu: canShowContextMenu)) } } struct MediaDownloadMenuModifier: ViewModifier { let url: URL + let thumbnailURL: URL @Binding var canShowContextMenu: Bool @State var isExportingDocument: Bool = false @@ -45,6 +46,7 @@ struct MediaDownloadMenuModifier: ViewModifier { .contextMenu { MediaContextMenu( url: url, + thumbnailURL: thumbnailURL, isExportingDocument: $isExportingDocument, canShowContextMenu: $canShowContextMenu, presentingToast: $presentingToast, diff --git a/swiftchan/Views/Media/ReverseImageSearchMenu.swift b/swiftchan/Views/Media/ReverseImageSearchMenu.swift new file mode 100644 index 0000000..78a8eba --- /dev/null +++ b/swiftchan/Views/Media/ReverseImageSearchMenu.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct ReverseImageSearchMenu: View { + let media: Media + var identifier = "Image Search" + + var body: some View { + if ReverseImageSearchProvider.sourceURL(for: media) != nil { + Menu { + ReverseImageSearchLinks(media: media) + Divider() + Link("Open Original", destination: media.url) + .accessibilityIdentifier("Open Original Media") + } label: { + Label(ReverseImageSearchProvider.usesThumbnail(for: media) ? "Search Thumbnail" : "Image Search", + systemImage: "photo.badge.magnifyingglass") + } + .accessibilityIdentifier(identifier) + } + } +} + +struct ReverseImageSearchLinks: View { + let media: Media + + var body: some View { + Section(ReverseImageSearchProvider.usesThumbnail(for: media) ? "Search Video Thumbnail" : "Reverse Image Search") { + ForEach(ReverseImageSearchProvider.allCases) { provider in + if let url = provider.searchURL(for: media) { + Link(provider.name, destination: url) + .accessibilityIdentifier("Image Search \(provider.name)") + } + } + } + } +} diff --git a/swiftchanTests/ReverseImageSearchTests.swift b/swiftchanTests/ReverseImageSearchTests.swift new file mode 100644 index 0000000..15315c3 --- /dev/null +++ b/swiftchanTests/ReverseImageSearchTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import swiftchan + +final class ReverseImageSearchTests: XCTestCase { + func testEachProviderReceivesTheOriginalImageURL() throws { + let original = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.png")) + let thumbnail = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123s.jpg")) + let media = Media(index: 0, url: original, thumbnailUrl: thumbnail) + let hosts = ["lens.google.com", "yandex.com", "tineye.com", "saucenao.com", "iqdb.org", "trace.moe"] + for (provider, host) in zip(ReverseImageSearchProvider.allCases, hosts) { + let url = try XCTUnwrap(provider.searchURL(for: media)) + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, host) + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "url" }?.value, + original.absoluteString) + } + } + + func testSearchQueryPreservesEscapesAndFormSensitiveCharacters() throws { + let original = try XCTUnwrap(URL(string: "https://images.example/image%20name.PNG?token=a+b&escaped=%2B%26#fragment")) + let media = Media(index: 0, url: original, thumbnailUrl: original) + for provider in ReverseImageSearchProvider.allCases { + let url = try XCTUnwrap(provider.searchURL(for: media)) + let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedQuery) + XCTAssertFalse(query.contains("+"), "Providers interpret raw + as a space") + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "url" }?.value, + original.absoluteString) + } + } + + func testVideoSearchesUseThumbnailsInsteadOfVideoFiles() throws { + let thumbnail = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123s.jpg")) + for ext in ["webm", "mp4"] { + let media = Media(index: 0, url: try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.\(ext)")), thumbnailUrl: thumbnail) + XCTAssertTrue(ReverseImageSearchProvider.usesThumbnail(for: media)) + XCTAssertEqual(ReverseImageSearchProvider.sourceURL(for: media), thumbnail) + for provider in ReverseImageSearchProvider.allCases { + let url = try XCTUnwrap(provider.searchURL(for: media)) + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "url" }?.value, + thumbnail.absoluteString) + } + } + } + + func testGIFSearchesUseTheOriginalAnimation() throws { + let gif = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.gif")) + let media = Media(index: 0, url: gif, thumbnailUrl: try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123s.jpg"))) + XCTAssertEqual(ReverseImageSearchProvider.sourceURL(for: media), gif) + XCTAssertFalse(ReverseImageSearchProvider.usesThumbnail(for: media)) + } + + func testLocalUnsupportedAndCredentialedSourcesDoNotProduceSearchLinks() throws { + for text in ["file:///tmp/photo.png", "https://example.com/document.pdf", "https://user:password@example.com/photo.png"] { + let url = try XCTUnwrap(URL(string: text)) + let media = Media(index: 0, url: url, thumbnailUrl: url) + for provider in ReverseImageSearchProvider.allCases { XCTAssertNil(provider.searchURL(for: media)) } + } + } + + func testYandexIncludesImageSearchMode() throws { + let url = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.jpg")) + let search = try XCTUnwrap(ReverseImageSearchProvider.yandex.searchURL(for: Media(index: 0, url: url, thumbnailUrl: url))) + XCTAssertEqual(URLComponents(url: search, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "rpt" }?.value, "imageview") + } +} diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 0b59cc6..a155883 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -169,6 +169,79 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.staticTexts["#100"].isHittable) } + func testImageSearchIsAvailableFromPostsAndPagedGalleryMedia() { + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture"]) + openFixtureThread(in: app) + let search = app.buttons["Image Search Post 100"] + XCTAssertTrue(search.isHittable) + search.tap() + assertImageSearchProviders(in: app) + XCTAssertTrue(app.buttons["Open Original Media"].isHittable) + attachScreenshot("Post Image Search") + app.navigationBars.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.tapThumbnailMedia(0) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + let position = app.staticTexts["Gallery Position"] + XCTAssertEqual(position.label, "Media 1 of 3") + attachScreenshot("Gallery Search Button And Position") + app.buttons["Gallery Image Search"].tap() + assertImageSearchProviders(in: app) + attachScreenshot("Gallery Image Search") + position.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.galleryMediaImage(0).swipeUp() + XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 5)) + let nextPosition = NSPredicate(format: "label == %@", "Media 2 of 3") + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: nextPosition, object: position)], timeout: 5), .completed) + app.buttons["Gallery Image Search"].tap() + assertImageSearchProviders(in: app) + XCTAssertTrue(app.buttons["Open Original Media"].isHittable) + attachScreenshot("Search After Gallery Paging") + app.buttons["Image Search IQDB"].tap() + let browser = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + XCTAssertTrue(browser.wait(for: .runningForeground, timeout: 10)) + attachScreenshot("Image Search Browser Handoff") + app.activate() + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + XCTAssertEqual(position.label, "Media 2 of 3") + app.buttons["Close gallery"].tap() + XCTAssertTrue(search.isHittable) + } + + func testCopyQuoteUsesTheSelectedPostAndKeepsReadingPosition() { + let app = launchThreadFixture() + openFixtureThread(in: app, anchor: "#p105") + app.buttons["Post Options 105"].tap() + let copy = app.buttons["Copy Selected Post Quote"] + XCTAssertTrue(copy.waitForExistence(timeout: 5)) + attachScreenshot("Copy Quote Post Action") + copy.tap() + XCTAssertFalse(app.sheets.firstMatch.exists) + XCTAssertTrue(app.staticTexts["#105"].isHittable) + app.buttons["Post Options 105"].tap() + app.buttons["Draft Reply To Post"].tap() + let draft = app.textViews["Reply Draft"] + XCTAssertTrue(draft.waitForExistence(timeout: 5)) + draft.tap() + draft.press(forDuration: 1.2) + let selectAll = app.descendants(matching: .any).matching(identifier: "Select All").firstMatch + XCTAssertTrue(selectAll.waitForExistence(timeout: 5)) + selectAll.tap() + draft.typeText("Paste here") + XCTAssertEqual(draft.value as? String, "Paste here") + draft.press(forDuration: 1.2) + let paste = app.descendants(matching: .any).matching(identifier: "Paste").firstMatch + XCTAssertTrue(paste.waitForExistence(timeout: 5)) + paste.tap() + XCTAssertTrue((draft.value as? String ?? "").contains(">>105")) + attachScreenshot("Copied Quote Pasted Into Reply Draft") + } + + private func assertImageSearchProviders(in app: XCUIApplication) { + for provider in ["Google Lens", "Yandex", "TinEye", "SauceNAO", "IQDB", "trace.moe"] { + XCTAssertTrue(app.buttons["Image Search \(provider)"].isHittable) + } + } + func testArchivedGeneralFindsNewerThread() { let app = launchThreadFixture(extraArguments: ["--ui-general-rollover"]) openFixtureThread(in: app) From 0fb0748f371934fa203e70ca61a90fbcfce5d446 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 19:54:40 -0500 Subject: [PATCH 08/10] Adapt Duo and iPad workspaces and retain reading anchors --- AGENTS.md | 6 +- docs/ipad-testing.md | 28 ++++ docs/iphone-duo-testing.md | 6 +- swiftchan.xcodeproj/project.pbxproj | 4 +- .../Views/Boards/Catalog/CatalogView.swift | 139 +++++++++++++----- .../Boards/Catalog/Thread/ThreadView.swift | 3 + swiftchanUITests/BrowsingUITests.swift | 36 +++++ 7 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 docs/ipad-testing.md diff --git a/AGENTS.md b/AGENTS.md index 8329aa5..2a8ec81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,13 @@ Swiftchan is a SwiftUI imageboard app using MVVM, SwiftData, and Swift Package Manager. The app requires iOS 18.0 or later. Video playback uses KSPlayer and FFmpegKit; CocoaPods and MobileVLCKit are no longer used. +## iPad testing + +Read [docs/ipad-testing.md](docs/ipad-testing.md) for regular-width reading/workspaces, native orientation preparation, fixture safety, and simulator videos. Check iPad mini portrait and iPad Pro landscape, and adapt to the available window. Capture native main display 1 and verify actual window/PNG geometry. Use `uv run` for Python helpers; keep `CLAUDE.md` linked to this file. + ## iPhone Duo testing -Read [docs/iphone-duo-testing.md](docs/iphone-duo-testing.md) before Duo layout, pose, or recording work. Native `agent-device@0.21.20` hinge control is verified for open, book, and closed; use the scoped Duo toolchain and confirm angle plus app-visible geometry. Automated tabletop rotation remains unverified. Capture the lit panel explicitly and distinguish live continuity from saved-state reopening. Use `uv run` for Python helpers. Keep `CLAUDE.md` as the compatibility symlink to this file. +Read [docs/iphone-duo-testing.md](docs/iphone-duo-testing.md) before Duo layout, pose, or recording work. Native `agent-device@0.21.20` hinge control is verified for open, book, and closed; use the scoped Duo toolchain and confirm angle plus app-visible geometry. A manual tabletop quarter-turn is verified; automated physical rotation remains unverified. Capture the lit panel explicitly and distinguish live continuity from saved-state reopening. Use `uv run` for Python helpers. Keep `CLAUDE.md` as the compatibility symlink to this file. ## Setup and validation diff --git a/docs/ipad-testing.md b/docs/ipad-testing.md new file mode 100644 index 0000000..7ea28fa --- /dev/null +++ b/docs/ipad-testing.md @@ -0,0 +1,28 @@ +# iPad layout and simulator review + +Read Apple's complete [Designing for iPadOS](https://developer.apple.com/design/human-interface-guidelines/designing-for-ipados) guidance and [Supporting multiple windows on iPad](https://developer.apple.com/documentation/uikit/supporting-multiple-windows-on-ipad) before changing the layout. Design for the available window, size class, safe areas, and text size, rather than treating device orientation as the layout model. Check portrait, landscape, and narrow windows. These apps have not gained new multiple-window support in this pass. + +## Native simulator capture + +The review uses iPad mini (A17 Pro) portrait (744 x 1133 pt) and iPad Pro 13-inch (M5) landscape (1376 x 1032 pt), on iPadOS 27.0. Standard iPad uses main display 1. Verify the device with `xcrun simctl list devices` and its displays with `xcrun simctl io enumerate`; do not reuse Duo inner display 3. + +An XCTest preparation sets `XCUIDevice.shared.orientation` to `.portrait` or `.landscapeLeft`, launches the app, and waits until the actual app window has the requested aspect ratio before starting recordVideo. Then the walkthrough navigates the real app. Native screenshots must have the same requested orientation: mini portrait 1488 x 2266 pixels, Pro landscape 2752 x 2064 pixels. Do not rotate a PNG/video to claim a native orientation passed. + +```sh +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun simctl io recordVideo --codec=h264 --display=1 review.mp4 +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun simctl io screenshot --display=1 review.png +``` + +Use a scoped DEVELOPER_DIR; do not change global xcode-select. Native Duo APIs require the 27.1/27.2 toolchain described in [iphone-duo-testing.md](iphone-duo-testing.md). The iPad walkthroughs may use that same built binary but run on an ordinary iPad simulator. A passing CLI orientation command alone does not prove app geometry. + +Python capture/package helpers run with `uv run --with pillow python`. Run one UI walkthrough per simulator at a time. Package only actual nonempty passing test cases and passing selected suites; reject assertions, empty test restarts, missing/black screenshots, or incomplete videos. Keep original pixels for PNGs. A review MP4 can be scaled for size, without rotating, cropping, or recreating the UI. Verify videos with a full FFmpeg decode and native AVFoundation playback/frame decode. + +## App behavior and validation + +Regular-width windows at least 580 pt wide use a board and thread workspace. Compact windows retain pushed thread navigation. Keep catalog selection visible, the selected thread readable, and the catalog title inline in the workspace. The sidebar is a catalog for choosing discussions; the detail shows the selected thread and its media/actions. On a native horizontal Duo division, the catalog sits above the thread, with the system division and both 20 pt margins kept clear. iPad uses the balanced NavigationSplitView. Thread scrolling tracks post identity with scrollPosition(id:anchor:), so a window resize can keep the reading post visible. Build 1.3 (3) is the app version. + +Use debug-only in-memory catalog/thread/media fixtures. Check switching threads, returning to boards, gallery next-image/filmstrip/dismissal, favorites, settings, following a general, and post actions. The normal regression suite passes 95 unit tests and 20 UI tests on iPhone Air 26.5; pose-only cases skip there. After adding the post-ID scroll binding, 95 unit tests and eight focused quote, jump, resume, unread, recent-thread, and linked-post UI regressions pass on the final source. Mini portrait and Pro landscape walkthroughs both pass on that source. + +## Review artifacts + +The shared workspace review is `../iphone-duo-review/2026-10-04-current/review/`, with per-app `*-ipad-mini` and `*-ipad-pro` MP4/JSON/PNG packets. `validation.json` records passing cases and source provenance; use it to distinguish historical Duo footage from the current build. The local gallery is http://127.0.0.1:8179/ when its server is running. These artifacts do not indicate an App Store Connect or TestFlight submission. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index f080360..b36d023 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -53,6 +53,8 @@ The hinge helper depends on private simulator HID behavior and the selected simu `orientation portrait` and `orientation landscape-right` returned success here, but the live native probe still reported the same 951 x 669 pt viewport and active vertical division. Therefore automated tabletop rotation is **unverified**. Do not label a book capture as tabletop, or treat an orientation command's success message as proof. A tabletop check must observe an active horizontal division in the app and inspect the actual rendered controls. Use the simulator's physical rotation control when available, and retain this manual requirement until app-visible geometry confirms a working automation path. +The user's physical quarter-turn was verified on 2026-10-04: the native viewport became 669 x 951 pt, with an active horizontal division at y=455.5, height=40, width=669 pt, and 20 pt top/bottom margins. This proves the manual tabletop geometry; it does not prove an automated rotation command. Capture harnesses must preserve that orientation instead of setting landscape again during setup. Native arrangement views can then place primary and secondary content above and below the fold. + ## Screenshots, video, and continuity evidence Always name the lit display. On this simulator: @@ -73,7 +75,9 @@ Distinguish these outcomes: a prepared state; saved state restored after reopeni ## App-specific checks -Use the workspace. Verify the board catalog beside the selected thread, a visible scrolled reply, and background/foreground restoration. Background snapshot geometry must not push a standalone thread route. +Use the workspace. Verify the board catalog beside the selected thread, a visible scrolled reply, and background/foreground restoration. Background snapshot geometry must not push a standalone thread route. Once the device exposes a division (including inactive regions), retain the native ArrangementView container across folding and background snapshots. Keep the remembered workspace mode while inactive. Use a post-ID scroll position binding so pane resizing keeps the reading post visible rather than preserving a stale pixel offset. Keep navigation outside the arrangement. + +The live native Tabletop-to-flat check passes with thread 200 and reply 205 visible before and after background reactivation. All four app process IDs remain unchanged; the other assertions cover Bible chapters 3/4, Mango reading page 2, and advancing real Earmark playback. The Tabletop tour also passes catalog/thread switching, all primary routes, and media actions. Installed app bundle ID: `vanities.swiftchan`. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 475d9cd..cd0c834 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -1101,7 +1101,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1133,7 +1133,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index 87fe2f7..fc1fbee 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -15,6 +15,7 @@ struct CatalogView: View { @Environment(AppState.self) var appState @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass var boardName: String @State var catalogViewModel: CatalogViewModel @@ -24,6 +25,7 @@ struct CatalogView: View { @State private var selectedThread: SwiftchanPost? @State private var usesThreadWorkspace = false + @State private var usesDuoArrangement = false @State private var scene: SKScene = { let s = SnowScene() @@ -56,49 +58,45 @@ struct CatalogView: View { GeometryReader { geometry in let layout = workspaceLayout(in: geometry) Group { - if geometry.size.width >= 760 { - NavigationSplitView { - catalogPosts(filteredPosts, workspace: true) - .navigationSplitViewColumnWidth( - min: layout.sidebarWidth ?? 260, - ideal: layout.sidebarWidth ?? 320, - max: layout.sidebarWidth ?? 400 - ) - } detail: { - NavigationStack { - Group { - if let selectedThread { - ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id, - showsNavigationTitle: false) - .id(selectedThread.id) - .accessibilityIdentifier("CatalogThreadDetail") - } else { - ContentUnavailableView { - Label("Open a Thread", systemImage: "text.bubble") - } description: { - Text("Choose a discussion on the left. Browse the board while you read.") - } - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Back to boards", systemImage: "chevron.left") { dismiss() } - } + if usesThreadWorkspace { +#if IPHONE_DUO_LAYOUTS + if #available(iOS 27.1, *), usesDuoArrangement { + let insets = duoDivisionInsets(in: geometry) + ArrangementView { + catalogPosts(filteredPosts, workspace: true) + .padding(insets.primary) + } secondary: { + workspaceThread + .padding(insets.secondary) + } + .arrangementViewStyle(.split) + .accessibilityIdentifier("BoardThreadWorkspace") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back to boards", systemImage: "chevron.left") { dismiss() } } } - .padding(.leading, layout.hingeGap) + } else { + sideBySideWorkspace(filteredPosts, layout: layout) } - .navigationSplitViewStyle(.balanced) - .accessibilityIdentifier("BoardThreadWorkspace") +#else + sideBySideWorkspace(filteredPosts, layout: layout) +#endif } else { catalogPosts(filteredPosts, workspace: false) } } .onChange(of: scenePhase) { _, phase in - if phase == .active { usesThreadWorkspace = geometry.size.width >= 760 } + if phase == .active { usesThreadWorkspace = wantsWorkspace(in: geometry) } + } + .onGeometryChange(for: Bool.self) { hasDuoDivision(in: $0) } action: { hasDivision in + // Background snapshots may omit reserved regions. Once the + // device exposes a division, retain its container identity. + if hasDivision { usesDuoArrangement = true } } } - .onGeometryChange(for: Bool.self) { $0.size.width >= 760 } action: { isWide in + .onGeometryChange(for: Bool.self) { wantsWorkspace(in: $0) } action: { isWide in // Background snapshots can briefly use compact dimensions. Keep // them from pushing the selected thread onto the navigation stack. if scenePhase == .active { usesThreadWorkspace = isWide } @@ -138,6 +136,7 @@ struct CatalogView: View { catalogViewModel.stopPrefetching() } .navigationBarTitle(boardName) + .navigationBarTitleDisplayMode(usesThreadWorkspace ? .inline : .automatic) .navigationBarItems( trailing: settingsButton @@ -191,6 +190,80 @@ struct CatalogView: View { } } + private func wantsWorkspace(in geometry: GeometryProxy) -> Bool { + geometry.size.width >= 760 || (horizontalSizeClass == .regular && geometry.size.width >= 580) + } + + private func duoDivisionInsets(in geometry: GeometryProxy) -> (primary: EdgeInsets, secondary: EdgeInsets) { +#if IPHONE_DUO_LAYOUTS + if #available(iOS 27.1, *) { + // Keep the same container when the hinge becomes inactive. Replacing + // it with NavigationSplitView can hide the catalog and recreate the thread. + var primary = EdgeInsets() + var secondary = EdgeInsets() + if let fold = geometry.reservedRegions(kind: .division).first { + if fold.frame.width > fold.frame.height { + primary.bottom = fold.margins.bottom + secondary.top = fold.margins.top + } else { + primary.trailing = fold.margins.trailing + secondary.leading = fold.margins.leading + } + } + return (primary, secondary) + } +#endif + return (EdgeInsets(), EdgeInsets()) + } + + private func hasDuoDivision(in geometry: GeometryProxy) -> Bool { +#if IPHONE_DUO_LAYOUTS + if #available(iOS 27.1, *) { + return !geometry.reservedRegions(kind: .division, options: .includeInactive).isEmpty + } +#endif + return false + } + + @ViewBuilder + private var workspaceThread: some View { + if let selectedThread { + ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id, + showsNavigationTitle: false) + .id(selectedThread.id) + .accessibilityIdentifier("CatalogThreadDetail") + } else { + ContentUnavailableView { + Label("Open a Thread", systemImage: "text.bubble") + } description: { + Text("Choose a discussion from the board. Browse the board while you read.") + } + } + } + + private func sideBySideWorkspace(_ posts: [SwiftchanPost], layout: (sidebarWidth: CGFloat?, hingeGap: CGFloat)) -> some View { + NavigationSplitView { + catalogPosts(posts, workspace: true) + .navigationSplitViewColumnWidth( + min: layout.sidebarWidth ?? 260, + ideal: layout.sidebarWidth ?? 320, + max: layout.sidebarWidth ?? 400 + ) + } detail: { + NavigationStack { + workspaceThread + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back to boards", systemImage: "chevron.left") { dismiss() } + } + } + } + .padding(.leading, layout.hingeGap) + } + .navigationSplitViewStyle(.balanced) + .accessibilityIdentifier("BoardThreadWorkspace") + } + private func workspaceLayout(in geometry: GeometryProxy) -> (sidebarWidth: CGFloat?, hingeGap: CGFloat) { #if IPHONE_DUO_LAYOUTS if #available(iOS 27.1, *), diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index aa05123..dcebeb8 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -33,6 +33,7 @@ struct ThreadView: View { @AppStorage("rememberThreadPositions") private var rememberThreadPositions = true @State private var reading = ThreadReadingSession() @State private var visiblePostIDs: [Int] = [] + @State private var scrollPostID: Int? @State private var showFollowGeneral = false @State private var rolloverGeneral: RecurringFavorite? @State private var nextGeneral: ThreadDestination? @@ -138,6 +139,8 @@ struct ThreadView: View { } } .accessibilityIdentifier("Thread Posts") + // Track a post rather than a pixel offset when the pane reflows. + .scrollPosition(id: $scrollPostID, anchor: .top) .task(id: linkedPost) { if linkedPost == nil, let postID = replyReturnPostID { // Restore the reading anchor after navigation and gallery layout changes. diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index a155883..2bc77bd 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,42 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testTabletopBoardAndThreadWorkspace() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_TABLETOP"] == "1", + "Requires a verified native horizontal Duo division.") + continueAfterFailure = false + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture"]) + app.buttons["Open Link Button"].tap() + let link = app.textFields["Open Link URL"] + XCTAssertTrue(link.waitForExistence(timeout: 5)) + link.tap() + link.typeText("https://boards.4chan.org/biz/") + app.buttons["Open Link Confirm"].tap() + let row = app.buttons["CatalogThread100"] + XCTAssertTrue(row.waitForExistence(timeout: 10)) + row.tap() + let post = app.staticTexts["#100"] + XCTAssertTrue(post.waitForExistence(timeout: 10)) + let workspace = app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch + XCTAssertTrue(workspace.exists) + print("DUO_TABLETOP_WORKSPACE:catalog=\(row.frame);post=\(post.frame);window=\(app.windows.firstMatch.frame)") + XCTAssertLessThanOrEqual(row.frame.maxY, 436.5) + XCTAssertGreaterThanOrEqual(post.frame.minY, 514.5) + captureDuo(app, "tabletop_01_board_and_thread") + app.buttons["CatalogThread200"].tap() + let selected = app.staticTexts["#200"] + XCTAssertTrue(selected.waitForExistence(timeout: 10)) + XCTAssertTrue(selected.isHittable) + captureDuo(app, "tabletop_02_switch_thread") + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(workspace.waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["CatalogThread200"].isHittable) + XCTAssertTrue(selected.isHittable) + XCTAssertGreaterThanOrEqual(selected.frame.minY, 514.5) + captureDuo(app, "tabletop_03_retained_selection") + } + func testDuoWorkspaceSurvivesBackgroundActivation() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", "Run on Duo's open inner display.") From 686ec322ca7b48d9aed342ce1554dbcd6620373e Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 20:00:21 -0500 Subject: [PATCH 09/10] Record native Duo quarter-turn verification limits --- docs/iphone-duo-testing.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index b36d023..6507c00 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -73,6 +73,10 @@ A single-panel recording can show a live flat/book transition, but does not prov Distinguish these outcomes: a prepared state; saved state restored after reopening; an existing process reactivated after a pose change; and the same app staying foreground throughout a recorded fold. Assert visible content and interaction, not only an offscreen DOM title or a retained model identifier. +## Physical quarter-turn verification + +On 2026-10-04 a scoped Xcode 27.1 XCTest set XCUIDevice.shared.orientation to landscapeLeft while the Duo was in Tabletop. Its real landscape-window expectation failed. The fresh native probe still reported viewport 669 x 951 and an active horizontal division at y=455.5, height 40, with top/bottom margins 20. Standard iPad XCTest orientation preparation passes; do not infer Duo physical rotation support from it. Native agent-device hinge changes remain verified. Use the Simulator's manual quarter-turn for Duo, then read native region geometry. This establishes the tested limitation of these current commands, not a claim that every future automation path is unavailable. + ## App-specific checks Use the workspace. Verify the board catalog beside the selected thread, a visible scrolled reply, and background/foreground restoration. Background snapshot geometry must not push a standalone thread route. Once the device exposes a division (including inactive regions), retain the native ArrangementView container across folding and background snapshots. Keep the remembered workspace mode while inactive. Use a post-ID scroll position binding so pane resizing keeps the reading post visible rather than preserving a stale pixel offset. Keep navigation outside the arrangement. From 5e31854ab068f9e3d9e8f008e9be0c921f0aee36 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 20:56:52 -0500 Subject: [PATCH 10/10] Keep catalog grids adaptive across Duo and iPad --- .github/workflows/build_and_test.yml | 2 +- AGENTS.md | 3 + docs/ipad-testing.md | 6 +- docs/iphone-duo-testing.md | 2 + swiftchan.xcodeproj/project.pbxproj | 4 +- swiftchan/Services/ThreadUITestFixture.swift | 14 ++- .../Views/Boards/Catalog/CatalogView.swift | 100 ++++++++---------- swiftchanUITests/BrowsingUITests.swift | 77 +++++++++++++- 8 files changed, 140 insertions(+), 68 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index b9b8c5b..d87eb1e 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -48,7 +48,7 @@ jobs: xcodebuild -resolvePackageDependencies -workspace swiftchan.xcodeproj/project.xcworkspace -scheme swiftchan -clonedSourcePackagesDirPath build/SourcePackages -onlyUsePackageVersionsFromResolvedFile - name: Build and test - timeout-minutes: 20 + timeout-minutes: 25 run: bash ci_scripts/test_with_diagnostics.sh - name: Upload test results diff --git a/AGENTS.md b/AGENTS.md index 2a8ec81..bd59863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ Read [docs/ipad-testing.md](docs/ipad-testing.md) for regular-width reading/work Read [docs/iphone-duo-testing.md](docs/iphone-duo-testing.md) before Duo layout, pose, or recording work. Native `agent-device@0.21.20` hinge control is verified for open, book, and closed; use the scoped Duo toolchain and confirm angle plus app-visible geometry. A manual tabletop quarter-turn is verified; automated physical rotation remains unverified. Capture the lit panel explicitly and distinguish live continuity from saved-state reopening. Use `uv run` for Python helpers. Keep `CLAUDE.md` as the compatibility symlink to this file. +Keep the catalog's OP card grid in both compact navigation and board/thread workspaces. Compact phones, the closed Duo, and an active native Duo division use two columns. With the Duo flat or on iPad, derive additional columns from the catalog pane's available width; a narrow iPad sidebar may still fit only two. Check the real two-to-more-to-two transition without relaunching or losing the selected thread and visible reply. + ## Setup and validation - Use Xcode 26.6 (CI uses this version); updated packages require at least Swift 6.2 / Xcode 26. @@ -51,5 +53,6 @@ KSPlayer is intentionally pinned to `bb6d367c02f3247ef3f328dc0cfea34e77aef7c5` f - UI tests live in `swiftchanUITests/`. The shared scheme runs `BrowsingUITests` for following/editing generals, link validation, reading restoration, and unread navigation. These tests use a debug-only in-memory favorites store (`--ui-testing`); Thread UI tests opt into a deterministic debug-only fixture with `--ui-thread-fixture`; older live-network UI tests remain excluded. - Add focused regressions for behavior fixes. Prefer fixtures and injected loaders over live network dependencies. - GitHub Actions builds and runs unit and focused UI tests for pull requests. +- The build/test step has a 25-minute limit within the 30-minute job, allowing cold dependency compilation plus the full UI suite. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. - `bundle exec fastlane beta` signs and uploads a TestFlight release, then bumps the build number. Run it only when releasing is requested. - Certificates are managed with Fastlane match (`make renew_certs` / `make get_certs`). diff --git a/docs/ipad-testing.md b/docs/ipad-testing.md index 7ea28fa..4d96532 100644 --- a/docs/ipad-testing.md +++ b/docs/ipad-testing.md @@ -19,10 +19,14 @@ Python capture/package helpers run with `uv run --with pillow python`. Run one U ## App behavior and validation -Regular-width windows at least 580 pt wide use a board and thread workspace. Compact windows retain pushed thread navigation. Keep catalog selection visible, the selected thread readable, and the catalog title inline in the workspace. The sidebar is a catalog for choosing discussions; the detail shows the selected thread and its media/actions. On a native horizontal Duo division, the catalog sits above the thread, with the system division and both 20 pt margins kept clear. iPad uses the balanced NavigationSplitView. Thread scrolling tracks post identity with scrollPosition(id:anchor:), so a window resize can keep the reading post visible. Build 1.3 (3) is the app version. +Regular-width windows at least 580 pt wide use a board and thread workspace. Compact windows retain pushed thread navigation. Keep catalog selection visible, the selected thread readable, and the catalog title inline in the workspace. The sidebar uses the same OP card grid as compact navigation; the detail shows the selected thread and its media/actions. Compact phones and active Duo divisions keep two catalog columns. Flat Duo and regular iPad layouts add columns when the actual catalog pane has room, with approximately 140 pt per card; a narrow iPad sidebar still uses two. Selected cards have an accent outline. On a native horizontal Duo division, the catalog sits above the thread, with the system division and both 20 pt margins kept clear. iPad uses the balanced NavigationSplitView. Thread scrolling tracks post identity with scrollPosition(id:anchor:), so a window resize can keep the reading post visible. Build 1.3 (4) is the app version. Use debug-only in-memory catalog/thread/media fixtures. Check switching threads, returning to boards, gallery next-image/filmstrip/dismissal, favorites, settings, following a general, and post actions. The normal regression suite passes 95 unit tests and 20 UI tests on iPhone Air 26.5; pose-only cases skip there. After adding the post-ID scroll binding, 95 unit tests and eight focused quote, jump, resume, unread, recent-thread, and linked-post UI regressions pass on the final source. Mini portrait and Pro landscape walkthroughs both pass on that source. +Those counts describe build 3. Build 4 adds `testCompactCatalogKeepsTwoColumnsAndOpensThreads`, which checks real card frames and pushed navigation, and the opt-in `testDuoCatalogGridExpandsAndRefoldsWithoutLosingThread`. The native grid transition passes with two columns at 130 degrees, four at 180 degrees, and two after returning to 130 degrees, while thread 200 and reply 205 remain visible. Use `--ui-catalog-grid-fixture` for twelve invented catalog cards. Do not enable pose-test flags on ordinary CI phones. + ## Review artifacts The shared workspace review is `../iphone-duo-review/2026-10-04-current/review/`, with per-app `*-ipad-mini` and `*-ipad-pro` MP4/JSON/PNG packets. `validation.json` records passing cases and source provenance; use it to distinguish historical Duo footage from the current build. The local gallery is http://127.0.0.1:8179/ when its server is running. These artifacts do not indicate an App Store Connect or TestFlight submission. + +The follow-up catalog grid review is `../iphone-duo-review/2026-10-04-grid/`. Keep its build 4 clips and validation separate from the earlier build 3 Swiftchan footage. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index 6507c00..70070c4 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -83,6 +83,8 @@ Use the workspace. Verify the board catalog beside the selected thread, a visibl The live native Tabletop-to-flat check passes with thread 200 and reply 205 visible before and after background reactivation. All four app process IDs remain unchanged; the other assertions cover Bible chapters 3/4, Mango reading page 2, and advancing real Earmark playback. The Tabletop tour also passes catalog/thread switching, all primary routes, and media actions. +Build 1.3 (4) preserves the OP card catalog grid in the workspace. The closed outer display and an active native division keep two columns. Flat Duo uses the actual catalog pane width to add columns. `testDuoCatalogGridExpandsAndRefoldsWithoutLosingThread` passes a foreground 130-to-180-to-130-degree transition: two columns, four columns, then two, with thread 200 and reply 205 still visible. Run it with `TEST_RUNNER_CATALOG_GRID_CAPTURE=1`, preserve the verified manual tabletop quarter-turn, and use native hinge watchers at `CATALOG_GRID_READY_TO_UNFOLD` and `CATALOG_GRID_READY_TO_FOLD`. The twelve-card fixture is debug-only. The follow-up videos and native PNGs live in `../iphone-duo-review/2026-10-04-grid/`; earlier build 3 clips retain their original provenance. + Installed app bundle ID: `vanities.swiftchan`. ## Sources diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index cd0c834..1997817 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -1101,7 +1101,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1133,7 +1133,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; diff --git a/swiftchan/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 416874e..8750d2e 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -9,7 +9,8 @@ static func load(board: String, id: Int) throws -> ChanThread? { let arguments = ProcessInfo.processInfo.arguments guard arguments.contains("--ui-thread-fixture"), board == "biz", - id == 100 || ((arguments.contains("--ui-general-rollover") || arguments.contains("--ui-catalog-fixture")) && id == 200) else { return nil } + id == 100 || ((arguments.contains("--ui-general-rollover") || arguments.contains("--ui-catalog-fixture")) && id == 200) + || (arguments.contains("--ui-catalog-grid-fixture") && (100...1200).contains(id) && id.isMultiple(of: 100)) 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)] @@ -71,10 +72,13 @@ static func catalog(board: String) throws -> Catalog? { let arguments = ProcessInfo.processInfo.arguments guard board == "biz" else { return nil } - if arguments.contains("--ui-catalog-fixture") { - let threads: [[String: Any]] = [100, 200].map { id in - seedMedia(timestamp: 9_000_000_000 + id, index: id == 100 ? 0 : 1) - return ["no": id, "sub": id == 100 ? "Coin collecting — this week's finds" : "Designing a display for a collection", + if arguments.contains("--ui-catalog-fixture") || arguments.contains("--ui-catalog-grid-fixture") { + let ids = arguments.contains("--ui-catalog-grid-fixture") ? (1...12).map { $0 * 100 } : [100, 200] + let titles = ["Coin collecting — this week's finds", "Designing a display for a collection", + "Favorite local museums", "Organizing a stamp collection", "Weekend flea market finds", "Photographing small objects"] + let threads: [[String: Any]] = ids.map { id in + seedMedia(timestamp: 9_000_000_000 + id, index: id / 100 - 1) + return ["no": id, "sub": titles[(id / 100 - 1) % titles.count], "com": "A sample discussion about collecting coins and precious metals.", "replies": 14, "images": 3, "tim": 9_000_000_000 + id, "ext": ".png", "tn_w": 250, "tn_h": 188] } diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index fc1fbee..543137f 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -57,13 +57,15 @@ struct CatalogView: View { case .loaded: GeometryReader { geometry in let layout = workspaceLayout(in: geometry) + let twoColumnGrid = hasActiveDuoDivision(in: geometry) + || (horizontalSizeClass == .compact && !hasDuoDivision(in: geometry)) Group { if usesThreadWorkspace { #if IPHONE_DUO_LAYOUTS if #available(iOS 27.1, *), usesDuoArrangement { let insets = duoDivisionInsets(in: geometry) ArrangementView { - catalogPosts(filteredPosts, workspace: true) + catalogPosts(filteredPosts, workspace: true, twoColumns: twoColumnGrid) .padding(insets.primary) } secondary: { workspaceThread @@ -84,7 +86,7 @@ struct CatalogView: View { sideBySideWorkspace(filteredPosts, layout: layout) #endif } else { - catalogPosts(filteredPosts, workspace: false) + catalogPosts(filteredPosts, workspace: false, twoColumns: twoColumnGrid) } } .onChange(of: scenePhase) { _, phase in @@ -225,6 +227,15 @@ struct CatalogView: View { return false } + private func hasActiveDuoDivision(in geometry: GeometryProxy) -> Bool { +#if IPHONE_DUO_LAYOUTS + if #available(iOS 27.1, *) { + return !geometry.reservedRegions(kind: .division).isEmpty + } +#endif + return false + } + @ViewBuilder private var workspaceThread: some View { if let selectedThread { @@ -243,7 +254,7 @@ struct CatalogView: View { private func sideBySideWorkspace(_ posts: [SwiftchanPost], layout: (sidebarWidth: CGFloat?, hingeGap: CGFloat)) -> some View { NavigationSplitView { - catalogPosts(posts, workspace: true) + catalogPosts(posts, workspace: true, twoColumns: horizontalSizeClass == .compact) .navigationSplitViewColumnWidth( min: layout.sidebarWidth ?? 260, ideal: layout.sidebarWidth ?? 320, @@ -280,69 +291,44 @@ struct CatalogView: View { return (nil, 0) } - private func catalogPosts(_ posts: [SwiftchanPost], workspace: Bool) -> some View { - ScrollViewReader { reader in - ScrollView(.vertical) { - LazyVGrid(columns: workspace ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 155), spacing: 0)], - alignment: .center, spacing: workspace ? 8 : 0) { - ForEach(posts) { post in - if !post.post.isHidden(boardName: boardName), - PostFilterStore.shared.effect(board: boardName, post: post.post, text: String(post.comment.characters)) != .hide { - Button { - selectedThread = post - } label: { - if workspace { - catalogRow(post) - } else { + private func catalogPosts(_ posts: [SwiftchanPost], workspace: Bool, twoColumns: Bool) -> some View { + GeometryReader { geometry in + let inset: CGFloat = workspace ? 10 : 0 + let count = twoColumns ? 2 : max(2, Int((geometry.size.width - inset * 2) / 140)) + let columns = Array(repeating: GridItem(.flexible(), spacing: 0, alignment: .top), count: count) + ScrollViewReader { reader in + ScrollView(.vertical) { + LazyVGrid(columns: columns, alignment: .center, spacing: 0) { + ForEach(posts) { post in + if !post.post.isHidden(boardName: boardName), + PostFilterStore.shared.effect(board: boardName, post: post.post, text: String(post.comment.characters)) != .hide { + Button { + selectedThread = post + } label: { OPView(boardName: boardName, post: post) + .overlay { + RoundedRectangle(cornerRadius: OPView.Constants.backgroundCornerRadius) + .strokeBorder(selectedThread?.id == post.id ? Color.accentColor : .clear, lineWidth: 2) + } } + .buttonStyle(.plain) + .accessibilityIdentifier("CatalogThread\(post.id)") + .id(post.id) + .opacity(isSearching && !catalogViewModel.searchResultIndices.isEmpty ? + (catalogViewModel.getCurrentSearchResultPostIndex().map { catalogViewModel.posts[$0].id } == post.id ? 1 : 0.5) : 1) } - .buttonStyle(.plain) - .accessibilityIdentifier("CatalogThread\(post.id)") - .id(post.id) - .opacity(isSearching && !catalogViewModel.searchResultIndices.isEmpty ? - (catalogViewModel.getCurrentSearchResultPostIndex().map { catalogViewModel.posts[$0].id } == post.id ? 1 : 0.5) : 1) } } + .padding(inset) } - .padding(workspace ? 10 : 0) - } - .accessibilityIdentifier("Catalog Threads") - .onChange(of: catalogViewModel.currentSearchResultIndex) { _, _ in - if let index = catalogViewModel.getCurrentSearchResultPostIndex(), catalogViewModel.posts.indices.contains(index) { - withAnimation { reader.scrollTo(catalogViewModel.posts[index].id, anchor: .center) } - } - } - } - } - - private func catalogRow(_ post: SwiftchanPost) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 10) { - if let url = post.post.getMediaUrl(boardId: boardName), - let thumbnail = post.post.getMediaUrl(boardId: boardName, thumbnail: true) { - ThumbnailMediaView(url: url, thumbnailUrl: thumbnail) - .frame(width: 72, height: 72) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: 8)) - .allowsHitTesting(false) - } - VStack(alignment: .leading, spacing: 5) { - Text(post.post.sub?.clean ?? "Thread #\(post.id)") - .font(.subheadline.weight(.semibold)) - .lineLimit(3) - Text("\(post.post.replies ?? 0) replies · \(post.post.images ?? 0) images") - .font(.caption) - .foregroundStyle(.secondary) + .accessibilityIdentifier("Catalog Threads") + .onChange(of: catalogViewModel.currentSearchResultIndex) { _, _ in + if let index = catalogViewModel.getCurrentSearchResultPostIndex(), catalogViewModel.posts.indices.contains(index) { + withAnimation { reader.scrollTo(catalogViewModel.posts[index].id, anchor: .center) } + } } } - Text(post.comment).font(.subheadline).lineLimit(3) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(selectedThread?.id == post.id ? Color.accentColor.opacity(0.12) : Color(uiColor: .secondarySystemGroupedBackground), - in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(selectedThread?.id == post.id ? Color.accentColor.opacity(0.5) : .clear)) } var settingsButton: some View { diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 2bc77bd..139741a 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,72 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testCompactCatalogKeepsTwoColumnsAndOpensThreads() throws { + let app = launchThreadFixture(extraArguments: ["--ui-catalog-grid-fixture", "--ui-media-fixture"]) + try XCTSkipUnless(app.windows.firstMatch.frame.width < 580, "Run the compact catalog check on a phone.") + openGridCatalog(in: app) + assertTwoCatalogColumns(in: app) + attachScreenshot("Compact Two Column Catalog") + app.buttons["CatalogThread200"].tap() + XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch.exists) + } + + func testDuoCatalogGridExpandsAndRefoldsWithoutLosingThread() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["CATALOG_GRID_CAPTURE"] == "1", + "Run on a partially folded Duo with native hinge control.") + let app = launchThreadFixture(extraArguments: ["--ui-catalog-grid-fixture", "--ui-media-fixture"]) + openGridCatalog(in: app) + assertTwoCatalogColumns(in: app) + app.buttons["CatalogThread200"].tap() + let anchor = app.staticTexts["#205"] + let scroll = app.scrollViews["CatalogThreadDetail"] + for _ in 0..<40 { + if anchor.isHittable { break } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.8)) + let end = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.55)) + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1) + } + XCTAssertTrue(anchor.isHittable) + captureDuo(app, "grid_01_folded_two_columns") + print("CATALOG_GRID_READY_TO_UNFOLD") + let third = app.buttons["CatalogThread300"] + let first = app.buttons["CatalogThread100"] + let expanded = NSPredicate { _, _ in third.exists && abs(third.frame.minY - first.frame.minY) < 2 } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: expanded, object: nil)], timeout: 90), .completed) + XCTAssertTrue(app.buttons["CatalogThread200"].isHittable) + XCTAssertTrue(anchor.isHittable) + captureDuo(app, "grid_02_unfolded_more_columns") + print("CATALOG_GRID_READY_TO_FOLD") + let refolded = NSPredicate { _, _ in third.exists && third.frame.minY > first.frame.minY + 10 } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: refolded, object: nil)], timeout: 90), .completed) + assertTwoCatalogColumns(in: app) + XCTAssertTrue(anchor.isHittable) + captureDuo(app, "grid_03_refolded_two_columns") + } + + private func openGridCatalog(in app: XCUIApplication) { + app.buttons["Open Link Button"].tap() + let field = app.textFields["Open Link URL"] + XCTAssertTrue(field.waitForExistence(timeout: 5)) + field.tap() + field.typeText("https://boards.4chan.org/biz/") + app.buttons["Open Link Confirm"].tap() + XCTAssertTrue(app.buttons["CatalogThread100"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["CatalogThread200"].waitForExistence(timeout: 10)) + } + + private func assertTwoCatalogColumns(in app: XCUIApplication) { + let first = app.buttons["CatalogThread100"] + let second = app.buttons["CatalogThread200"] + let third = app.buttons["CatalogThread300"] + XCTAssertTrue(third.waitForExistence(timeout: 10)) + XCTAssertEqual(first.frame.minY, second.frame.minY, accuracy: 2) + XCTAssertGreaterThan(second.frame.minX, first.frame.maxX - 2) + XCTAssertGreaterThan(third.frame.minY, first.frame.minY + 10) + print("CATALOG_GRID_FRAMES: first=\(first.frame); second=\(second.frame); third=\(third.frame)") + } + func testTabletopBoardAndThreadWorkspace() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_TABLETOP"] == "1", "Requires a verified native horizontal Duo division.") @@ -240,7 +306,14 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) XCTAssertEqual(position.label, "Media 2 of 3") app.buttons["Close gallery"].tap() - XCTAssertTrue(search.isHittable) + // Gallery paging returns to the post for the selected media. The first + // post can now be above the viewport on smaller phones. + let returnedSearch = app.buttons["Image Search Post 101"] + let hittable = NSPredicate(format: "hittable == true") + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: hittable, object: returnedSearch)], timeout: 10), .completed) + attachScreenshot("Paged Gallery Returns To Selected Media Post") + returnedSearch.tap() + assertImageSearchProviders(in: app) } func testCopyQuoteUsesTheSelectedPostAndKeepsReadingPosition() { @@ -612,7 +685,7 @@ final class BrowsingUITests: XCTestCase { 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) + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: completeName, object: nameField(in: app))], timeout: 20), .completed) XCTAssertTrue(app.buttons["Save General"].isEnabled) }