Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions swiftchan/Models/PresentationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,23 @@ class PresentationState {
var presentingIndex: Int = 0
var presentingReplies: Bool = false
}

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
}

extension View {
/// Marks a thumbnail as the source of the gallery zoom transition.
@ViewBuilder
func galleryTransitionSource(id: Int, namespace: Namespace.ID?, isActive: Bool) -> some View {
if let namespace, isActive {
matchedTransitionSource(id: id, in: namespace)
} else {
self
}
}
}
1 change: 1 addition & 0 deletions swiftchan/Services/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class AccessibilityIdentifiers {
static func galleryMediaImage(_ index: Int) -> String {
"\(index) Gallery Media Image"
}
static let galleryCloseButton: String = "Gallery Close Button"
static let saveToPhotosButton: String = "Save to Photos Button"
static let saveToFilesButton: String = "Save to Files Button"
static let copyToPasteboardButton: String = "Copy to Pasteboard Button"
Expand Down
17 changes: 12 additions & 5 deletions swiftchan/Views/Boards/Catalog/Thread/PostView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ struct PostView: View {
@Environment(ThreadViewModel.self) private var viewModel
@Environment(AppState.self) private var appState
@Environment(PresentationState.self) private var presentationState: PresentationState
@Environment(\.galleryNamespace) private var galleryNamespace
@Environment(\.inRepliesContext) private var inRepliesContext

let index: Int

Expand Down Expand Up @@ -50,12 +52,17 @@ struct PostView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.thumbnailMediaImage(index))
.frame(width: UIScreen.halfWidth)
.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
)
Comment on lines +55 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file="swiftchan/Views/Boards/Catalog/Thread/PostView.swift"
printf '%s\n' '--- target file ---'
sed -n '1,180p' "$file"

printf '%s\n' '--- related declarations and usages ---'
rg -n -C 4 \
  'inRepliesContext|presentingReplies|galleryTransitionSource|galleryNamespace|ThreadView|RepliesView' \
  swiftchan/Views

Repository: vanities/swiftchan

Length of output: 35102


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(PostView|ThreadView|RepliesView|Gallery|Catalog)([^/]*\.swift)$'

printf '%s\n' '--- exact environment declarations and presentation state ---'
rg -n -C 8 \
  'EnvironmentKey|inRepliesContext|presentingReplies|presentationState|navigationDestination|sheet|fullScreenCover|NavigationStack|NavigationLink' \
  swiftchan -g '*.swift'

Repository: vanities/swiftchan

Length of output: 49487


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path

terms = (
    "inRepliesContext",
    "presentingReplies",
    "galleryTransitionSource",
    "galleryNamespace",
)
for path in Path("swiftchan").rglob("*.swift"):
    text = path.read_text(errors="replace")
    if any(term in text for term in terms):
        print(f"\n--- {path} ---")
        for i, line in enumerate(text.splitlines(), 1):
            if any(term in line for term in terms):
                lo, hi = max(1, i - 8), min(len(text.splitlines()), i + 12)
                lines = text.splitlines()
                for n in range(lo, hi + 1):
                    print(f"{n}: {lines[n-1]}")
                print()
PY

Repository: vanities/swiftchan

Length of output: 18627


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- ThreadView hierarchy ---'
sed -n '45,145p' swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
sed -n '225,245p' swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift

printf '%s\n' '--- RepliesView hierarchy ---'
sed -n '20,45p' swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift

printf '%s\n' '--- deterministic source-activation model ---'
python3 - <<'PY'
cases = [
    ("thread list", False, False),
    ("ThreadView PostView destination", False, False),
    ("RepliesView list", True, True),
    ("RepliesView PostView destination", True, True),
]
for name, in_replies, presenting_replies in cases:
    active = in_replies == presenting_replies
    print(f"{name}: inRepliesContext={in_replies}, "
          f"presentingReplies={presenting_replies}, isActive={active}")

groups = {}
for name, in_replies, presenting_replies in cases:
    if in_replies == presenting_replies:
        groups.setdefault((in_replies, presenting_replies), []).append(name)
for state, owners in groups.items():
    if len(owners) > 1:
        print(f"DUPLICATE-CONTEXT: {state} -> {', '.join(owners)}")
PY

Repository: vanities/swiftchan

Length of output: 7284


Deactivate the obscured list source for pushed PostView destinations.

Both ThreadView and RepliesView register the same mediaIndex as their pushed PostView because the destination inherits the list’s context values. Track the active gallery-source owner and deactivate the obscured list source while a post-detail destination is visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@swiftchan/Views/Boards/Catalog/Thread/PostView.swift` around lines 55 - 61,
Update the gallery source activation around galleryTransitionSource in PostView
so the obscured ThreadView or RepliesView list source is inactive while the
pushed post-detail destination is visible. Track and use the active
gallery-source owner rather than relying only on inRepliesContext and
presentationState.presentingReplies, ensuring only the current context registers
mediaIndex.

.onTapGesture {
withAnimation(.easeInOut(duration: 0.3)) {
viewModel.media[mediaIndex].isSelected = true
presentationState.galleryIndex = mediaIndex
presentationState.presentingGallery = true
}
viewModel.media[mediaIndex].isSelected = true
presentationState.galleryIndex = mediaIndex
presentationState.presentingGallery = true
}
if let filename = post.filename,
let fileExtension = post.ext {
Expand Down
1 change: 1 addition & 0 deletions swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct RepliesView: View {
}
}
}
.environment(\.inRepliesContext, true)
.onOpenURL { url in
if case .post(let id) = Deeplinker.getType(url: url) {
showReply = true
Expand Down
19 changes: 9 additions & 10 deletions swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct ThreadView: View {
@State private var showAutoRefreshToast: Bool = false
@State private var autoRefreshToastMessage: String = ""
@State private var isSearching: Bool = false
@Namespace private var galleryNamespace

@State private var scene: SKScene = {
let s = SnowScene()
Expand Down Expand Up @@ -118,7 +119,7 @@ struct ThreadView: View {
.disabled(true)
}
}
.sheet(
.fullScreenCover(
isPresented: $presentationState.presentingGallery,
onDismiss: {
// reneable this if it got disabled
Expand All @@ -127,6 +128,11 @@ struct ThreadView: View {
},
content: {
gallerySheetContent
// Zoom back to whichever media the user is on;
// scrollToPost keeps its thumbnail on screen.
.navigationTransition(
.zoom(sourceID: presentationState.galleryIndex, in: galleryNamespace)
)
Comment on lines +131 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(ThreadView|GalleryView|RepliesView)\.swift$'
printf '%s\n' '--- ThreadView outline ---'
ast-grep outline swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 5 'galleryIndex|scrollToPost|presentingReplies|fullScreenCover|navigationTransition|RepliesView|GalleryView' swiftchan/Views/Boards/Catalog/Thread
printf '%s\n' '--- line range under review ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '90,165p'

Repository: vanities/swiftchan

Length of output: 18137


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- ThreadView body and helpers ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '1,115p'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '390,465p'

printf '%s\n' '--- RepliesView ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift

printf '%s\n' '--- GalleryView structure and paging ---'
ast-grep outline swiftchan/Views/Media/Gallery/GalleryView.swift
rg -n -C 8 'galleryIndex|TabView|selection|presentingGallery|dismiss|onDisappear|onChange' swiftchan/Views/Media/Gallery/GalleryView.swift

printf '%s\n' '--- PostView media source and reply context ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/PostView.swift | sed -n '1,90p'
cat -n swiftchan/Views/Boards/Catalog/Thread/PostView.swift | sed -n '145,190p'

printf '%s\n' '--- presentation state declarations and media mappings ---'
rg -n -C 5 'class PresentationState|struct PresentationState|galleryIndex|presentingIndex|postMediaMapping|media:' swiftchan

Repository: vanities/swiftchan

Length of output: 47609


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- transition environment definitions ---'
cat -n swiftchan/Models/PresentationState.swift
rg -n -C 8 'galleryTransitionSource|inRepliesContext|galleryNamespace' swiftchan

printf '%s\n' '--- complete GalleryView state update path ---'
cat -n swiftchan/Views/Media/Gallery/GalleryView.swift | sed -n '130,205p'

printf '%s\n' '--- relevant navigation and cover modifiers ---'
rg -n -C 12 'NavigationStack|NavigationSplitView|navigationDestination|fullScreenCover|presentingReplies|presentingGallery' swiftchan/Views/Boards/Catalog/Thread swiftchan/Models

printf '%s\n' '--- repository changes near the reviewed code ---'
git diff --stat
git diff -- swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift swiftchan/Views/Boards/Catalog/Thread/PostView.swift

Repository: vanities/swiftchan

Length of output: 39697


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

thread = Path("swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift").read_text()
replies = Path("swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift").read_text()
post = Path("swiftchan/Views/Boards/Catalog/Thread/PostView.swift").read_text()
gallery = Path("swiftchan/Views/Media/Gallery/GalleryView.swift").read_text()

checks = {
    "gallery transition uses galleryIndex": ".zoom(sourceID: presentationState.galleryIndex" in thread,
    "gallery paging updates galleryIndex": "state.galleryIndex = index" in gallery,
    "thread scroll is disabled in replies": "if !presentationState.presentingReplies && !showReply" in thread,
    "replies view has a ScrollView": "ScrollView(.vertical" in replies,
    "replies view lacks a ScrollViewReader": "ScrollViewReader" not in replies,
    "replies view lacks galleryIndex change handling": ".onChange(of: presentationState.galleryIndex)" not in replies,
    "reply context owns the active source": "inRepliesContext == presentationState.presentingReplies" in post,
}

for name, result in checks.items():
    print(f"{'PASS' if result else 'FAIL'}: {name}")

if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: vanities/swiftchan

Length of output: 461


Keep the active reply thumbnail visible before dismissal.

When presentationState.presentingReplies is true, ThreadView does not call scrollToPost. RepliesView does not scroll when GalleryView changes presentationState.galleryIndex. If the user pages to media whose reply is outside the visible LazyVGrid range, the zoom transition has no rendered source.

Add a ScrollViewReader to RepliesView and scroll the reply that maps to the active media before dismissal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift` around lines 131 -
135, Add a ScrollViewReader around the reply grid in RepliesView and, when
presentationState.presentingReplies is true, scroll to the reply corresponding
to presentationState.galleryIndex before GalleryView dismissal so its thumbnail
is rendered as the zoom source. Reuse the existing reply-to-media mapping and
scrollToPost behavior where applicable, without changing the non-replies flow.

}
)
.onOpenURL { url in
Expand Down Expand Up @@ -175,6 +181,7 @@ struct ThreadView: View {
}
}
.environment(presentationState)
.environment(\.galleryNamespace, galleryNamespace)
.navigationTitle(viewModel.title)
.searchable(text: $viewModel.searchText, isPresented: $isSearching)
.onChange(of: viewModel.searchText) { _, _ in
Expand Down Expand Up @@ -445,7 +452,7 @@ struct ThreadView: View {
extension ThreadView {
@ViewBuilder
private var gallerySheetContent: some View {
let gallery = GalleryView(
GalleryView(
index: presentationState.galleryIndex
)
.environment(appState)
Expand All @@ -457,14 +464,6 @@ extension ThreadView {
.onDisappear {
threadAutorefresher.startTimer()
}

if #available(iOS 16.0, *) {
gallery
.presentationDetents([.large])
.presentationDragIndicator(.visible)
} else {
gallery
}
}
}

Expand Down
48 changes: 22 additions & 26 deletions swiftchan/Views/Media/Gallery/GalleryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import SwiftUI
import SwiftUIIntrospect
import UIKit

struct GalleryView: View {
Expand All @@ -26,7 +25,6 @@ struct GalleryView: View {
@State private var isSeeking = false
@State private var isZoomed = false
@State private var pagerScrollView: UIScrollView?
@State private var sheetPresentationController: UISheetPresentationController?

var onMediaChanged: ((Bool) -> Void)?
var onPageDragChanged: ((CGFloat) -> Void)?
Expand Down Expand Up @@ -106,26 +104,35 @@ struct GalleryView: View {
}
}
}
.overlay(alignment: .topLeading) {
closeButton
}
.onDisappear {
restorePagerScrolling()
sheetPresentationController?.presentedViewController.isModalInPresentation = false
}
.gesture(canShowPreview && showGalleryPreview ? showPreviewTap() : nil)
.introspect(.sheet, on: .iOS(.v17, .v18, .v26)) { controller in
controller.prefersGrabberVisible = true
controller.prefersScrollingExpandsWhenScrolledToEdge = false
controller.detents = [.large()]
// Defer state update to avoid "Modifying state during view update" warning
if sheetPresentationController !== controller {
DispatchQueue.main.async {
sheetPresentationController = controller
}
}
updateInteractiveDismiss(using: controller)
}
// 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 closeButton: some View {
Button {
state.presentingGallery = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white)
.shadow(radius: 4)
}
.padding(16)
.opacity(isZoomed ? 0 : 1)
.animation(.easeInOut(duration: 0.15), value: isZoomed)
.accessibilityIdentifier(AccessibilityIdentifiers.galleryCloseButton)
Comment on lines +120 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Disable the hidden close button.

When isZoomed is true, .opacity(0) hides the button but keeps its hit area and accessibility element active. Add .allowsHitTesting(!isZoomed) and .accessibilityHidden(isZoomed).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@swiftchan/Views/Media/Gallery/GalleryView.swift` around lines 120 - 133, Add
.allowsHitTesting(!isZoomed) and .accessibilityHidden(isZoomed) to the
closeButton view so it is neither interactive nor exposed to accessibility when
zoomed, while preserving its existing opacity and animation behavior.

}

@ViewBuilder
private func mediaView(for index: Int) -> some View {
if viewModel.media.indices.contains(index) {
Expand All @@ -139,15 +146,13 @@ struct GalleryView: View {
if zoomed {
showPreview = false
}
updateInteractiveDismiss()
onMediaChanged?(zoomed)
}
.onSeekChanged { seeking in
isSeeking = seeking
refreshPagingState()
canShowPreview = !seeking
canShowContextMenu = !seeking
updateInteractiveDismiss()
}
.mediaDownloadMenu(url: media.url, canShowContextMenu: $canShowContextMenu)
.accessibilityIdentifier(
Expand Down Expand Up @@ -183,7 +188,6 @@ struct GalleryView: View {
var currentItem = viewModel.media[index]
currentItem.isSelected = true
viewModel.media[index] = currentItem
updateInteractiveDismiss()

// Dynamic prefetching: update prefetch window as user swipes
viewModel.prefetch(currentIndex: index)
Expand Down Expand Up @@ -220,14 +224,6 @@ struct GalleryView: View {
refreshPagingState()
}

private func updateInteractiveDismiss(using controller: UISheetPresentationController? = nil) {
let controller = controller ?? sheetPresentationController
guard let controller else { return }
let allowDismiss = !isZoomed && !isSeeking
DispatchQueue.main.async {
controller.presentedViewController.isModalInPresentation = !allowDismiss
}
}
}

extension GalleryView: Buildable {
Expand Down
8 changes: 4 additions & 4 deletions swiftchanTests/swiftchanTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ class SwiftchanTests: XCTestCase {

Softimage Mod Tool:

http://usa.autodesk.com/adsk/servlet/pc/item?id=13571257&siteID=123112
http://usa.autodesk.com/adsk/servle\u{200B}t/pc/item?id=13571257&siteID=123112

Houdini Apprentice:

http://www.sidefx.com/index.php?option=com_download&Itemid=208&task=apprentice
http://www.sidefx.com/index.php?opt\u{200B}ion=com_download&Itemid=208&task=ap\u{200B}prentice
""")
print(result)
XCTAssertEqual(result[0].0, URL(string: "http://www.blender.org/")!)
Expand All @@ -39,8 +39,8 @@ class SwiftchanTests: XCTestCase {
}

func testHyperLinkFinderQueryParam() throws {
let urlString = "https://store.steampowered.com/app/773840/DRAG/"
// let percentUrlString = "https://store.steampowered.com/app/773840/DRAG/".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let urlString = "https://store.steampowered.com/app/\u{200B}773840/DRAG/"
// let percentUrlString = "https://store.steampowered.com/app/\u{200B}773840/DRAG/".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let result = parser.checkForUrls(urlString)
XCTAssertEqual(result[0].0, URL(string: "https://store.steampowered.com/app/773840/DRAG/"))
}
Expand Down
Loading