commits
- Add expired field to GrainStory model
- Add storyUri/storyThumb and story notification types to notifications
- Story notification tap fetches and opens specific story in StoryViewer
- Handle expired story deep links with single-story viewer
- Remove StoryFavoriteCache (server now provides viewer state)
- Comment sheet locked to .large detent for smooth keyboard behavior
- Remove comment bubble icon, comment preview gets subtle background
- Full comment input capsule is tappable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add kebab (ellipsis) button and action sheet to GalleryCardView
- Wire onReport/onDelete in all feed views
- Remove redundant toolbar menu from GalleryDetailView
- Fix ReportView parameter names (uri/cid → subjectUri/subjectCid)
- Fix HashtagFeedView type-checker timeout by extracting card helper
- Fix StoryViewer missing self. in closure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Support local UIImage in ZoomableImage / ImageZoomState
Adds a Source enum to ZoomableImage with .url and .local cases plus a
matching ImageZoomOverlay branch that renders an Image(uiImage:) when
the state holds a local image. The legacy URL initializer is preserved
so existing feed call sites are unchanged. LocalZoomableViewer is
removed; the create gallery preview now uses the same zoom infrastructure
as the feed.
* Push CreateGalleryView instead of presenting as a sheet
Switches the create flow from a modal sheet to a NavigationStack push
inside the feed tab. The push lives on FeedView via .navigationDestination
(isPresented:), and CreateGalleryView no longer wraps its body in its own
NavigationStack — title and trailing toolbar item attach to the parent
stack. Removes the Cancel toolbar button since the iOS edge-swipe back
handles dismissal natively. MainTabView drops the .sheet wiring.
Also pulls in the location detection changes that came along with this
restructure: detectLocation() now reads items.first only and re-runs on
photoItems.first?.id changes, plus the "Use first photo location"
copy update.
* feat: photo editor strip + grid reorder with UIKit gestures
- PhotoEditor with inline Form sections, carousel, tap-to-preview alt overlay
- ReorderablePhotoStrip with custom long-press+drag gesture, auto-scroll,
live sibling reorder preview
- ReorderablePhotoGrid with 2D drag math and row-boundary wraps
- PhotoThumbnailCell extracted as shared cell with outside-corner X button
- Uniform grid slot sizing driven by most-portrait photo
- UIKit gesture recognizers replace SwiftUI gestures for strip and grid
* feat: high-res preview cache for post preview carousel
The carousel was rendering item.thumbnail (downsized to 150pt for the
strip/grid) and looked blurry full-screen. PhotoEditor now keeps a small
LRU cache of preview-sized images (1500pt max) loaded on demand whenever
selectedPhotoID changes.
The cache is bounded by previewCacheLimit (5 entries) so peak memory stays
sane even on the maxSelectionCount=20 ceiling — at ~5MB per decoded preview
that's a worst case of ~25MB on top of existing app memory. Picker items
go through PhotosPickerItem.loadTransferable; camera items reuse the
UIImage already in PhotoSource.camera. Fallback during load is the
existing 150pt thumbnail so the carousel is never blank.
* fix: editor polish — grid bindings, pickup animation, matched-geometry prep
- Wire simultaneous UIKit gesture recognition via delegate
- Replace bindingFor with ForEach($items) safe per-id bindings
- Pickup animation scales + fades cell on drag start
- Lock parent form scroll during cell pickup
- Matched-geometry namespace prep for strip↔grid transition
- Grid cells use photo's natural aspect ratio
- Lock back-swipe and strip scroll while reordering
* fix: zoom overlay tracks gesture session, not image identity
ZoomableImage's isCurrentlyZoomed used `zoomState.localImage === image` for
.local sources, which fails when the rendered UIImage instance changes during
the same view lifetime — e.g. PhotoEditor swapping a 150pt thumbnail for a
1500pt preview as its cache fills. The base image stayed visible behind the
overlay because the identity check couldn't resolve.
Replaces it with a per-instance @State flag flipped on in onBegan and off in
resetZoom, so 'this view is currently the one being zoomed' tracks the gesture
session rather than the photo bytes. URL sources still compare by URL string
since those don't churn.
Also adds .failed to handlePinch / handlePan switch cases so an interrupted
pinch or pan (third finger lands, sibling recognizer claims the gesture)
still routes through onEnded — without it the zoom state could freeze and
the snap-back never fired.
* feat: remove reorder from strip, rename grid as "Reorder" mode
Per HIG modality guidance, reorder is now a distinct MODE rather than a
gesture available everywhere. Gesture-wise, reorder lives exclusively in
the grid; entering / exiting the grid is a clear mode transition with
text affordances (not icon-only).
Strip changes (Grain/Views/Create/ReorderablePhotoStrip.swift →
PhotoStrip.swift):
- Deleted: ReorderRecognizer gesture, handleReorder / beginDrag /
handleDragChanged / autoScrollIfNeeded / handleDragEnded / resetDragState,
xOffset live-preview math, ScrollPanLocker, all drag state
(draggedID / dragStartIndex / dragCurrentIndex / dragOffsetX /
scrollOffsetX / lastScrollOffsetX / viewportWidth / lastAutoScrollAt),
onScrollGeometryChange delta compensation, isReordering binding.
- Kept: tap-to-select, delete, wallet-remove transition, scrollTo the
selected photo on selection change.
- Struct renamed ReorderablePhotoStrip → PhotoStrip to reflect the new
read-only role. The file size drops from ~350 lines to ~115.
PhotoEditor header changes:
- Section title swaps "Photos" ↔ "Reorder" on the isExpanded toggle.
- Trailing button is now a text affordance, not an icon: "Reorder"
(semibold) to enter, "Done" (bold, accent) to exit. Same visual
pattern as Apple Photos' Select mode.
- Accessibility label updated to "Enter reorder mode" / "Exit reorder
mode" so VoiceOver conveys the modal nature.
- Strip no longer takes isReordering — PhotoEditor drops that argument
at the PhotoStrip callsite. Grid still takes it (its reorder gesture
still needs to drive scroll lock + back-swipe lock).
* refactor: replace Mode enum with CellGeometry, extract ExifSettingsRow
PhotoThumbnailCell now receives a CellGeometry bundle (photoSize, maskSide,
maskCornerRadius) instead of a Mode enum, preventing callers from passing
inconsistent values and centralising layout math in the parent. Adds
MatchedPhotoModifier for the strip↔grid transition.
ExifInfoView extracts ExifSettingsRow as a standalone component with stable
max-width proxies and a spring animation on value changes.
* fix: EXIF view slides under carousel instead of snapping on selection change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: replace EditorMode binary enum with 3-case CaseIterable
* feat: add captions mode with inline alt-text list
* fix: isAnimatingMode safety net, carousel gating, and transition cleanup
* fix: gallery photo editor — selection ring, captions crash, grid layout
- Replace matched-geometry selection ring with per-cell SelectionBorderShape
trim animation; border draws from X-button corner, easeInOut curve avoids
spring overshoot on bounded trim values, drawingGroup() offloads path
re-computation to Metal each frame
- Add ExifChip component for inline EXIF display in cells
- Fix captions↔strip/grid crash: withAnimation(.smooth) on a Form row height
change (short strip → tall captions list) drove UICollectionViewCompositional-
Layout into recursive invalidation (depth 100 assertion); captions transitions
are now instant
- Fix reorder grid photos stuck at origin: containerWidth sentinel was 0 so
cellSide = 0 on first render; initialise from UIScreen.main.bounds.width and
suppress animation on geometry corrections; accept containerWidth from parent
so the value is known before the grid mounts
- Strip scroll stutter: two concurrent withAnimation(.snappy) contexts (onTap +
onChange) competed on the same layout; onTap now sets selectedPhotoID without
animation (value-based ring animation handles the ring, onChange handles scroll)
- Simplify carousel gate from complex opacity/isAnimatingMode logic to plain
mode == .preview with .transition(.opacity)
* refactor: centralize aspect ratio as PhotoItem.naturalAspect
Add a single naturalAspect computed property to PhotoItem (w/h of thumbnail)
as the authoritative source for cell aspect geometry. Also replaces
UIScreen.main.bounds.width with hardcoded 393 in preview data so Xcode
previews don't depend on the host screen at render time.
* feat: captions list with unified thumbnails
* instrument: add OSSignposter tracing for photo loading and morph animation
Adds signpost intervals and events across the gallery create flow so
Instruments (os_signpost) can show timing for:
- Photo picker batch loading and per-photo load/thumb/decompress stages
- Hi-res preview prefetch window and per-image load/decode pipeline
- Strip↔grid morph animation start, completion-handler path, and safety-net path
- Strip scroll events: tap, post-morph deferred scroll, carousel fallback, delete
- MatchedPhotoModifier applied vs. passthrough (for debugging geometry capture)
* chore: support SIM_UDID targeting in just sim recipe
Add SIM_UDID to .env.example and update the sim recipe to target that
specific simulator when the env var is set and the device is booted.
Falls back to 'booted' when unset, preserving existing behaviour.
* fix: captions polish — EXIF badge, ExifChip cleanup, morph diagnostics
- Detach preview image loading from main actor to eliminate scroll hangs
- Simplify ExifChip API — remove compact and cameraName params
- Add compact EXIF badge to captions list thumbnail overlay
- Fix signposter closure captures for strict concurrency
- Pre-seed strip scroll from selectedPhotoID
- Clean up captions mode UI polish
* refactor: replace UIScrollView strip with pure @State offset + drag gesture
PhotoStrip now positions its HStack via @State baseOffset + @GestureState
dragTranslation instead of a ScrollView-backed UIScrollView. This means
layout settles in one synchronous SwiftUI pass — matched-geometry captures
destination frames at their final resting positions on the first render,
eliminating the old two-phase morph-then-scroll artifact.
- Strip: HStack with offset(), DragGesture for free-scroll + velocity snap
- Editor: drop the 80ms pre-delay workaround; both transition directions
are now symmetric (no UIScrollView async catchup to wait for)
- ThumbnailCell: swap selection ring overlay for accent glow shadows + scale
* feat(captions): widen caption TextField with reliable mode morphs
- Widen caption TextField for comfortable editing
- Restructure mode conditionals and dynamic isMatchedSource
for reliable captions morphs across strip ↔ reorder ↔ captions
* fix: update test to match 3-feed defaults (recent, following, foryou)
* refactor: replace PhotoStrip/ReorderablePhotoGrid with AdaptivePhotoLayout
Split drag state into ReorderDragState, scroll state into StripScrollState,
and replaced the two monolithic views with AdaptivePhotoLayout. Simplified
PhotoThumbnailCell, removed matchedNamespace from CaptionsListPrototype,
and stripped verbose inline docs from PhotoEditor/PreviewCacheStore.
* fix: prevent simultaneous H+V gesture recognition in strip pan
* fix: strip clip, xmark sizing, and captions divider polish
* feat: present CreateGallery as sheet with discard-changes guard
* fix: block form scroll from touch-down through end of reorder drag
* fix: block sheet swipe-dismiss when gallery has unsaved changes
* fix: use alert for discard confirmation, tint X red when dirty
* swift build
* drag animation problems
* Update DEVELOPMENT.md
* Chnage discard alert copy
* fix drag
* Rename PhotoEditor to GalleryEditor
Renames the struct, test class, and source files; updates all call
sites and comments throughout the codebase; regenerates xcodeproj.
* feat: improve photo editor UX and fix picker sync
- Replace scale+glow thumbnail selection with border+dim approach
- Fix tap target issues with .contentShape(Rectangle())
- Sync photo picker selection when removing photos via editor X button
(add .continuousAndOrdered + .shared() to PhotosPicker)
- Add dedup guard to prevent duplicate photos from rapid picker events
- Make ExifChip square to match ALT pill styling
- Use adaptive background for editor (light/dark mode support)
- Change "Post Preview" header to "Preview"
- Add always-visible Bluesky cross-post footer note
- Refresh feed after gallery creation via feedRefreshID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chad Miller <chadtmiller15@gmail.com>
- toggleStoryFavorite now captures storyUri and re-resolves the index by
URI after every await, so timer auto-advance or a user tap mid-request
no longer lands the optimistic/success/rollback writes on the wrong
story. Guard per-URI via favoritingStoryUris so overlapping requests
on different stories don't clobber each other. Signpost intervals +
logger lines wrap the toggle flow for Instruments.
- Rename StoryCommentsViewModel.latestComment → firstComment (it's
chronologically first, not latest); update tests and StoryViewer
callers.
- Double-tap heart now follows the finger: tap zones report location
via .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")),
with the matching .coordinateSpace declared on the storyContent
ZStack where the hearts overlay renders.
- Fix self.progress autoclosure warning in StoryTimer.resume logger.
Removes per-body-eval, onAppear/onDisappear, onChange, task
fired/done, image.onAppear, and startTimerIfSafe/resumeTimerIfSafe
skip logs — they spammed the log stream on every SwiftUI
re-evaluation and drowned out the events that matter.
Also fixes the missing self. on StoryTimer.resume's progress
interpolation, which broke the build the moment anything else
touched this file.
Adds os_signpost coverage for the comment sheet lifecycle
(onAppear, onDisappear with last detent, detent changes, task
begin/end) and the StoryCommentsViewModel loadComments interval.
Gives the Zed log:StoryComments stream the events needed to
correlate sheet-lifecycle issues with fetch timing.
Hiding the dedicated comment window (or calling endEditing on it)
synchronously inside SwiftUI's .sheet onDismiss crashes when the
TextField is still first responder — resigning a @FocusState-bound
field mid-sheet-teardown re-enters SwiftUI while the sheet view tree
is still unwinding. Defer both endEditing and isHidden to the next
main-runloop tick so the current dismiss callback returns first.
Also drops the redundant dismiss() in CommentSheetContent's Done
button. onDismiss() already clears the sheet target, so dismiss()
was a double-dismiss racing with the same teardown.
Adds signposts and pendingDismissSource tracking around the second
window path to make any future regression easier to bisect.
Bump the heart to .title / 44pt frame so it visually matches the add
comment pill, and scale the particle burst by 1.4 to keep the burst
proportional to the bigger icon.
Extract heartIcon and particleBurstOverlay helpers, thread story through
bottomInputBar so the non-interactive variant can reflect favorited state
and trigger the particle burst on change.
Also fix Logger self-capture in StoryTimer.resume and tighten the latest
comment crossfade to 0.2s.
Time now appears inline with the username on row 1 (caption2, dimmed);
location sits alone on row 2. Placeholder Text reserves two-line height
so the username never shifts when time/location fade in (0.12s easeIn).
Also removes GrainProfile.viewer field (fully unused after last refactor).
Hosts the comment sheet in an isolated UIWindow via StoryCommentPresenter
so SwiftUI no longer rebuilds StoryViewer's @State on every sheet open or
close. Adds StoryTimer.resume() to continue from existing progress after
sheet dismissal, combines the location pill and latest comment into a
single row, and simplifies the comment button to a bare bubble icon.
Both gallery and story comment sheets rendered the same threaded list,
glass input pill, mention autocomplete, and reply banner through
duplicate code. Extract CommentSheetContent as the shared inner view;
each wrapper now owns only its VM, load lifecycle, and post/delete
closures. Adopt the story path's optimistic-clear post flow in both,
and route the gallery path through CommentService to match.
StoryCommentSheet's init signature is unchanged.
- Fix TupleView safeAreaInset bug by wrapping commentList in VStack
- Wire Done button via @Environment(\.dismiss) so it works without onDismiss callback
- Replace manual ultraThinMaterial pill with GlassEffectContainer matching iMessage-style input from PR #13
Framework signposts in the user's trace showed the comment sheet
opening fully (~840ms) then dismissing 3ms later — a programmatic
dismissal right after presentation completes. The _UIFormSheetPresentationController
was being torn down immediately after setup.
The DragToDismissInstaller installs a UIPanGestureRecognizer directly
on the hosting view controller's view via findViewController(). That
gesture recognizer remains active when a .sheet presents on top, and
its interaction with the sheet's own gesture system (the form sheet's
drag-to-dismiss) was tearing down the presentation.
Fix: add isEnabled: Bool to DragToDismissInstaller, wire it to
!showCommentSheet in StoryViewer. When the comment sheet is open, our
pan gesture is disabled so the sheet's own gestures can own touches
without interference.
Adds Logger and OSSignposter instrumentation at every event that could
explain the chained failure: body evaluation, .task fires, presentStories,
advanceStory, loadStoriesForCurrentAuthor, startTimerIfSafe, timer
start/stop/complete, openCommentSheet, sheet content body evaluation
(logs whether sheetStoryUri or currentStory is nil), image onAppear
(both cached and lazy paths), body onAppear/onDisappear, and onChange
for showCommentSheet / imageLoaded / currentStoryIndex / stories.count.
Subsystem: social.grain.grain, category: StoryViewer.
Filter in Console.app or use `log stream` with predicate:
subsystem == "social.grain.grain" AND category == "StoryViewer"
Two tightly-correlated bugs had the same root cause: SwiftUI was
re-firing the StoryViewer .task when the comment sheet presented,
triggering loadStoriesForCurrentAuthor → presentStories again. That
reset imageLoaded and restarted the timer, which caused:
- blur/spinner flash on the story image (thumb cache bug was back)
- timer resetting to 0 and auto-advancing while the sheet was open
- the sheet collapsing because currentStory briefly became nil during
a presentStories cycle, and the .sheet content reads `if let story =
currentStory` — empty content auto-dismisses
Fixes:
- hasLoadedInitialStories guard on .task so it only loads once per
StoryViewer instance, even if SwiftUI re-fires it
- sheetStoryUri @State pinned when a comment button is tapped, so the
sheet content uses a stable URI instead of re-reading currentStory
on every render
- openCommentSheet(focusInput:) helper consolidating the three button
call sites
- Remove the auto-restart onChange handler so the timer stays paused
after the comment sheet dismisses (user manually navigates)
- Persist StoryFavoriteCache to UserDefaults so favorites survive app
restarts, not just the current session
- Prefetch comment previews for all stories in the current author's
set, and for the next author on swipe (likes stay local per user's
request)
- Left-align the comment preview pill with a trailing Spacer
- Clean up toggleStoryFavorite rollback paths: restore prevViewer on
error, handle nil response.uri gracefully
The server doesn't implement social.grain.unspecced.getStoryThread —
verified via curl. But getGalleryThread already queries by subject URI
regardless of whether the subject is a gallery or a story, so the
client can use it for both. Switched StoryCommentsViewModel to call
getGalleryThread(gallery: storyUri) and deleted the orphaned
getStoryThread endpoint.
Simplified StoryFavoriteCache to a cleaner bool-oriented API
(isLiked/like/unlike). Key fix: isFavorited in StoryViewer now reads
from BOTH the story's viewer state AND the cache, so reopened stories
still show as liked even when the server returns no viewer state.
Also guards against nil response.uri on create so we don't accidentally
clear the cache entry on a successful request.
Adds mock stories (with real bundle images for Kyoto/Oregon/Portland),
story-specific comment fixtures (including a threaded reply), and
avatars on the secondary preview profiles so they render with content
instead of gray circles.
Also wires the StoryViewer preview to inject initialStories so it
displays something on first load, and adds a populated StoryCommentSheet
preview showing the sheet at medium detent with mock comments visible.
Sheet collapse bug: tap zones overlapped with the comment input bar,
letting taps leak through to goToNext() which called advanceStory and
restarted the timer. Added an 80pt bottom inset to the tap zones so
they don't cover the comment bar, and scoped allowsHitTesting correctly
back onto the tap zones VStack (not the hearts ForEach). Added
.buttonStyle(.plain) + explicit contentShape on each input bar button
so Swift reliably routes taps to them.
Favorite persistence bug: story viewer state wasn't surviving a close +
reopen because the client re-fetches stories and the server doesn't
yet return viewer state for stories. Added StoryFavoriteCache —
session-scoped storage of storyUri → favUri that toggleStoryFavorite
writes to and presentStories overlays onto fetched stories.
Both gallery and story code duplicated the record-building logic for
comment/favorite creation and the rkey extraction for deletion. Pull
these into stateless service enums so callers only handle their own
optimistic state updates.
Subtle differences (favCount tracking on galleries, viewer state types,
UI layouts, thread endpoint names) stay at call sites to keep gallery
and story concerns independent.
8 test cases covering preview loading, cache hits/misses, comment
CRUD, pagination, and cache-first story switching. Relaxes the
active-story guard in loadPreview so direct calls work outside the
switchToStory flow.
- Instagram-style floating comment bar at bottom (bubble icon, "Add a
comment..." placeholder, heart button)
- Latest comment preview above the bar, tappable to open comment list
- Comment sheet opens at medium detent, expandable to large
- Two sheet modes: input-focused (keyboard) vs list-browsing
- Double-tap anywhere to like with heart ripple animation
- Heart button with particle burst and optimistic favorite toggle
- Timer pauses when sheet opens, resumes on dismiss
- Story navigation (tap/swipe) blocked while sheet is open
- Comment preview loaded per-story via cache-first switchToStory
ViewModel with preview caching (per-URI), full comment loading,
pagination, and CRUD. Sheet supports medium/large detents, threaded
comments via CommentRow, mention autocomplete, and two open modes
(input-focused vs comment-list browsing).
Remove private access from HeartAnimationState, DoubleTapHeartView, and
LikeParticleView so they can be reused in StoryViewer for story likes.
Foundation for story comments — adds optional viewer state to GrainStory
(with fav tracking) and a getStoryThread XRPC endpoint that mirrors
getGalleryThread, reusing GetGalleryThreadResponse.
* Support local UIImage in ZoomableImage / ImageZoomState
Adds a Source enum to ZoomableImage with .url and .local cases plus a
matching ImageZoomOverlay branch that renders an Image(uiImage:) when
the state holds a local image. The legacy URL initializer is preserved
so existing feed call sites are unchanged. LocalZoomableViewer is
removed; the create gallery preview now uses the same zoom infrastructure
as the feed.
* Push CreateGalleryView instead of presenting as a sheet
Switches the create flow from a modal sheet to a NavigationStack push
inside the feed tab. The push lives on FeedView via .navigationDestination
(isPresented:), and CreateGalleryView no longer wraps its body in its own
NavigationStack — title and trailing toolbar item attach to the parent
stack. Removes the Cancel toolbar button since the iOS edge-swipe back
handles dismissal natively. MainTabView drops the .sheet wiring.
Also pulls in the location detection changes that came along with this
restructure: detectLocation() now reads items.first only and re-runs on
photoItems.first?.id changes, plus the "Use first photo location"
copy update.
* feat: photo editor strip + grid reorder with UIKit gestures
- PhotoEditor with inline Form sections, carousel, tap-to-preview alt overlay
- ReorderablePhotoStrip with custom long-press+drag gesture, auto-scroll,
live sibling reorder preview
- ReorderablePhotoGrid with 2D drag math and row-boundary wraps
- PhotoThumbnailCell extracted as shared cell with outside-corner X button
- Uniform grid slot sizing driven by most-portrait photo
- UIKit gesture recognizers replace SwiftUI gestures for strip and grid
* feat: high-res preview cache for post preview carousel
The carousel was rendering item.thumbnail (downsized to 150pt for the
strip/grid) and looked blurry full-screen. PhotoEditor now keeps a small
LRU cache of preview-sized images (1500pt max) loaded on demand whenever
selectedPhotoID changes.
The cache is bounded by previewCacheLimit (5 entries) so peak memory stays
sane even on the maxSelectionCount=20 ceiling — at ~5MB per decoded preview
that's a worst case of ~25MB on top of existing app memory. Picker items
go through PhotosPickerItem.loadTransferable; camera items reuse the
UIImage already in PhotoSource.camera. Fallback during load is the
existing 150pt thumbnail so the carousel is never blank.
* fix: editor polish — grid bindings, pickup animation, matched-geometry prep
- Wire simultaneous UIKit gesture recognition via delegate
- Replace bindingFor with ForEach($items) safe per-id bindings
- Pickup animation scales + fades cell on drag start
- Lock parent form scroll during cell pickup
- Matched-geometry namespace prep for strip↔grid transition
- Grid cells use photo's natural aspect ratio
- Lock back-swipe and strip scroll while reordering
* fix: zoom overlay tracks gesture session, not image identity
ZoomableImage's isCurrentlyZoomed used `zoomState.localImage === image` for
.local sources, which fails when the rendered UIImage instance changes during
the same view lifetime — e.g. PhotoEditor swapping a 150pt thumbnail for a
1500pt preview as its cache fills. The base image stayed visible behind the
overlay because the identity check couldn't resolve.
Replaces it with a per-instance @State flag flipped on in onBegan and off in
resetZoom, so 'this view is currently the one being zoomed' tracks the gesture
session rather than the photo bytes. URL sources still compare by URL string
since those don't churn.
Also adds .failed to handlePinch / handlePan switch cases so an interrupted
pinch or pan (third finger lands, sibling recognizer claims the gesture)
still routes through onEnded — without it the zoom state could freeze and
the snap-back never fired.
* feat: remove reorder from strip, rename grid as "Reorder" mode
Per HIG modality guidance, reorder is now a distinct MODE rather than a
gesture available everywhere. Gesture-wise, reorder lives exclusively in
the grid; entering / exiting the grid is a clear mode transition with
text affordances (not icon-only).
Strip changes (Grain/Views/Create/ReorderablePhotoStrip.swift →
PhotoStrip.swift):
- Deleted: ReorderRecognizer gesture, handleReorder / beginDrag /
handleDragChanged / autoScrollIfNeeded / handleDragEnded / resetDragState,
xOffset live-preview math, ScrollPanLocker, all drag state
(draggedID / dragStartIndex / dragCurrentIndex / dragOffsetX /
scrollOffsetX / lastScrollOffsetX / viewportWidth / lastAutoScrollAt),
onScrollGeometryChange delta compensation, isReordering binding.
- Kept: tap-to-select, delete, wallet-remove transition, scrollTo the
selected photo on selection change.
- Struct renamed ReorderablePhotoStrip → PhotoStrip to reflect the new
read-only role. The file size drops from ~350 lines to ~115.
PhotoEditor header changes:
- Section title swaps "Photos" ↔ "Reorder" on the isExpanded toggle.
- Trailing button is now a text affordance, not an icon: "Reorder"
(semibold) to enter, "Done" (bold, accent) to exit. Same visual
pattern as Apple Photos' Select mode.
- Accessibility label updated to "Enter reorder mode" / "Exit reorder
mode" so VoiceOver conveys the modal nature.
- Strip no longer takes isReordering — PhotoEditor drops that argument
at the PhotoStrip callsite. Grid still takes it (its reorder gesture
still needs to drive scroll lock + back-swipe lock).
* refactor: replace Mode enum with CellGeometry, extract ExifSettingsRow
PhotoThumbnailCell now receives a CellGeometry bundle (photoSize, maskSide,
maskCornerRadius) instead of a Mode enum, preventing callers from passing
inconsistent values and centralising layout math in the parent. Adds
MatchedPhotoModifier for the strip↔grid transition.
ExifInfoView extracts ExifSettingsRow as a standalone component with stable
max-width proxies and a spring animation on value changes.
* fix: EXIF view slides under carousel instead of snapping on selection change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: replace EditorMode binary enum with 3-case CaseIterable
* feat: add captions mode with inline alt-text list
* fix: isAnimatingMode safety net, carousel gating, and transition cleanup
* fix: gallery photo editor — selection ring, captions crash, grid layout
- Replace matched-geometry selection ring with per-cell SelectionBorderShape
trim animation; border draws from X-button corner, easeInOut curve avoids
spring overshoot on bounded trim values, drawingGroup() offloads path
re-computation to Metal each frame
- Add ExifChip component for inline EXIF display in cells
- Fix captions↔strip/grid crash: withAnimation(.smooth) on a Form row height
change (short strip → tall captions list) drove UICollectionViewCompositional-
Layout into recursive invalidation (depth 100 assertion); captions transitions
are now instant
- Fix reorder grid photos stuck at origin: containerWidth sentinel was 0 so
cellSide = 0 on first render; initialise from UIScreen.main.bounds.width and
suppress animation on geometry corrections; accept containerWidth from parent
so the value is known before the grid mounts
- Strip scroll stutter: two concurrent withAnimation(.snappy) contexts (onTap +
onChange) competed on the same layout; onTap now sets selectedPhotoID without
animation (value-based ring animation handles the ring, onChange handles scroll)
- Simplify carousel gate from complex opacity/isAnimatingMode logic to plain
mode == .preview with .transition(.opacity)
* refactor: centralize aspect ratio as PhotoItem.naturalAspect
Add a single naturalAspect computed property to PhotoItem (w/h of thumbnail)
as the authoritative source for cell aspect geometry. Also replaces
UIScreen.main.bounds.width with hardcoded 393 in preview data so Xcode
previews don't depend on the host screen at render time.
* feat: captions list with unified thumbnails
* instrument: add OSSignposter tracing for photo loading and morph animation
Adds signpost intervals and events across the gallery create flow so
Instruments (os_signpost) can show timing for:
- Photo picker batch loading and per-photo load/thumb/decompress stages
- Hi-res preview prefetch window and per-image load/decode pipeline
- Strip↔grid morph animation start, completion-handler path, and safety-net path
- Strip scroll events: tap, post-morph deferred scroll, carousel fallback, delete
- MatchedPhotoModifier applied vs. passthrough (for debugging geometry capture)
* chore: support SIM_UDID targeting in just sim recipe
Add SIM_UDID to .env.example and update the sim recipe to target that
specific simulator when the env var is set and the device is booted.
Falls back to 'booted' when unset, preserving existing behaviour.
* fix: captions polish — EXIF badge, ExifChip cleanup, morph diagnostics
- Detach preview image loading from main actor to eliminate scroll hangs
- Simplify ExifChip API — remove compact and cameraName params
- Add compact EXIF badge to captions list thumbnail overlay
- Fix signposter closure captures for strict concurrency
- Pre-seed strip scroll from selectedPhotoID
- Clean up captions mode UI polish
* refactor: replace UIScrollView strip with pure @State offset + drag gesture
PhotoStrip now positions its HStack via @State baseOffset + @GestureState
dragTranslation instead of a ScrollView-backed UIScrollView. This means
layout settles in one synchronous SwiftUI pass — matched-geometry captures
destination frames at their final resting positions on the first render,
eliminating the old two-phase morph-then-scroll artifact.
- Strip: HStack with offset(), DragGesture for free-scroll + velocity snap
- Editor: drop the 80ms pre-delay workaround; both transition directions
are now symmetric (no UIScrollView async catchup to wait for)
- ThumbnailCell: swap selection ring overlay for accent glow shadows + scale
* feat(captions): widen caption TextField with reliable mode morphs
- Widen caption TextField for comfortable editing
- Restructure mode conditionals and dynamic isMatchedSource
for reliable captions morphs across strip ↔ reorder ↔ captions
* fix: update test to match 3-feed defaults (recent, following, foryou)
* refactor: replace PhotoStrip/ReorderablePhotoGrid with AdaptivePhotoLayout
Split drag state into ReorderDragState, scroll state into StripScrollState,
and replaced the two monolithic views with AdaptivePhotoLayout. Simplified
PhotoThumbnailCell, removed matchedNamespace from CaptionsListPrototype,
and stripped verbose inline docs from PhotoEditor/PreviewCacheStore.
* fix: prevent simultaneous H+V gesture recognition in strip pan
* fix: strip clip, xmark sizing, and captions divider polish
* feat: present CreateGallery as sheet with discard-changes guard
* fix: block form scroll from touch-down through end of reorder drag
* fix: block sheet swipe-dismiss when gallery has unsaved changes
* fix: use alert for discard confirmation, tint X red when dirty
* swift build
* drag animation problems
* Update DEVELOPMENT.md
* Chnage discard alert copy
* fix drag
* Rename PhotoEditor to GalleryEditor
Renames the struct, test class, and source files; updates all call
sites and comments throughout the codebase; regenerates xcodeproj.
* feat: improve photo editor UX and fix picker sync
- Replace scale+glow thumbnail selection with border+dim approach
- Fix tap target issues with .contentShape(Rectangle())
- Sync photo picker selection when removing photos via editor X button
(add .continuousAndOrdered + .shared() to PhotosPicker)
- Add dedup guard to prevent duplicate photos from rapid picker events
- Make ExifChip square to match ALT pill styling
- Use adaptive background for editor (light/dark mode support)
- Change "Post Preview" header to "Preview"
- Add always-visible Bluesky cross-post footer note
- Refresh feed after gallery creation via feedRefreshID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chad Miller <chadtmiller15@gmail.com>
Instagram-style comment sheet with iMessage glass input
- Extract comments from GalleryDetailView into standalone CommentSheetView
- Add onCommentTap to GalleryCardView for direct sheet presentation
- Feed, Search, Hashtag, Location, Camera views open comment sheet directly
- GalleryDetailView (from profile) shows "View all X comments" button
- Comment input uses GlassEffectContainer with glass capsule text field
and glass circle send button, matching iMessage composer style
- Input floats via safeAreaInset with no backdrop
- Sheet starts at medium detent, expandable to large
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Guard .task so it only loads when profile is nil, preventing a full
reload (first page only) when popping back from gallery detail.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Block/mute actions on profile overflow menu with optimistic updates
- Muted comments shown collapsed with tap to expand
- Settings > Moderation > Blocked Users / Muted Users management
- Block OAuth scope added to auth request
- Reauth alert when block fails due to missing scope
- Block URI stored after creation for correct unblock
- Mute option hidden when user is blocked
- Profile content hidden when either party blocks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reset favoritesLoaded/archiveLoaded flags on refresh and re-fetch
when the user is currently on those tabs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Uses Apple's NaturalLanguage framework to detect non-native text and
shows a translate link that opens the system Translation sheet.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When advancing to a story whose fullsize image was already in the Nuke
cache, imageLoaded stayed true but nobody restarted the timer. Now
advanceStory starts the timer immediately for cached images.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a separate bare domain regex pass matching the web app's
RichText.svelte approach, so links like throne.com/user and
clarabelle.xyz are tappable without requiring https:// prefix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add FeedsManagementView with pin/unpin, drag-to-reorder, and
camera/location browsing sections
- Add CameraFeedView for previewing camera feeds before pinning
- Add "My Feeds" entry to feed switcher menu
- Add For You to default pinned feeds
- Add reorderFeeds to FeedPreferencesViewModel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PinchZoomOverlay's updateUIView was empty, so the coordinator kept a
stale onDoubleTap closure when LazyVStack reused cells. Now updateUIView
refreshes the coordinator's parent reference so the gesture always fires
against the correct gallery.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix: skip blur flash on cached stories; lower viewed threshold to 1%
If the timer starts at all the story counts as seen — the 25% threshold
was too conservative and missed quick swipes. 1% fires after ~50ms of
playback, ensuring a non-zero guard without requiring the user to sit
through a quarter of the story.
Extract the cache-check logic from StoryViewer into a module-internal
free function storyFullsizeCached(_:in:) that accepts an injectable
ImagePipeline, making it testable without a SwiftUI view or shared state.
Add StoryImageCacheTests covering nil story, invalid URL, cache miss,
cache hit, and pipeline isolation. Also fix project.yml so the test
target uses stable PRODUCT_MODULE_NAME/PRODUCT_NAME settings and a
BUNDLE_NAME-aware TEST_HOST, which was previously broken.
Swiping to a pane that had already been viewed would flash blur + spinner
over the image: imageLoaded was unconditionally reset in advanceStory and
presentStories, causing a render frame where cachedFullsize appeared nil
before the synchronous cache lookup ran.
Now both sites check Nuke's memory cache for the target story before
resetting — if the fullsize is already there, imageLoaded stays true and
the timer starts immediately with no intermediate loading state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When returning from background after 5+ minutes, onChange(scenePhase)
fired loadInitial which set isLoading=true. If that request hung on a
stale socket, pull-to-refresh hit the guard and silently returned.
Now loadInitial cancels any in-flight load before starting a new one.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add expired field to GrainStory model
- Add storyUri/storyThumb and story notification types to notifications
- Story notification tap fetches and opens specific story in StoryViewer
- Handle expired story deep links with single-story viewer
- Remove StoryFavoriteCache (server now provides viewer state)
- Comment sheet locked to .large detent for smooth keyboard behavior
- Remove comment bubble icon, comment preview gets subtle background
- Full comment input capsule is tappable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add kebab (ellipsis) button and action sheet to GalleryCardView
- Wire onReport/onDelete in all feed views
- Remove redundant toolbar menu from GalleryDetailView
- Fix ReportView parameter names (uri/cid → subjectUri/subjectCid)
- Fix HashtagFeedView type-checker timeout by extracting card helper
- Fix StoryViewer missing self. in closure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Support local UIImage in ZoomableImage / ImageZoomState
Adds a Source enum to ZoomableImage with .url and .local cases plus a
matching ImageZoomOverlay branch that renders an Image(uiImage:) when
the state holds a local image. The legacy URL initializer is preserved
so existing feed call sites are unchanged. LocalZoomableViewer is
removed; the create gallery preview now uses the same zoom infrastructure
as the feed.
* Push CreateGalleryView instead of presenting as a sheet
Switches the create flow from a modal sheet to a NavigationStack push
inside the feed tab. The push lives on FeedView via .navigationDestination
(isPresented:), and CreateGalleryView no longer wraps its body in its own
NavigationStack — title and trailing toolbar item attach to the parent
stack. Removes the Cancel toolbar button since the iOS edge-swipe back
handles dismissal natively. MainTabView drops the .sheet wiring.
Also pulls in the location detection changes that came along with this
restructure: detectLocation() now reads items.first only and re-runs on
photoItems.first?.id changes, plus the "Use first photo location"
copy update.
* feat: photo editor strip + grid reorder with UIKit gestures
- PhotoEditor with inline Form sections, carousel, tap-to-preview alt overlay
- ReorderablePhotoStrip with custom long-press+drag gesture, auto-scroll,
live sibling reorder preview
- ReorderablePhotoGrid with 2D drag math and row-boundary wraps
- PhotoThumbnailCell extracted as shared cell with outside-corner X button
- Uniform grid slot sizing driven by most-portrait photo
- UIKit gesture recognizers replace SwiftUI gestures for strip and grid
* feat: high-res preview cache for post preview carousel
The carousel was rendering item.thumbnail (downsized to 150pt for the
strip/grid) and looked blurry full-screen. PhotoEditor now keeps a small
LRU cache of preview-sized images (1500pt max) loaded on demand whenever
selectedPhotoID changes.
The cache is bounded by previewCacheLimit (5 entries) so peak memory stays
sane even on the maxSelectionCount=20 ceiling — at ~5MB per decoded preview
that's a worst case of ~25MB on top of existing app memory. Picker items
go through PhotosPickerItem.loadTransferable; camera items reuse the
UIImage already in PhotoSource.camera. Fallback during load is the
existing 150pt thumbnail so the carousel is never blank.
* fix: editor polish — grid bindings, pickup animation, matched-geometry prep
- Wire simultaneous UIKit gesture recognition via delegate
- Replace bindingFor with ForEach($items) safe per-id bindings
- Pickup animation scales + fades cell on drag start
- Lock parent form scroll during cell pickup
- Matched-geometry namespace prep for strip↔grid transition
- Grid cells use photo's natural aspect ratio
- Lock back-swipe and strip scroll while reordering
* fix: zoom overlay tracks gesture session, not image identity
ZoomableImage's isCurrentlyZoomed used `zoomState.localImage === image` for
.local sources, which fails when the rendered UIImage instance changes during
the same view lifetime — e.g. PhotoEditor swapping a 150pt thumbnail for a
1500pt preview as its cache fills. The base image stayed visible behind the
overlay because the identity check couldn't resolve.
Replaces it with a per-instance @State flag flipped on in onBegan and off in
resetZoom, so 'this view is currently the one being zoomed' tracks the gesture
session rather than the photo bytes. URL sources still compare by URL string
since those don't churn.
Also adds .failed to handlePinch / handlePan switch cases so an interrupted
pinch or pan (third finger lands, sibling recognizer claims the gesture)
still routes through onEnded — without it the zoom state could freeze and
the snap-back never fired.
* feat: remove reorder from strip, rename grid as "Reorder" mode
Per HIG modality guidance, reorder is now a distinct MODE rather than a
gesture available everywhere. Gesture-wise, reorder lives exclusively in
the grid; entering / exiting the grid is a clear mode transition with
text affordances (not icon-only).
Strip changes (Grain/Views/Create/ReorderablePhotoStrip.swift →
PhotoStrip.swift):
- Deleted: ReorderRecognizer gesture, handleReorder / beginDrag /
handleDragChanged / autoScrollIfNeeded / handleDragEnded / resetDragState,
xOffset live-preview math, ScrollPanLocker, all drag state
(draggedID / dragStartIndex / dragCurrentIndex / dragOffsetX /
scrollOffsetX / lastScrollOffsetX / viewportWidth / lastAutoScrollAt),
onScrollGeometryChange delta compensation, isReordering binding.
- Kept: tap-to-select, delete, wallet-remove transition, scrollTo the
selected photo on selection change.
- Struct renamed ReorderablePhotoStrip → PhotoStrip to reflect the new
read-only role. The file size drops from ~350 lines to ~115.
PhotoEditor header changes:
- Section title swaps "Photos" ↔ "Reorder" on the isExpanded toggle.
- Trailing button is now a text affordance, not an icon: "Reorder"
(semibold) to enter, "Done" (bold, accent) to exit. Same visual
pattern as Apple Photos' Select mode.
- Accessibility label updated to "Enter reorder mode" / "Exit reorder
mode" so VoiceOver conveys the modal nature.
- Strip no longer takes isReordering — PhotoEditor drops that argument
at the PhotoStrip callsite. Grid still takes it (its reorder gesture
still needs to drive scroll lock + back-swipe lock).
* refactor: replace Mode enum with CellGeometry, extract ExifSettingsRow
PhotoThumbnailCell now receives a CellGeometry bundle (photoSize, maskSide,
maskCornerRadius) instead of a Mode enum, preventing callers from passing
inconsistent values and centralising layout math in the parent. Adds
MatchedPhotoModifier for the strip↔grid transition.
ExifInfoView extracts ExifSettingsRow as a standalone component with stable
max-width proxies and a spring animation on value changes.
* fix: EXIF view slides under carousel instead of snapping on selection change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: replace EditorMode binary enum with 3-case CaseIterable
* feat: add captions mode with inline alt-text list
* fix: isAnimatingMode safety net, carousel gating, and transition cleanup
* fix: gallery photo editor — selection ring, captions crash, grid layout
- Replace matched-geometry selection ring with per-cell SelectionBorderShape
trim animation; border draws from X-button corner, easeInOut curve avoids
spring overshoot on bounded trim values, drawingGroup() offloads path
re-computation to Metal each frame
- Add ExifChip component for inline EXIF display in cells
- Fix captions↔strip/grid crash: withAnimation(.smooth) on a Form row height
change (short strip → tall captions list) drove UICollectionViewCompositional-
Layout into recursive invalidation (depth 100 assertion); captions transitions
are now instant
- Fix reorder grid photos stuck at origin: containerWidth sentinel was 0 so
cellSide = 0 on first render; initialise from UIScreen.main.bounds.width and
suppress animation on geometry corrections; accept containerWidth from parent
so the value is known before the grid mounts
- Strip scroll stutter: two concurrent withAnimation(.snappy) contexts (onTap +
onChange) competed on the same layout; onTap now sets selectedPhotoID without
animation (value-based ring animation handles the ring, onChange handles scroll)
- Simplify carousel gate from complex opacity/isAnimatingMode logic to plain
mode == .preview with .transition(.opacity)
* refactor: centralize aspect ratio as PhotoItem.naturalAspect
Add a single naturalAspect computed property to PhotoItem (w/h of thumbnail)
as the authoritative source for cell aspect geometry. Also replaces
UIScreen.main.bounds.width with hardcoded 393 in preview data so Xcode
previews don't depend on the host screen at render time.
* feat: captions list with unified thumbnails
* instrument: add OSSignposter tracing for photo loading and morph animation
Adds signpost intervals and events across the gallery create flow so
Instruments (os_signpost) can show timing for:
- Photo picker batch loading and per-photo load/thumb/decompress stages
- Hi-res preview prefetch window and per-image load/decode pipeline
- Strip↔grid morph animation start, completion-handler path, and safety-net path
- Strip scroll events: tap, post-morph deferred scroll, carousel fallback, delete
- MatchedPhotoModifier applied vs. passthrough (for debugging geometry capture)
* chore: support SIM_UDID targeting in just sim recipe
Add SIM_UDID to .env.example and update the sim recipe to target that
specific simulator when the env var is set and the device is booted.
Falls back to 'booted' when unset, preserving existing behaviour.
* fix: captions polish — EXIF badge, ExifChip cleanup, morph diagnostics
- Detach preview image loading from main actor to eliminate scroll hangs
- Simplify ExifChip API — remove compact and cameraName params
- Add compact EXIF badge to captions list thumbnail overlay
- Fix signposter closure captures for strict concurrency
- Pre-seed strip scroll from selectedPhotoID
- Clean up captions mode UI polish
* refactor: replace UIScrollView strip with pure @State offset + drag gesture
PhotoStrip now positions its HStack via @State baseOffset + @GestureState
dragTranslation instead of a ScrollView-backed UIScrollView. This means
layout settles in one synchronous SwiftUI pass — matched-geometry captures
destination frames at their final resting positions on the first render,
eliminating the old two-phase morph-then-scroll artifact.
- Strip: HStack with offset(), DragGesture for free-scroll + velocity snap
- Editor: drop the 80ms pre-delay workaround; both transition directions
are now symmetric (no UIScrollView async catchup to wait for)
- ThumbnailCell: swap selection ring overlay for accent glow shadows + scale
* feat(captions): widen caption TextField with reliable mode morphs
- Widen caption TextField for comfortable editing
- Restructure mode conditionals and dynamic isMatchedSource
for reliable captions morphs across strip ↔ reorder ↔ captions
* fix: update test to match 3-feed defaults (recent, following, foryou)
* refactor: replace PhotoStrip/ReorderablePhotoGrid with AdaptivePhotoLayout
Split drag state into ReorderDragState, scroll state into StripScrollState,
and replaced the two monolithic views with AdaptivePhotoLayout. Simplified
PhotoThumbnailCell, removed matchedNamespace from CaptionsListPrototype,
and stripped verbose inline docs from PhotoEditor/PreviewCacheStore.
* fix: prevent simultaneous H+V gesture recognition in strip pan
* fix: strip clip, xmark sizing, and captions divider polish
* feat: present CreateGallery as sheet with discard-changes guard
* fix: block form scroll from touch-down through end of reorder drag
* fix: block sheet swipe-dismiss when gallery has unsaved changes
* fix: use alert for discard confirmation, tint X red when dirty
* swift build
* drag animation problems
* Update DEVELOPMENT.md
* Chnage discard alert copy
* fix drag
* Rename PhotoEditor to GalleryEditor
Renames the struct, test class, and source files; updates all call
sites and comments throughout the codebase; regenerates xcodeproj.
* feat: improve photo editor UX and fix picker sync
- Replace scale+glow thumbnail selection with border+dim approach
- Fix tap target issues with .contentShape(Rectangle())
- Sync photo picker selection when removing photos via editor X button
(add .continuousAndOrdered + .shared() to PhotosPicker)
- Add dedup guard to prevent duplicate photos from rapid picker events
- Make ExifChip square to match ALT pill styling
- Use adaptive background for editor (light/dark mode support)
- Change "Post Preview" header to "Preview"
- Add always-visible Bluesky cross-post footer note
- Refresh feed after gallery creation via feedRefreshID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chad Miller <chadtmiller15@gmail.com>
- toggleStoryFavorite now captures storyUri and re-resolves the index by
URI after every await, so timer auto-advance or a user tap mid-request
no longer lands the optimistic/success/rollback writes on the wrong
story. Guard per-URI via favoritingStoryUris so overlapping requests
on different stories don't clobber each other. Signpost intervals +
logger lines wrap the toggle flow for Instruments.
- Rename StoryCommentsViewModel.latestComment → firstComment (it's
chronologically first, not latest); update tests and StoryViewer
callers.
- Double-tap heart now follows the finger: tap zones report location
via .onTapGesture(count: 2, coordinateSpace: .named("storyHearts")),
with the matching .coordinateSpace declared on the storyContent
ZStack where the hearts overlay renders.
- Fix self.progress autoclosure warning in StoryTimer.resume logger.
Removes per-body-eval, onAppear/onDisappear, onChange, task
fired/done, image.onAppear, and startTimerIfSafe/resumeTimerIfSafe
skip logs — they spammed the log stream on every SwiftUI
re-evaluation and drowned out the events that matter.
Also fixes the missing self. on StoryTimer.resume's progress
interpolation, which broke the build the moment anything else
touched this file.
Hiding the dedicated comment window (or calling endEditing on it)
synchronously inside SwiftUI's .sheet onDismiss crashes when the
TextField is still first responder — resigning a @FocusState-bound
field mid-sheet-teardown re-enters SwiftUI while the sheet view tree
is still unwinding. Defer both endEditing and isHidden to the next
main-runloop tick so the current dismiss callback returns first.
Also drops the redundant dismiss() in CommentSheetContent's Done
button. onDismiss() already clears the sheet target, so dismiss()
was a double-dismiss racing with the same teardown.
Adds signposts and pendingDismissSource tracking around the second
window path to make any future regression easier to bisect.
Hosts the comment sheet in an isolated UIWindow via StoryCommentPresenter
so SwiftUI no longer rebuilds StoryViewer's @State on every sheet open or
close. Adds StoryTimer.resume() to continue from existing progress after
sheet dismissal, combines the location pill and latest comment into a
single row, and simplifies the comment button to a bare bubble icon.
Both gallery and story comment sheets rendered the same threaded list,
glass input pill, mention autocomplete, and reply banner through
duplicate code. Extract CommentSheetContent as the shared inner view;
each wrapper now owns only its VM, load lifecycle, and post/delete
closures. Adopt the story path's optimistic-clear post flow in both,
and route the gallery path through CommentService to match.
StoryCommentSheet's init signature is unchanged.
Framework signposts in the user's trace showed the comment sheet
opening fully (~840ms) then dismissing 3ms later — a programmatic
dismissal right after presentation completes. The _UIFormSheetPresentationController
was being torn down immediately after setup.
The DragToDismissInstaller installs a UIPanGestureRecognizer directly
on the hosting view controller's view via findViewController(). That
gesture recognizer remains active when a .sheet presents on top, and
its interaction with the sheet's own gesture system (the form sheet's
drag-to-dismiss) was tearing down the presentation.
Fix: add isEnabled: Bool to DragToDismissInstaller, wire it to
!showCommentSheet in StoryViewer. When the comment sheet is open, our
pan gesture is disabled so the sheet's own gestures can own touches
without interference.
Adds Logger and OSSignposter instrumentation at every event that could
explain the chained failure: body evaluation, .task fires, presentStories,
advanceStory, loadStoriesForCurrentAuthor, startTimerIfSafe, timer
start/stop/complete, openCommentSheet, sheet content body evaluation
(logs whether sheetStoryUri or currentStory is nil), image onAppear
(both cached and lazy paths), body onAppear/onDisappear, and onChange
for showCommentSheet / imageLoaded / currentStoryIndex / stories.count.
Subsystem: social.grain.grain, category: StoryViewer.
Filter in Console.app or use `log stream` with predicate:
subsystem == "social.grain.grain" AND category == "StoryViewer"
Two tightly-correlated bugs had the same root cause: SwiftUI was
re-firing the StoryViewer .task when the comment sheet presented,
triggering loadStoriesForCurrentAuthor → presentStories again. That
reset imageLoaded and restarted the timer, which caused:
- blur/spinner flash on the story image (thumb cache bug was back)
- timer resetting to 0 and auto-advancing while the sheet was open
- the sheet collapsing because currentStory briefly became nil during
a presentStories cycle, and the .sheet content reads `if let story =
currentStory` — empty content auto-dismisses
Fixes:
- hasLoadedInitialStories guard on .task so it only loads once per
StoryViewer instance, even if SwiftUI re-fires it
- sheetStoryUri @State pinned when a comment button is tapped, so the
sheet content uses a stable URI instead of re-reading currentStory
on every render
- openCommentSheet(focusInput:) helper consolidating the three button
call sites
- Remove the auto-restart onChange handler so the timer stays paused
after the comment sheet dismisses (user manually navigates)
- Persist StoryFavoriteCache to UserDefaults so favorites survive app
restarts, not just the current session
- Prefetch comment previews for all stories in the current author's
set, and for the next author on swipe (likes stay local per user's
request)
- Left-align the comment preview pill with a trailing Spacer
- Clean up toggleStoryFavorite rollback paths: restore prevViewer on
error, handle nil response.uri gracefully
The server doesn't implement social.grain.unspecced.getStoryThread —
verified via curl. But getGalleryThread already queries by subject URI
regardless of whether the subject is a gallery or a story, so the
client can use it for both. Switched StoryCommentsViewModel to call
getGalleryThread(gallery: storyUri) and deleted the orphaned
getStoryThread endpoint.
Simplified StoryFavoriteCache to a cleaner bool-oriented API
(isLiked/like/unlike). Key fix: isFavorited in StoryViewer now reads
from BOTH the story's viewer state AND the cache, so reopened stories
still show as liked even when the server returns no viewer state.
Also guards against nil response.uri on create so we don't accidentally
clear the cache entry on a successful request.
Adds mock stories (with real bundle images for Kyoto/Oregon/Portland),
story-specific comment fixtures (including a threaded reply), and
avatars on the secondary preview profiles so they render with content
instead of gray circles.
Also wires the StoryViewer preview to inject initialStories so it
displays something on first load, and adds a populated StoryCommentSheet
preview showing the sheet at medium detent with mock comments visible.
Sheet collapse bug: tap zones overlapped with the comment input bar,
letting taps leak through to goToNext() which called advanceStory and
restarted the timer. Added an 80pt bottom inset to the tap zones so
they don't cover the comment bar, and scoped allowsHitTesting correctly
back onto the tap zones VStack (not the hearts ForEach). Added
.buttonStyle(.plain) + explicit contentShape on each input bar button
so Swift reliably routes taps to them.
Favorite persistence bug: story viewer state wasn't surviving a close +
reopen because the client re-fetches stories and the server doesn't
yet return viewer state for stories. Added StoryFavoriteCache —
session-scoped storage of storyUri → favUri that toggleStoryFavorite
writes to and presentStories overlays onto fetched stories.
Both gallery and story code duplicated the record-building logic for
comment/favorite creation and the rkey extraction for deletion. Pull
these into stateless service enums so callers only handle their own
optimistic state updates.
Subtle differences (favCount tracking on galleries, viewer state types,
UI layouts, thread endpoint names) stay at call sites to keep gallery
and story concerns independent.
- Instagram-style floating comment bar at bottom (bubble icon, "Add a
comment..." placeholder, heart button)
- Latest comment preview above the bar, tappable to open comment list
- Comment sheet opens at medium detent, expandable to large
- Two sheet modes: input-focused (keyboard) vs list-browsing
- Double-tap anywhere to like with heart ripple animation
- Heart button with particle burst and optimistic favorite toggle
- Timer pauses when sheet opens, resumes on dismiss
- Story navigation (tap/swipe) blocked while sheet is open
- Comment preview loaded per-story via cache-first switchToStory
* Support local UIImage in ZoomableImage / ImageZoomState
Adds a Source enum to ZoomableImage with .url and .local cases plus a
matching ImageZoomOverlay branch that renders an Image(uiImage:) when
the state holds a local image. The legacy URL initializer is preserved
so existing feed call sites are unchanged. LocalZoomableViewer is
removed; the create gallery preview now uses the same zoom infrastructure
as the feed.
* Push CreateGalleryView instead of presenting as a sheet
Switches the create flow from a modal sheet to a NavigationStack push
inside the feed tab. The push lives on FeedView via .navigationDestination
(isPresented:), and CreateGalleryView no longer wraps its body in its own
NavigationStack — title and trailing toolbar item attach to the parent
stack. Removes the Cancel toolbar button since the iOS edge-swipe back
handles dismissal natively. MainTabView drops the .sheet wiring.
Also pulls in the location detection changes that came along with this
restructure: detectLocation() now reads items.first only and re-runs on
photoItems.first?.id changes, plus the "Use first photo location"
copy update.
* feat: photo editor strip + grid reorder with UIKit gestures
- PhotoEditor with inline Form sections, carousel, tap-to-preview alt overlay
- ReorderablePhotoStrip with custom long-press+drag gesture, auto-scroll,
live sibling reorder preview
- ReorderablePhotoGrid with 2D drag math and row-boundary wraps
- PhotoThumbnailCell extracted as shared cell with outside-corner X button
- Uniform grid slot sizing driven by most-portrait photo
- UIKit gesture recognizers replace SwiftUI gestures for strip and grid
* feat: high-res preview cache for post preview carousel
The carousel was rendering item.thumbnail (downsized to 150pt for the
strip/grid) and looked blurry full-screen. PhotoEditor now keeps a small
LRU cache of preview-sized images (1500pt max) loaded on demand whenever
selectedPhotoID changes.
The cache is bounded by previewCacheLimit (5 entries) so peak memory stays
sane even on the maxSelectionCount=20 ceiling — at ~5MB per decoded preview
that's a worst case of ~25MB on top of existing app memory. Picker items
go through PhotosPickerItem.loadTransferable; camera items reuse the
UIImage already in PhotoSource.camera. Fallback during load is the
existing 150pt thumbnail so the carousel is never blank.
* fix: editor polish — grid bindings, pickup animation, matched-geometry prep
- Wire simultaneous UIKit gesture recognition via delegate
- Replace bindingFor with ForEach($items) safe per-id bindings
- Pickup animation scales + fades cell on drag start
- Lock parent form scroll during cell pickup
- Matched-geometry namespace prep for strip↔grid transition
- Grid cells use photo's natural aspect ratio
- Lock back-swipe and strip scroll while reordering
* fix: zoom overlay tracks gesture session, not image identity
ZoomableImage's isCurrentlyZoomed used `zoomState.localImage === image` for
.local sources, which fails when the rendered UIImage instance changes during
the same view lifetime — e.g. PhotoEditor swapping a 150pt thumbnail for a
1500pt preview as its cache fills. The base image stayed visible behind the
overlay because the identity check couldn't resolve.
Replaces it with a per-instance @State flag flipped on in onBegan and off in
resetZoom, so 'this view is currently the one being zoomed' tracks the gesture
session rather than the photo bytes. URL sources still compare by URL string
since those don't churn.
Also adds .failed to handlePinch / handlePan switch cases so an interrupted
pinch or pan (third finger lands, sibling recognizer claims the gesture)
still routes through onEnded — without it the zoom state could freeze and
the snap-back never fired.
* feat: remove reorder from strip, rename grid as "Reorder" mode
Per HIG modality guidance, reorder is now a distinct MODE rather than a
gesture available everywhere. Gesture-wise, reorder lives exclusively in
the grid; entering / exiting the grid is a clear mode transition with
text affordances (not icon-only).
Strip changes (Grain/Views/Create/ReorderablePhotoStrip.swift →
PhotoStrip.swift):
- Deleted: ReorderRecognizer gesture, handleReorder / beginDrag /
handleDragChanged / autoScrollIfNeeded / handleDragEnded / resetDragState,
xOffset live-preview math, ScrollPanLocker, all drag state
(draggedID / dragStartIndex / dragCurrentIndex / dragOffsetX /
scrollOffsetX / lastScrollOffsetX / viewportWidth / lastAutoScrollAt),
onScrollGeometryChange delta compensation, isReordering binding.
- Kept: tap-to-select, delete, wallet-remove transition, scrollTo the
selected photo on selection change.
- Struct renamed ReorderablePhotoStrip → PhotoStrip to reflect the new
read-only role. The file size drops from ~350 lines to ~115.
PhotoEditor header changes:
- Section title swaps "Photos" ↔ "Reorder" on the isExpanded toggle.
- Trailing button is now a text affordance, not an icon: "Reorder"
(semibold) to enter, "Done" (bold, accent) to exit. Same visual
pattern as Apple Photos' Select mode.
- Accessibility label updated to "Enter reorder mode" / "Exit reorder
mode" so VoiceOver conveys the modal nature.
- Strip no longer takes isReordering — PhotoEditor drops that argument
at the PhotoStrip callsite. Grid still takes it (its reorder gesture
still needs to drive scroll lock + back-swipe lock).
* refactor: replace Mode enum with CellGeometry, extract ExifSettingsRow
PhotoThumbnailCell now receives a CellGeometry bundle (photoSize, maskSide,
maskCornerRadius) instead of a Mode enum, preventing callers from passing
inconsistent values and centralising layout math in the parent. Adds
MatchedPhotoModifier for the strip↔grid transition.
ExifInfoView extracts ExifSettingsRow as a standalone component with stable
max-width proxies and a spring animation on value changes.
* fix: EXIF view slides under carousel instead of snapping on selection change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: replace EditorMode binary enum with 3-case CaseIterable
* feat: add captions mode with inline alt-text list
* fix: isAnimatingMode safety net, carousel gating, and transition cleanup
* fix: gallery photo editor — selection ring, captions crash, grid layout
- Replace matched-geometry selection ring with per-cell SelectionBorderShape
trim animation; border draws from X-button corner, easeInOut curve avoids
spring overshoot on bounded trim values, drawingGroup() offloads path
re-computation to Metal each frame
- Add ExifChip component for inline EXIF display in cells
- Fix captions↔strip/grid crash: withAnimation(.smooth) on a Form row height
change (short strip → tall captions list) drove UICollectionViewCompositional-
Layout into recursive invalidation (depth 100 assertion); captions transitions
are now instant
- Fix reorder grid photos stuck at origin: containerWidth sentinel was 0 so
cellSide = 0 on first render; initialise from UIScreen.main.bounds.width and
suppress animation on geometry corrections; accept containerWidth from parent
so the value is known before the grid mounts
- Strip scroll stutter: two concurrent withAnimation(.snappy) contexts (onTap +
onChange) competed on the same layout; onTap now sets selectedPhotoID without
animation (value-based ring animation handles the ring, onChange handles scroll)
- Simplify carousel gate from complex opacity/isAnimatingMode logic to plain
mode == .preview with .transition(.opacity)
* refactor: centralize aspect ratio as PhotoItem.naturalAspect
Add a single naturalAspect computed property to PhotoItem (w/h of thumbnail)
as the authoritative source for cell aspect geometry. Also replaces
UIScreen.main.bounds.width with hardcoded 393 in preview data so Xcode
previews don't depend on the host screen at render time.
* feat: captions list with unified thumbnails
* instrument: add OSSignposter tracing for photo loading and morph animation
Adds signpost intervals and events across the gallery create flow so
Instruments (os_signpost) can show timing for:
- Photo picker batch loading and per-photo load/thumb/decompress stages
- Hi-res preview prefetch window and per-image load/decode pipeline
- Strip↔grid morph animation start, completion-handler path, and safety-net path
- Strip scroll events: tap, post-morph deferred scroll, carousel fallback, delete
- MatchedPhotoModifier applied vs. passthrough (for debugging geometry capture)
* chore: support SIM_UDID targeting in just sim recipe
Add SIM_UDID to .env.example and update the sim recipe to target that
specific simulator when the env var is set and the device is booted.
Falls back to 'booted' when unset, preserving existing behaviour.
* fix: captions polish — EXIF badge, ExifChip cleanup, morph diagnostics
- Detach preview image loading from main actor to eliminate scroll hangs
- Simplify ExifChip API — remove compact and cameraName params
- Add compact EXIF badge to captions list thumbnail overlay
- Fix signposter closure captures for strict concurrency
- Pre-seed strip scroll from selectedPhotoID
- Clean up captions mode UI polish
* refactor: replace UIScrollView strip with pure @State offset + drag gesture
PhotoStrip now positions its HStack via @State baseOffset + @GestureState
dragTranslation instead of a ScrollView-backed UIScrollView. This means
layout settles in one synchronous SwiftUI pass — matched-geometry captures
destination frames at their final resting positions on the first render,
eliminating the old two-phase morph-then-scroll artifact.
- Strip: HStack with offset(), DragGesture for free-scroll + velocity snap
- Editor: drop the 80ms pre-delay workaround; both transition directions
are now symmetric (no UIScrollView async catchup to wait for)
- ThumbnailCell: swap selection ring overlay for accent glow shadows + scale
* feat(captions): widen caption TextField with reliable mode morphs
- Widen caption TextField for comfortable editing
- Restructure mode conditionals and dynamic isMatchedSource
for reliable captions morphs across strip ↔ reorder ↔ captions
* fix: update test to match 3-feed defaults (recent, following, foryou)
* refactor: replace PhotoStrip/ReorderablePhotoGrid with AdaptivePhotoLayout
Split drag state into ReorderDragState, scroll state into StripScrollState,
and replaced the two monolithic views with AdaptivePhotoLayout. Simplified
PhotoThumbnailCell, removed matchedNamespace from CaptionsListPrototype,
and stripped verbose inline docs from PhotoEditor/PreviewCacheStore.
* fix: prevent simultaneous H+V gesture recognition in strip pan
* fix: strip clip, xmark sizing, and captions divider polish
* feat: present CreateGallery as sheet with discard-changes guard
* fix: block form scroll from touch-down through end of reorder drag
* fix: block sheet swipe-dismiss when gallery has unsaved changes
* fix: use alert for discard confirmation, tint X red when dirty
* swift build
* drag animation problems
* Update DEVELOPMENT.md
* Chnage discard alert copy
* fix drag
* Rename PhotoEditor to GalleryEditor
Renames the struct, test class, and source files; updates all call
sites and comments throughout the codebase; regenerates xcodeproj.
* feat: improve photo editor UX and fix picker sync
- Replace scale+glow thumbnail selection with border+dim approach
- Fix tap target issues with .contentShape(Rectangle())
- Sync photo picker selection when removing photos via editor X button
(add .continuousAndOrdered + .shared() to PhotosPicker)
- Add dedup guard to prevent duplicate photos from rapid picker events
- Make ExifChip square to match ALT pill styling
- Use adaptive background for editor (light/dark mode support)
- Change "Post Preview" header to "Preview"
- Add always-visible Bluesky cross-post footer note
- Refresh feed after gallery creation via feedRefreshID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chad Miller <chadtmiller15@gmail.com>
- Extract comments from GalleryDetailView into standalone CommentSheetView
- Add onCommentTap to GalleryCardView for direct sheet presentation
- Feed, Search, Hashtag, Location, Camera views open comment sheet directly
- GalleryDetailView (from profile) shows "View all X comments" button
- Comment input uses GlassEffectContainer with glass capsule text field
and glass circle send button, matching iMessage composer style
- Input floats via safeAreaInset with no backdrop
- Sheet starts at medium detent, expandable to large
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Block/mute actions on profile overflow menu with optimistic updates
- Muted comments shown collapsed with tap to expand
- Settings > Moderation > Blocked Users / Muted Users management
- Block OAuth scope added to auth request
- Reauth alert when block fails due to missing scope
- Block URI stored after creation for correct unblock
- Mute option hidden when user is blocked
- Profile content hidden when either party blocks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add FeedsManagementView with pin/unpin, drag-to-reorder, and
camera/location browsing sections
- Add CameraFeedView for previewing camera feeds before pinning
- Add "My Feeds" entry to feed switcher menu
- Add For You to default pinned feeds
- Add reorderFeeds to FeedPreferencesViewModel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract the cache-check logic from StoryViewer into a module-internal
free function storyFullsizeCached(_:in:) that accepts an injectable
ImagePipeline, making it testable without a SwiftUI view or shared state.
Add StoryImageCacheTests covering nil story, invalid URL, cache miss,
cache hit, and pipeline isolation. Also fix project.yml so the test
target uses stable PRODUCT_MODULE_NAME/PRODUCT_NAME settings and a
BUNDLE_NAME-aware TEST_HOST, which was previously broken.
Swiping to a pane that had already been viewed would flash blur + spinner
over the image: imageLoaded was unconditionally reset in advanceStory and
presentStories, causing a render frame where cachedFullsize appeared nil
before the synchronous cache lookup ran.
Now both sites check Nuke's memory cache for the target story before
resetting — if the fullsize is already there, imageLoaded stays true and
the timer starts immediately with no intermediate loading state.
When returning from background after 5+ minutes, onChange(scenePhase)
fired loadInitial which set isLoading=true. If that request hung on a
stale socket, pull-to-refresh hit the guard and silently returned.
Now loadInitial cancels any in-flight load before starting a new one.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>