From f281a398cbb42e4d6b2b3a62dc1802720d38653c Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 16:38:53 -0500 Subject: [PATCH 1/2] Restore nested reply navigation and unify thread jumps --- swiftchan.xcodeproj/project.pbxproj | 8 +-- swiftchan/Models/PresentationState.swift | 8 +-- swiftchan/Services/ThreadUITestFixture.swift | 5 +- .../Catalog/Thread/PostDetailView.swift | 25 ++++++++ .../Boards/Catalog/Thread/PostView.swift | 15 ++--- .../Catalog/Thread/QuotePreviewSheet.swift | 57 ------------------ .../Boards/Catalog/Thread/RepliesView.swift | 35 +++++++---- .../Boards/Catalog/Thread/ThreadView.swift | 59 ++++++++++--------- swiftchanUITests/BrowsingUITests.swift | 56 ++++++++++++++++-- 9 files changed, 145 insertions(+), 123 deletions(-) create mode 100644 swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift delete mode 100644 swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index e979859..d0f5ca7 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ CA000004CCCC000100000002 /* PostFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000004CCCC000100000001 /* PostFiltersView.swift */; }; CA000003CCCC000100000002 /* ChanPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000003CCCC000100000001 /* ChanPreferences.swift */; }; CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000002CCCC000100000001 /* ReplyDraftSheet.swift */; }; - CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000001CCCC000100000001 /* QuotePreviewSheet.swift */; }; + CA000001CCCC000100000002 /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000001CCCC000100000001 /* PostDetailView.swift */; }; AC000001CCCC000100000002 /* RecentThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC000001CCCC000100000001 /* RecentThreadsView.swift */; }; AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000003AAAA000100000001 /* SavedReplyTests.swift */; }; AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF000002AAAA000100000001 /* SavedRepliesView.swift */; }; @@ -166,7 +166,7 @@ CA000004CCCC000100000001 /* PostFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFiltersView.swift; sourceTree = ""; }; CA000003CCCC000100000001 /* ChanPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChanPreferences.swift; sourceTree = ""; }; CA000002CCCC000100000001 /* ReplyDraftSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyDraftSheet.swift; sourceTree = ""; }; - CA000001CCCC000100000001 /* QuotePreviewSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePreviewSheet.swift; sourceTree = ""; }; + CA000001CCCC000100000001 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = ""; }; AC000001CCCC000100000001 /* RecentThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentThreadsView.swift; sourceTree = ""; }; AF000003AAAA000100000001 /* SavedReplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedReplyTests.swift; sourceTree = ""; }; AF000002AAAA000100000001 /* SavedRepliesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRepliesView.swift; sourceTree = ""; }; @@ -557,7 +557,7 @@ children = ( 9599CA95254DF8C0002EB026 /* ThreadView.swift */, CA000002CCCC000100000001 /* ReplyDraftSheet.swift */, - CA000001CCCC000100000001 /* QuotePreviewSheet.swift */, + CA000001CCCC000100000001 /* PostDetailView.swift */, 9599CA9F254DFDA1002EB026 /* PostView.swift */, 95D804CD25677FF4005A3128 /* RepliesView.swift */, 9559F79228443E7D00F5803F /* ThreadAutoRefresher.swift */, @@ -855,7 +855,7 @@ 9559F78F2843B07D00F5803F /* NSFWTagView.swift in Sources */, 9599CA96254DF8C0002EB026 /* ThreadView.swift in Sources */, CA000002CCCC000100000002 /* ReplyDraftSheet.swift in Sources */, - CA000001CCCC000100000002 /* QuotePreviewSheet.swift in Sources */, + CA000001CCCC000100000002 /* PostDetailView.swift in Sources */, 95181791254CFB600032616E /* swiftchanApp.swift in Sources */, 95978F162CA7981600C520EF /* UINavigationController.swift in Sources */, 9587A51C255726D80028A143 /* CacheService.swift in Sources */, diff --git a/swiftchan/Models/PresentationState.swift b/swiftchan/Models/PresentationState.swift index 6b2f882..cdf556a 100644 --- a/swiftchan/Models/PresentationState.swift +++ b/swiftchan/Models/PresentationState.swift @@ -12,15 +12,15 @@ class PresentationState { var presentingGallery: Bool = false var galleryIndex: Int = 0 var presentingIndex: Int = 0 - var presentingReplies: Bool = false + var activePostContext: UUID? + var presentingReplies: Bool { activePostContext != nil } } extension EnvironmentValues { /// Namespace used for the thumbnail → gallery zoom transition. @Entry var galleryNamespace: Namespace.ID? - /// True for PostViews rendered inside RepliesView, so only one context - /// registers a matchedTransitionSource for a given media index at a time. - @Entry var inRepliesContext: Bool = false + /// Identifies each pushed post/replies page; nil identifies the main thread. + @Entry var postContextID: UUID? } extension View { diff --git a/swiftchan/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 59c9419..416874e 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -17,7 +17,8 @@ post["sub"] = "/pmg/ - Precious Metals General" if arguments.contains("--ui-general-rollover"), id == 100 { post["archived"] = 1 } } - if arguments.contains("--ui-media-fixture"), number < id + 3 { + if arguments.contains("--ui-media-fixture"), + number < id + 3 || (arguments.contains("--ui-quote-fixture") && number == 104) { let timestamp = 9_000_000_000 + number post["tim"] = timestamp post["filename"] = "collection-study-\(number - id + 1)" @@ -29,7 +30,7 @@ post["fsize"] = 100_000 seedMedia(timestamp: timestamp, index: number - id) } - if arguments.contains("--ui-quote-fixture"), number == 105 || number == 104 { + if arguments.contains("--ui-quote-fixture"), (103...105).contains(number) { post["com"] = ">>\(number - 1)
Quoted reply \(number)" } return post diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift new file mode 100644 index 0000000..927ba05 --- /dev/null +++ b/swiftchan/Views/Boards/Catalog/Thread/PostDetailView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct PostDestination: Identifiable, Hashable { + let id: Int +} + +struct PostDetailView: View { + let postID: Int + @Environment(ThreadViewModel.self) private var model + + var body: some View { + Group { + if let index = model.getPostIndexFromId(String(postID)), + !model.posts[index].isHidden(boardName: model.boardName), model.filterEffect(at: index) != .hide { + RepliesView(replies: [index]) + .accessibilityIdentifier("Post Detail \(postID)") + } else { + ContentUnavailableView("Post Unavailable", systemImage: "text.bubble", + description: Text("This post is hidden or is not in the loaded thread.")) + } + } + .navigationTitle("#\(postID)") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 80d6b5c..9b0ff5b 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -15,7 +15,7 @@ struct PostView: View { @Environment(AppState.self) private var appState @Environment(PresentationState.self) private var presentationState: PresentationState @Environment(\.galleryNamespace) private var galleryNamespace - @Environment(\.inRepliesContext) private var inRepliesContext + @Environment(\.postContextID) private var postContextID let index: Int @@ -59,9 +59,8 @@ struct PostView: View { .galleryTransitionSource( id: mediaIndex, namespace: galleryNamespace, - // Only one context may own a source id: the thread - // list normally, RepliesView while it is pushed. - isActive: inRepliesContext == presentationState.presentingReplies + // Only the visible thread, post, or replies page owns the source. + isActive: postContextID == presentationState.activePostContext ) .onTapGesture { viewModel.media[mediaIndex].isSelected = true @@ -177,12 +176,8 @@ struct PostView: View { RepliesView(replies: replies) .environment(viewModel) .environment(presentationState) - .onAppear { - presentationState.presentingReplies = true - } - .onDisappear { - presentationState.presentingReplies = false - } + .navigationTitle("Replies to #\(post.no)") + .navigationBarTitleDisplayMode(.inline) } }, label: { diff --git a/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift b/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift deleted file mode 100644 index 619a2ab..0000000 --- a/swiftchan/Views/Boards/Catalog/Thread/QuotePreviewSheet.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI - -struct QuotePreviewSheet: View { - let postID: Int - @Environment(ThreadViewModel.self) private var model - @Environment(AppState.self) private var appState - @Environment(\.dismiss) private var dismiss - @State private var path: [Int] = [] - - var body: some View { - NavigationStack(path: $path) { - preview(postID) - .navigationDestination(for: Int.self) { preview($0) } - } - .environment(\.openURL, OpenURLAction { url in - if case .post(let id) = Deeplinker.getType(url: url), let number = Int(id) { - path.append(number) - return .handled - } - if let link = Deeplinker.getType(url: url), appState.openLink(link) { - dismiss() - return .handled - } - return .systemAction - }) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - @ViewBuilder - private func preview(_ number: Int) -> some View { - Group { - if let index = model.getPostIndexFromId(String(number)), - !model.posts[index].isHidden(boardName: model.boardName), model.filterEffect(at: index) != .hide { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Text(model.posts[index].name ?? "Anonymous").font(.headline) - Text(model.comment(at: index)).textSelection(.enabled) - ShareLink("Share Post", item: model.postURL(number)) - } - .frame(maxWidth: .infinity, alignment: .leading).padding() - } - .accessibilityIdentifier("Quote Preview \(number)") - } else { - ContentUnavailableView("Post Unavailable", systemImage: "text.bubble", - description: Text("This post is hidden or is not in the loaded thread.")) - } - } - .navigationTitle("#\(number)") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() }.accessibilityIdentifier("Close Quote Preview") - } - } - } -} diff --git a/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift b/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift index 9f8e32c..ac712e0 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift @@ -8,14 +8,15 @@ import SwiftUI struct RepliesView: View { + @AppStorage("chanTheme") private var theme = ChanTheme.system let replies: [Int] let columns = [GridItem(.flexible(), spacing: 0, alignment: .center)] - @State private var showReply: Bool = false - @State private var replyId: Int = 0 + @State private var linkedPost: PostDestination? + @State private var contextID = UUID() @State private var showPostUnavailable = false - @Environment(\.openURL) private var openURL + @Environment(AppState.self) private var appState @Environment(PresentationState.self) private var presentationState: PresentationState @Environment(ThreadViewModel.self) private var viewModel @@ -26,33 +27,43 @@ struct RepliesView: View { alignment: .center, spacing: 0) { ForEach(replies, id: \.self) { index in - PostView(index: index) + if viewModel.posts.indices.contains(index), + !viewModel.posts[index].isHidden(boardName: viewModel.boardName), viewModel.filterEffect(at: index) != .hide { + PostView(index: index) + } } } + .padding(3) } - .environment(\.inRepliesContext, true) + .background(theme.pageBackground) + .environment(\.postContextID, contextID) .environment(\.openURL, postLinkAction) + .onAppear { + presentationState.activePostContext = contextID + } + .onDisappear { + if !presentationState.presentingGallery, presentationState.activePostContext == contextID { + presentationState.activePostContext = nil + } + } .alert("Post Unavailable", isPresented: $showPostUnavailable) { Button("OK", role: .cancel) { } } message: { Text("This post is not in the loaded thread. It may have been deleted.") } - .navigationDestination(isPresented: $showReply) { - PostView(index: replyId) + .navigationDestination(item: $linkedPost) { destination in + PostDetailView(postID: destination.id) .environment(viewModel) .environment(presentationState) - .environment(\.openURL, postLinkAction) } } private var postLinkAction: OpenURLAction { OpenURLAction { url in guard case .post(let id) = Deeplinker.getType(url: url) else { - openURL(url) - return .handled + return appState.openLink(url) ? .handled : .systemAction } if let index = viewModel.getPostIndexFromId(id) { - replyId = index - showReply = true + linkedPost = PostDestination(id: viewModel.posts[index].no) } else { showPostUnavailable = true } diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index 4a762c4..f8085e5 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -27,8 +27,8 @@ struct ThreadView: View { @State private var threadAutorefresher = ThreadAutoRefresher() @State var viewModel: ThreadViewModel @State private var opacity: Double = 1 - @State private var showReply: Bool = false - @State private var replyId: Int = 0 + @State private var linkedPost: PostDestination? + @State private var replyReturnPostID: Int? @State private var showPostUnavailable = false @AppStorage("rememberThreadPositions") private var rememberThreadPositions = true @State private var reading = ThreadReadingSession() @@ -124,7 +124,7 @@ struct ThreadView: View { recordVisiblePosts() } .onChange(of: presentationState.galleryIndex) { _, _ in - if !presentationState.presentingReplies && !showReply { + if !presentationState.presentingReplies && linkedPost == nil { scrollToPost(reader: reader) } } @@ -138,6 +138,14 @@ struct ThreadView: View { } } .accessibilityIdentifier("Thread Posts") + .task(id: linkedPost) { + if linkedPost == nil, let postID = replyReturnPostID { + // Restore the reading anchor after navigation and gallery layout changes. + await Task.yield() + reader.scrollTo(postID, anchor: .top) + replyReturnPostID = nil + } + } .onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.1) { ids in visiblePostIDs = ids recordVisiblePosts() @@ -151,6 +159,12 @@ struct ThreadView: View { HStack { Spacer() Menu { + if rememberThreadPositions, let firstUnread = unreadPostIDs.first { + Button("\(unreadPostIDs.count) Unread \(unreadPostIDs.count == 1 ? "Reply" : "Replies")", systemImage: "arrow.down") { + reader.scrollTo(firstUnread, anchor: .top) + } + .accessibilityIdentifier("Jump To Unread") + } Button("First Post", systemImage: "arrow.up.to.line") { if let first = readablePostIDs.first { reader.scrollTo(first, anchor: .top) } } @@ -172,24 +186,6 @@ struct ThreadView: View { } } } - .safeAreaInset(edge: .top, spacing: 0) { - if rememberThreadPositions, !isSearching, viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), - let firstUnread = unreadPostIDs.first { - Button { - reader.scrollTo(firstUnread, anchor: .top) - } label: { - HStack { - Text("\(unreadPostIDs.count) unread \(unreadPostIDs.count == 1 ? "reply" : "replies")") - Spacer() - Label("Jump", systemImage: "arrow.down") - } - .font(.subheadline) - .padding(10) - .background(.regularMaterial) - } - .accessibilityIdentifier("Jump To Unread") - } - } } } .overlay(alignment: .bottom) { @@ -275,6 +271,9 @@ struct ThreadView: View { .onChange(of: autoRefreshThreadTime) { updateAutoRefreshState() } .onChange(of: presentationState.presentingReplies) { if presentationState.presentingReplies { + if replyReturnPostID == nil { + replyReturnPostID = reading.postID ?? visiblePostIDs.min() + } threadAutorefresher.cancelTimer() } else { updateAutoRefreshState() @@ -344,16 +343,18 @@ struct ThreadView: View { } .defaultCustomization(.hidden) } - .onChange(of: showReply) { - if showReply { + .onChange(of: linkedPost) { + if linkedPost != nil { threadAutorefresher.cancelTimer() } else { updateAutoRefreshState() } } - .sheet(isPresented: $showReply) { - QuotePreviewSheet(postID: viewModel.posts[replyId].no) + .navigationDestination(item: $linkedPost) { destination in + PostDetailView(postID: destination.id) .environment(viewModel) + .environment(presentationState) + .environment(\.galleryNamespace, galleryNamespace) } .sheet(isPresented: $appState.showingBottomSheet) { if let post = appState.selectedBottomSheetPost, @@ -595,7 +596,7 @@ struct ThreadView: View { private func recordVisiblePosts() { guard rememberThreadPositions, isThreadVisible, scenePhase == .active, !isSearching, - viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), !showReply, + viewModel.searchText.isEmpty, viewModel.searchFilters == SearchFilters(), linkedPost == nil, !presentationState.presentingGallery, !presentationState.presentingReplies, !showFollowGeneral else { return } reading.observe(visiblePostIDs: visiblePostIDs) if let postID = reading.postID { @@ -608,8 +609,8 @@ struct ThreadView: View { OpenURLAction { url in if case .post(let id) = Deeplinker.getType(url: url) { if let index = viewModel.getPostIndexFromId(id) { - replyId = index - showReply = true + replyReturnPostID = reading.postID ?? visiblePostIDs.min() + linkedPost = PostDestination(id: viewModel.posts[index].no) } else { showPostUnavailable = true } @@ -628,7 +629,7 @@ struct ThreadView: View { private func updateAutoRefreshState() { if isThreadVisible, scenePhase == .active, !viewModel.isArchived, !presentationState.presentingGallery, !presentationState.presentingReplies, - !showReply { + linkedPost == nil { threadAutorefresher.startTimer() } else { threadAutorefresher.cancelTimer() diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 97f7827..0b59cc6 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -113,16 +113,60 @@ final class BrowsingUITests: XCTestCase { attachScreenshot("Duo \(name)") } - func testQuotePreviewChainDismissesBackToReadingPosition() { - let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture"]) + func testQuotedPostsPushWithImagesAndNestedRepliesThenReturnToReadingPosition() { + let app = launchThreadFixture(extraArguments: ["--ui-quote-fixture", "--ui-media-fixture"]) openFixtureThread(in: app, anchor: "#p105") app.links[">>104"].tap() XCTAssertTrue(app.navigationBars["#104"].waitForExistence(timeout: 5)) - app.scrollViews["Quote Preview 104"].links[">>103"].tap() + XCTAssertFalse(app.sheets.firstMatch.exists) + XCTAssertFalse(app.buttons["Close Quote Preview"].exists) + XCTAssertTrue(app.thumbnailMediaImage(4).isHittable) + attachScreenshot("Quoted Post With Image") + app.tapThumbnailMedia(4) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.galleryMediaImage(3).isHittable) + app.buttons["Close gallery"].tap() + app.scrollViews["Post Detail 104"].links[">>103"].tap() + XCTAssertTrue(app.navigationBars["#103"].waitForExistence(timeout: 5)) + app.scrollViews["Post Detail 103"].links[">>102"].tap() + XCTAssertTrue(app.navigationBars["#102"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.thumbnailMediaImage(2).isHittable) + app.buttons["1 REPLY"].tap() + XCTAssertTrue(app.staticTexts["#103"].waitForExistence(timeout: 5)) + app.buttons["1 REPLY"].tap() + XCTAssertTrue(app.staticTexts["#104"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.thumbnailMediaImage(4).isHittable) + attachScreenshot("Nested Replies With Images") + app.tapThumbnailMedia(4) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.galleryMediaImage(3).isHittable) + app.buttons["Close gallery"].tap() + XCTAssertTrue(app.navigationBars["Replies to #103"].waitForExistence(timeout: 5)) + app.links[">>103"].tap() XCTAssertTrue(app.navigationBars["#103"].waitForExistence(timeout: 5)) - attachScreenshot("Quote Chain Preview") - app.navigationBars["#103"].buttons["Close Quote Preview"].tap() + XCTAssertFalse(app.sheets.firstMatch.exists) + for _ in 0..<6 { app.navigationBars.buttons.element(boundBy: 0).tap() } XCTAssertTrue(app.staticTexts["#105"].isHittable) + attachScreenshot("Returned To Thread Reading Position") + } + + func testUnreadNavigationSharesTheBottomJumpMenu() { + let app = launchThreadFixture(seedProgress: true) + openFixtureThread(in: app) + XCTAssertTrue(app.buttons["Thread Jump Menu"].isHittable) + XCTAssertFalse(app.buttons["Jump To Unread"].exists) + app.buttons["Thread Jump Menu"].tap() + XCTAssertTrue(app.buttons["Jump To First Post"].isHittable) + XCTAssertTrue(app.buttons["Jump To Latest Reply"].isHittable) + XCTAssertTrue(app.buttons["Jump To Unread"].isHittable) + attachScreenshot("One Menu For Thread Navigation") + app.buttons["Jump To Latest Reply"].tap() + XCTAssertTrue(app.staticTexts["#114"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["#114"].isHittable) + app.buttons["Thread Jump Menu"].tap() + XCTAssertFalse(app.buttons["Jump To Unread"].exists) + app.buttons["Jump To First Post"].tap() + XCTAssertTrue(app.staticTexts["#100"].isHittable) } func testArchivedGeneralFindsNewerThread() { @@ -347,6 +391,8 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(savedPost.waitForExistence(timeout: 5)) XCTAssertTrue(savedPost.isHittable) XCTAssertFalse(app.staticTexts["#100"].isHittable) + XCTAssertFalse(app.buttons["Jump To Unread"].exists) + app.buttons["Thread Jump Menu"].tap() let jump = app.buttons["Jump To Unread"] XCTAssertTrue(jump.waitForExistence(timeout: 5)) attachScreenshot("Restored Position And Unread Replies") From bdb9b6bf75315659ca530d0ad0e89645f53c6f3a Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Sun, 4 Oct 2026 17:14:36 -0500 Subject: [PATCH 2/2] Add reverse image search and media actions --- swiftchan.xcodeproj/project.pbxproj | 12 +++ swiftchan/Models/ReverseImageSearch.swift | 56 ++++++++++++++ .../Boards/Catalog/Thread/PostView.swift | 4 +- .../Boards/Catalog/Thread/ThreadView.swift | 9 ++- .../Views/Media/Gallery/GalleryView.swift | 36 ++++++++- .../Media/Gallery/MediaContextMenu.swift | 8 ++ .../Media/Gallery/MediaDownloadMenu.swift | 6 +- .../Views/Media/ReverseImageSearchMenu.swift | 36 +++++++++ swiftchanTests/ReverseImageSearchTests.swift | 65 +++++++++++++++++ swiftchanUITests/BrowsingUITests.swift | 73 +++++++++++++++++++ 10 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 swiftchan/Models/ReverseImageSearch.swift create mode 100644 swiftchan/Views/Media/ReverseImageSearchMenu.swift create mode 100644 swiftchanTests/ReverseImageSearchTests.swift diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index d0f5ca7..475d9cd 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + DA000003DDDD000100000002 /* ReverseImageSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA000003DDDD000100000001 /* ReverseImageSearchTests.swift */; }; + DA000002DDDD000100000002 /* ReverseImageSearchMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA000002DDDD000100000001 /* ReverseImageSearchMenu.swift */; }; + DA000001DDDD000100000002 /* ReverseImageSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA000001DDDD000100000001 /* ReverseImageSearch.swift */; }; CA000006CCCC000100000002 /* ThreadWatcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000006CCCC000100000001 /* ThreadWatcherView.swift */; }; CA000005CCCC000100000002 /* ThreadWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000005CCCC000100000001 /* ThreadWatcher.swift */; }; CA000004CCCC000100000002 /* PostFiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA000004CCCC000100000001 /* PostFiltersView.swift */; }; @@ -161,6 +164,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + DA000003DDDD000100000001 /* ReverseImageSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseImageSearchTests.swift; sourceTree = ""; }; + DA000002DDDD000100000001 /* ReverseImageSearchMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseImageSearchMenu.swift; sourceTree = ""; }; + DA000001DDDD000100000001 /* ReverseImageSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReverseImageSearch.swift; sourceTree = ""; }; CA000006CCCC000100000001 /* ThreadWatcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadWatcherView.swift; sourceTree = ""; }; CA000005CCCC000100000001 /* ThreadWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadWatcher.swift; sourceTree = ""; }; CA000004CCCC000100000001 /* PostFiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFiltersView.swift; sourceTree = ""; }; @@ -339,6 +345,7 @@ 950E3933255CD9BB00A00A74 /* Media */ = { isa = PBXGroup; children = ( + DA000002DDDD000100000001 /* ReverseImageSearchMenu.swift */, 959E6152255CD4750003638E /* Gallery */, 950E393A255CD9EE00A00A74 /* Image */, AA000004AAAA000000000004 /* Video */, @@ -360,6 +367,7 @@ 9510536725660071002E4051 /* Models */ = { isa = PBXGroup; children = ( + DA000001DDDD000100000001 /* ReverseImageSearch.swift */, AF000001AAAA000100000001 /* SavedReply.swift */, BF000001EEEE000100000001 /* FavoritesBackup.swift */, CA000003CCCC000100000001 /* ChanPreferences.swift */, @@ -430,6 +438,7 @@ 951817A1254CFB620032616E /* swiftchanTests */ = { isa = PBXGroup; children = ( + DA000003DDDD000100000001 /* ReverseImageSearchTests.swift */, AF000003AAAA000100000001 /* SavedReplyTests.swift */, BF000004EEEE000100000001 /* FavoritesBackupTests.swift */, EE000003EEEE000100000001 /* HiddenPostStoreTests.swift */, @@ -814,6 +823,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA000002DDDD000100000002 /* ReverseImageSearchMenu.swift in Sources */, + DA000001DDDD000100000002 /* ReverseImageSearch.swift in Sources */, AF000002AAAA000100000002 /* SavedRepliesView.swift in Sources */, AF000001AAAA000100000002 /* SavedReply.swift in Sources */, 954FB5832730BDF0009CC52F /* RoundedCorners.swift in Sources */, @@ -915,6 +926,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DA000003DDDD000100000002 /* ReverseImageSearchTests.swift in Sources */, AF000003AAAA000100000002 /* SavedReplyTests.swift in Sources */, BF000004EEEE000100000002 /* FavoritesBackupTests.swift in Sources */, EE000003EEEE000100000002 /* HiddenPostStoreTests.swift in Sources */, diff --git a/swiftchan/Models/ReverseImageSearch.swift b/swiftchan/Models/ReverseImageSearch.swift new file mode 100644 index 0000000..d99fa9f --- /dev/null +++ b/swiftchan/Models/ReverseImageSearch.swift @@ -0,0 +1,56 @@ +import Foundation + +enum ReverseImageSearchProvider: String, CaseIterable, Identifiable { + case googleLens, yandex, tineye, sauceNAO, iqdb, traceMoe + + var id: String { rawValue } + + var name: String { + switch self { + case .googleLens: "Google Lens" + case .yandex: "Yandex" + case .tineye: "TinEye" + case .sauceNAO: "SauceNAO" + case .iqdb: "IQDB" + case .traceMoe: "trace.moe" + } + } + + func searchURL(for media: Media) -> URL? { + guard let imageURL = Self.sourceURL(for: media) else { return nil } + let endpoint: String + switch self { + case .googleLens: endpoint = "https://lens.google.com/uploadbyurl" + case .yandex: endpoint = "https://yandex.com/images/search" + case .tineye: endpoint = "https://tineye.com/search" + case .sauceNAO: endpoint = "https://saucenao.com/search.php" + case .iqdb: endpoint = "https://iqdb.org/" + case .traceMoe: endpoint = "https://trace.moe/" + } + var components = URLComponents(string: endpoint) + var items: [URLQueryItem] = [] + if self == .yandex { items.append(URLQueryItem(name: "rpt", value: "imageview")) } + items.append(URLQueryItem(name: "url", value: imageURL.absoluteString)) + components?.queryItems = items + // These providers parse form-style queries, where an unescaped + means a space. + let encodedQuery = components?.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + components?.percentEncodedQuery = encodedQuery + return components?.url + } + + static func sourceURL(for media: Media) -> URL? { + let source: URL + switch media.url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": source = media.url + case "webm", "mp4": source = media.thumbnailUrl + default: return nil + } + guard ["http", "https"].contains(source.scheme?.lowercased() ?? ""), + let host = source.host, !host.isEmpty, source.user == nil, source.password == nil else { return nil } + return source + } + + static func usesThumbnail(for media: Media) -> Bool { + ["webm", "mp4"].contains(media.url.pathExtension.lowercased()) + } +} diff --git a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift index 9b0ff5b..54e4fac 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/PostView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/PostView.swift @@ -72,7 +72,9 @@ struct PostView: View { Text(filename + fileExtension) .font(Font.system(size: 8)) } - + ReverseImageSearchMenu(media: media, identifier: "Image Search Post \(post.no)") + .font(.caption) + .padding(.top, 4) } .padding(.leading, -5) } diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index f8085e5..aa05123 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -366,6 +366,13 @@ struct ThreadView: View { appState.selectedBottomSheetPost = nil } .accessibilityIdentifier("Draft Reply To Post") + Button("Copy Quote", systemImage: "quote.bubble") { + UIPasteboard.general.string = ">>\(post.no)" + UINotificationFeedbackGenerator().notificationOccurred(.success) + appState.showingBottomSheet = false + appState.selectedBottomSheetPost = nil + } + .accessibilityIdentifier("Copy Selected Post Quote") let isSaved = savedReplies.contains { $0.boardName == viewModel.boardName && $0.postID == post.no } Button(isSaved ? "Remove Saved Reply" : "Save Reply", systemImage: isSaved ? "bookmark.slash" : "bookmark") { saveReply(index: index) @@ -386,7 +393,7 @@ struct ThreadView: View { .accessibilityIdentifier("Hide Selected Post") } .padding() - .presentationDetents([.height(290), .medium]) + .presentationDetents([.height(350), .medium]) .presentationDragIndicator(.visible) } } diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index 4eb8d13..7c4bb39 100644 --- a/swiftchan/Views/Media/Gallery/GalleryView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryView.swift @@ -126,6 +126,40 @@ struct GalleryView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .topLeading) { closeButton } + .overlay(alignment: .topTrailing) { + if viewModel.media.indices.contains(selection) { + ReverseImageSearchMenu(media: viewModel.media[selection], identifier: "Gallery Image Search") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(12) + .background(.black.opacity(0.65), in: Capsule()) + .padding(16) + .opacity(isZoomed || isSeeking ? 0 : 1) + .allowsHitTesting(!isZoomed && !isSeeking) + } + } + .overlay(alignment: .bottomTrailing) { + if viewModel.media.indices.contains(selection) { + Text("\(selection + 1) of \(viewModel.media.count)") + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.black.opacity(0.65), in: Capsule()) + .padding(16) + .padding(.bottom, positionBottomPadding) + .opacity(isZoomed || isSeeking ? 0 : 1) + .allowsHitTesting(false) + .accessibilityLabel("Media \(selection + 1) of \(viewModel.media.count)") + .accessibilityIdentifier("Gallery Position") + } + } + } + + private var positionBottomPadding: CGFloat { + if showPreview { return 100 } + guard viewModel.media.indices.contains(selection) else { return 0 } + return ReverseImageSearchProvider.usesThumbnail(for: viewModel.media[selection]) ? 80 : 0 } private var closeButton: some View { @@ -167,7 +201,7 @@ struct GalleryView: View { canShowPreview = !seeking canShowContextMenu = !seeking } - .mediaDownloadMenu(url: media.url, canShowContextMenu: $canShowContextMenu) + .mediaDownloadMenu(url: media.url, thumbnailURL: media.thumbnailUrl, canShowContextMenu: $canShowContextMenu) .accessibilityIdentifier( AccessibilityIdentifiers.galleryMediaImage(media.index) ) diff --git a/swiftchan/Views/Media/Gallery/MediaContextMenu.swift b/swiftchan/Views/Media/Gallery/MediaContextMenu.swift index 4b45de0..92a037f 100644 --- a/swiftchan/Views/Media/Gallery/MediaContextMenu.swift +++ b/swiftchan/Views/Media/Gallery/MediaContextMenu.swift @@ -10,6 +10,7 @@ import UIKit struct MediaContextMenu: View { let url: URL + let thumbnailURL: URL @State private var notificationGenerator = UINotificationFeedbackGenerator() @Binding var isExportingDocument: Bool @@ -21,6 +22,13 @@ struct MediaContextMenu: View { @ViewBuilder var body: some View { if canShowContextMenu { + let media = Media(index: 0, url: url, thumbnailUrl: thumbnailURL) + if ReverseImageSearchProvider.sourceURL(for: media) != nil { + Menu("Reverse Image Search", systemImage: "photo.badge.magnifyingglass") { + ReverseImageSearchLinks(media: media) + } + Link("Open Original", destination: url) + } Button { UIPasteboard.general.string = url.absoluteString debugPrint("📋 Copied URL to pasteboard: \(url.absoluteString)") diff --git a/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift b/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift index 7df6064..aa672f5 100644 --- a/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift +++ b/swiftchan/Views/Media/Gallery/MediaDownloadMenu.swift @@ -8,13 +8,14 @@ import SwiftUI extension View { - func mediaDownloadMenu(url: URL, canShowContextMenu: Binding) -> some View { - modifier(MediaDownloadMenuModifier(url: url, canShowContextMenu: canShowContextMenu)) + func mediaDownloadMenu(url: URL, thumbnailURL: URL, canShowContextMenu: Binding) -> some View { + modifier(MediaDownloadMenuModifier(url: url, thumbnailURL: thumbnailURL, canShowContextMenu: canShowContextMenu)) } } struct MediaDownloadMenuModifier: ViewModifier { let url: URL + let thumbnailURL: URL @Binding var canShowContextMenu: Bool @State var isExportingDocument: Bool = false @@ -45,6 +46,7 @@ struct MediaDownloadMenuModifier: ViewModifier { .contextMenu { MediaContextMenu( url: url, + thumbnailURL: thumbnailURL, isExportingDocument: $isExportingDocument, canShowContextMenu: $canShowContextMenu, presentingToast: $presentingToast, diff --git a/swiftchan/Views/Media/ReverseImageSearchMenu.swift b/swiftchan/Views/Media/ReverseImageSearchMenu.swift new file mode 100644 index 0000000..78a8eba --- /dev/null +++ b/swiftchan/Views/Media/ReverseImageSearchMenu.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct ReverseImageSearchMenu: View { + let media: Media + var identifier = "Image Search" + + var body: some View { + if ReverseImageSearchProvider.sourceURL(for: media) != nil { + Menu { + ReverseImageSearchLinks(media: media) + Divider() + Link("Open Original", destination: media.url) + .accessibilityIdentifier("Open Original Media") + } label: { + Label(ReverseImageSearchProvider.usesThumbnail(for: media) ? "Search Thumbnail" : "Image Search", + systemImage: "photo.badge.magnifyingglass") + } + .accessibilityIdentifier(identifier) + } + } +} + +struct ReverseImageSearchLinks: View { + let media: Media + + var body: some View { + Section(ReverseImageSearchProvider.usesThumbnail(for: media) ? "Search Video Thumbnail" : "Reverse Image Search") { + ForEach(ReverseImageSearchProvider.allCases) { provider in + if let url = provider.searchURL(for: media) { + Link(provider.name, destination: url) + .accessibilityIdentifier("Image Search \(provider.name)") + } + } + } + } +} diff --git a/swiftchanTests/ReverseImageSearchTests.swift b/swiftchanTests/ReverseImageSearchTests.swift new file mode 100644 index 0000000..15315c3 --- /dev/null +++ b/swiftchanTests/ReverseImageSearchTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import swiftchan + +final class ReverseImageSearchTests: XCTestCase { + func testEachProviderReceivesTheOriginalImageURL() throws { + let original = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.png")) + let thumbnail = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123s.jpg")) + let media = Media(index: 0, url: original, thumbnailUrl: thumbnail) + let hosts = ["lens.google.com", "yandex.com", "tineye.com", "saucenao.com", "iqdb.org", "trace.moe"] + for (provider, host) in zip(ReverseImageSearchProvider.allCases, hosts) { + let url = try XCTUnwrap(provider.searchURL(for: media)) + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, host) + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "url" }?.value, + original.absoluteString) + } + } + + func testSearchQueryPreservesEscapesAndFormSensitiveCharacters() throws { + let original = try XCTUnwrap(URL(string: "https://images.example/image%20name.PNG?token=a+b&escaped=%2B%26#fragment")) + let media = Media(index: 0, url: original, thumbnailUrl: original) + for provider in ReverseImageSearchProvider.allCases { + let url = try XCTUnwrap(provider.searchURL(for: media)) + let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedQuery) + XCTAssertFalse(query.contains("+"), "Providers interpret raw + as a space") + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "url" }?.value, + original.absoluteString) + } + } + + func testVideoSearchesUseThumbnailsInsteadOfVideoFiles() throws { + let thumbnail = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123s.jpg")) + for ext in ["webm", "mp4"] { + let media = Media(index: 0, url: try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.\(ext)")), thumbnailUrl: thumbnail) + XCTAssertTrue(ReverseImageSearchProvider.usesThumbnail(for: media)) + XCTAssertEqual(ReverseImageSearchProvider.sourceURL(for: media), thumbnail) + for provider in ReverseImageSearchProvider.allCases { + let url = try XCTUnwrap(provider.searchURL(for: media)) + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "url" }?.value, + thumbnail.absoluteString) + } + } + } + + func testGIFSearchesUseTheOriginalAnimation() throws { + let gif = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.gif")) + let media = Media(index: 0, url: gif, thumbnailUrl: try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123s.jpg"))) + XCTAssertEqual(ReverseImageSearchProvider.sourceURL(for: media), gif) + XCTAssertFalse(ReverseImageSearchProvider.usesThumbnail(for: media)) + } + + func testLocalUnsupportedAndCredentialedSourcesDoNotProduceSearchLinks() throws { + for text in ["file:///tmp/photo.png", "https://example.com/document.pdf", "https://user:password@example.com/photo.png"] { + let url = try XCTUnwrap(URL(string: text)) + let media = Media(index: 0, url: url, thumbnailUrl: url) + for provider in ReverseImageSearchProvider.allCases { XCTAssertNil(provider.searchURL(for: media)) } + } + } + + func testYandexIncludesImageSearchMode() throws { + let url = try XCTUnwrap(URL(string: "https://i.4cdn.org/g/123.jpg")) + let search = try XCTUnwrap(ReverseImageSearchProvider.yandex.searchURL(for: Media(index: 0, url: url, thumbnailUrl: url))) + XCTAssertEqual(URLComponents(url: search, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "rpt" }?.value, "imageview") + } +} diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 0b59cc6..a155883 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -169,6 +169,79 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.staticTexts["#100"].isHittable) } + func testImageSearchIsAvailableFromPostsAndPagedGalleryMedia() { + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture"]) + openFixtureThread(in: app) + let search = app.buttons["Image Search Post 100"] + XCTAssertTrue(search.isHittable) + search.tap() + assertImageSearchProviders(in: app) + XCTAssertTrue(app.buttons["Open Original Media"].isHittable) + attachScreenshot("Post Image Search") + app.navigationBars.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.tapThumbnailMedia(0) + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + let position = app.staticTexts["Gallery Position"] + XCTAssertEqual(position.label, "Media 1 of 3") + attachScreenshot("Gallery Search Button And Position") + app.buttons["Gallery Image Search"].tap() + assertImageSearchProviders(in: app) + attachScreenshot("Gallery Image Search") + position.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.galleryMediaImage(0).swipeUp() + XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 5)) + let nextPosition = NSPredicate(format: "label == %@", "Media 2 of 3") + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: nextPosition, object: position)], timeout: 5), .completed) + app.buttons["Gallery Image Search"].tap() + assertImageSearchProviders(in: app) + XCTAssertTrue(app.buttons["Open Original Media"].isHittable) + attachScreenshot("Search After Gallery Paging") + app.buttons["Image Search IQDB"].tap() + let browser = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + XCTAssertTrue(browser.wait(for: .runningForeground, timeout: 10)) + attachScreenshot("Image Search Browser Handoff") + app.activate() + XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) + XCTAssertEqual(position.label, "Media 2 of 3") + app.buttons["Close gallery"].tap() + XCTAssertTrue(search.isHittable) + } + + func testCopyQuoteUsesTheSelectedPostAndKeepsReadingPosition() { + let app = launchThreadFixture() + openFixtureThread(in: app, anchor: "#p105") + app.buttons["Post Options 105"].tap() + let copy = app.buttons["Copy Selected Post Quote"] + XCTAssertTrue(copy.waitForExistence(timeout: 5)) + attachScreenshot("Copy Quote Post Action") + copy.tap() + XCTAssertFalse(app.sheets.firstMatch.exists) + XCTAssertTrue(app.staticTexts["#105"].isHittable) + app.buttons["Post Options 105"].tap() + app.buttons["Draft Reply To Post"].tap() + let draft = app.textViews["Reply Draft"] + XCTAssertTrue(draft.waitForExistence(timeout: 5)) + draft.tap() + draft.press(forDuration: 1.2) + let selectAll = app.descendants(matching: .any).matching(identifier: "Select All").firstMatch + XCTAssertTrue(selectAll.waitForExistence(timeout: 5)) + selectAll.tap() + draft.typeText("Paste here") + XCTAssertEqual(draft.value as? String, "Paste here") + draft.press(forDuration: 1.2) + let paste = app.descendants(matching: .any).matching(identifier: "Paste").firstMatch + XCTAssertTrue(paste.waitForExistence(timeout: 5)) + paste.tap() + XCTAssertTrue((draft.value as? String ?? "").contains(">>105")) + attachScreenshot("Copied Quote Pasted Into Reply Draft") + } + + private func assertImageSearchProviders(in app: XCUIApplication) { + for provider in ["Google Lens", "Yandex", "TinEye", "SauceNAO", "IQDB", "trace.moe"] { + XCTAssertTrue(app.buttons["Image Search \(provider)"].isHittable) + } + } + func testArchivedGeneralFindsNewerThread() { let app = launchThreadFixture(extraArguments: ["--ui-general-rollover"]) openFixtureThread(in: app)