diff --git a/README.md b/README.md index 30aa4fd..b410eb6 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ If you want to test it with me on testflight or think we can get it on the app s ## Usage +In a thread, use **Jump → First Post / Latest Reply** to move through long discussions without repeated scrolling. Hidden posts are skipped. + Use **Settings → Favorites Backup** to export saved thread links and named generals as JSON. Importing a backup adds missing entries while keeping existing names. Backups do not include media files, settings, or reading progress. Use **Open Link** on the Boards tab to jump to a board by entering its shortcut, such as `/biz/`, or paste a full board or thread URL. diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index 389f4ca..e1228dc 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -136,6 +136,33 @@ struct ThreadView: View { .onScrollPhaseChange { _, phase in if phase == .idle { ThreadReadingStore.shared.flush() } } + .safeAreaInset(edge: .bottom, spacing: 0) { + if !isSearching, viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), + readablePostIDs.count > 1 { + HStack { + Spacer() + Menu { + Button("First Post", systemImage: "arrow.up.to.line") { + if let first = readablePostIDs.first { reader.scrollTo(first, anchor: .top) } + } + .accessibilityIdentifier("Jump To First Post") + Button("Latest Reply", systemImage: "arrow.down.to.line") { + if let last = readablePostIDs.last { reader.scrollTo(last, anchor: .bottom) } + } + .accessibilityIdentifier("Jump To Latest Reply") + } label: { + Label("Jump", systemImage: "arrow.up.arrow.down") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + } + .accessibilityLabel("Jump within thread") + .accessibilityIdentifier("Thread Jump Menu") + .padding(12) + } + } + } .safeAreaInset(edge: .top, spacing: 0) { if rememberThreadPositions, !isSearching, viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), let firstUnread = unreadPostIDs.first { diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index e3cc45d..f14c352 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,6 +2,31 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { + func testJumpBetweenFirstPostAndLatestReply() { + let app = launchThreadFixture(rememberProgress: false) + openFixtureThread(in: app) + XCTAssertTrue(app.staticTexts["#100"].isHittable) + app.buttons["Thread Jump Menu"].tap() + app.buttons["Jump To Latest Reply"].tap() + XCTAssertTrue(app.staticTexts["#114"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["#114"].isHittable) + XCTAssertFalse(app.staticTexts["#100"].isHittable) + attachScreenshot("Jump To Latest Reply", app: app) + app.buttons["Post Options 114"].tap() + app.buttons["Hide Selected Post"].tap() + XCTAssertTrue(app.buttons["Undo Hide"].waitForExistence(timeout: 5)) + app.buttons["Thread Jump Menu"].tap() + app.buttons["Jump To First Post"].tap() + XCTAssertTrue(app.staticTexts["#100"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["#100"].isHittable) + XCTAssertFalse(app.staticTexts["#114"].isHittable) + app.buttons["Thread Jump Menu"].tap() + app.buttons["Jump To Latest Reply"].tap() + XCTAssertTrue(app.staticTexts["#113"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["#113"].isHittable) + XCTAssertFalse(app.staticTexts["#114"].exists) + } + func testFavoritesBackupShowsSavedGeneralAndOpensExport() { let app = launchApp() app.buttons["Settings"].tap()