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 f71a5c4..bd59863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,16 @@ 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. 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. @@ -43,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 new file mode 100644 index 0000000..4d96532 --- /dev/null +++ b/docs/ipad-testing.md @@ -0,0 +1,32 @@ +# 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 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 new file mode 100644 index 0000000..70070c4 --- /dev/null +++ b/docs/iphone-duo-testing.md @@ -0,0 +1,98 @@ +# 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. + +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: + +```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. + +## 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. + +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 + +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 diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 4a7391a..1997817 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,12 +7,15 @@ 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 */; }; 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 */; }; @@ -161,12 +164,15 @@ /* 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 = ""; }; 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 = ""; }; @@ -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 */, @@ -557,7 +566,7 @@ children = ( 9599CA95254DF8C0002EB026 /* ThreadView.swift */, CA000002CCCC000100000001 /* ReplyDraftSheet.swift */, - CA000001CCCC000100000001 /* QuotePreviewSheet.swift */, + CA000001CCCC000100000001 /* PostDetailView.swift */, 9599CA9F254DFDA1002EB026 /* PostView.swift */, 95D804CD25677FF4005A3128 /* RepliesView.swift */, 9559F79228443E7D00F5803F /* ThreadAutoRefresher.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 */, @@ -855,7 +866,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 */, @@ -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 */, @@ -1089,7 +1101,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1100,7 +1112,11 @@ "$(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"; + "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.2]" = "$(inherited) IPHONE_DUO_LAYOUTS"; PRODUCT_BUNDLE_IDENTIFIER = vanities.swiftchan; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1117,7 +1133,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1128,7 +1144,11 @@ "$(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"; + "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/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/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/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/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 62b0675..8750d2e 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -1,31 +1,91 @@ #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) + || (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)] + 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 || (arguments.contains("--ui-quote-fixture") && number == 104) { + 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"), (103...105).contains(number) { + 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") || 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] + } + 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..543137f 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -13,6 +13,9 @@ struct CatalogView: View { @AppStorage("hideTabOnBoards") var hideTabOnBoards = false @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 @@ -20,10 +23,9 @@ 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 usesDuoArrangement = false @State private var scene: SKScene = { let s = SnowScene() @@ -53,43 +55,60 @@ 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 + 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, twoColumns: twoColumnGrid) + .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() } } - .buttonStyle(PlainButtonStyle()) - .id(post.id) - .opacity(isSearching && highlightedPostID != nil ? - (post.id == highlightedPostID ? 1.0 : 0.5) : 1.0) } + } else { + sideBySideWorkspace(filteredPosts, layout: layout) } +#else + sideBySideWorkspace(filteredPosts, layout: layout) +#endif + } else { + catalogPosts(filteredPosts, workspace: false, twoColumns: twoColumnGrid) } } - .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) - } - } + .onChange(of: scenePhase) { _, phase in + 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) { 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 } + } + .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 { searchToolbar @@ -119,16 +138,11 @@ struct CatalogView: View { catalogViewModel.stopPrefetching() } .navigationBarTitle(boardName) + .navigationBarTitleDisplayMode(usesThreadWorkspace ? .inline : .automatic) .navigationBarItems( 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 +192,145 @@ 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 + } + + 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 { + 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, twoColumns: horizontalSizeClass == .compact) + .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, *), + 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, 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) + } + } + } + .padding(inset) + } + .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) } + } + } + } + } + } + var settingsButton: some View { Button(action: { withAnimation { 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 14d83c4..54e4fac 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 @@ -52,14 +52,15 @@ 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, 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 @@ -71,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) } @@ -175,12 +178,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 b1fcd23..dcebeb8 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -27,18 +27,20 @@ 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() @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? @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 +56,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, @@ -122,7 +125,7 @@ struct ThreadView: View { recordVisiblePosts() } .onChange(of: presentationState.galleryIndex) { _, _ in - if !presentationState.presentingReplies && !showReply { + if !presentationState.presentingReplies && linkedPost == nil { scrollToPost(reader: reader) } } @@ -136,6 +139,16 @@ 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. + await Task.yield() + reader.scrollTo(postID, anchor: .top) + replyReturnPostID = nil + } + } .onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.1) { ids in visiblePostIDs = ids recordVisiblePosts() @@ -149,6 +162,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) } } @@ -170,24 +189,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) { @@ -273,6 +274,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() @@ -286,7 +290,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() @@ -342,16 +346,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, @@ -363,6 +369,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) @@ -383,7 +396,7 @@ struct ThreadView: View { .accessibilityIdentifier("Hide Selected Post") } .padding() - .presentationDetents([.height(290), .medium]) + .presentationDetents([.height(350), .medium]) .presentationDragIndicator(.visible) } } @@ -593,7 +606,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 { @@ -606,8 +619,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 } @@ -626,7 +639,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/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..7c4bb39 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,42 @@ struct GalleryView: View { } } } - .overlay(alignment: .topLeading) { - closeButton + .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) + } } - .onDisappear { - restorePagerScrolling() + .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") + } } - .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 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 { @@ -127,10 +171,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) } @@ -155,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) ) @@ -237,28 +283,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/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/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/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 3bd6e13..139741a 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,16 +2,353 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { - func testQuotePreviewChainDismissesBackToReadingPosition() { - let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture"]) + 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.") + 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.") + 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", + "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 + 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 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 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() + // 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() { + 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() { @@ -236,6 +573,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") @@ -346,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) }