iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: photo editor grid, image prefetching, and story strip improvements (#4)

* feat: use grain typeahead API for login autocomplete

Switch from bsky's public searchActorsTypeahead to grain's merged
endpoint that includes both grain and bsky profiles, so users on
non-bsky PDSes show up in login suggestions.



* feat: reorganize just recipes with sim/sim-local/test commands

* chore: add VSCode extensions config

* test: add DeepLink and LabelResolution unit tests

Add 27 new tests covering DeepLink URL parsing (grain:// and https://
schemes, invalid URLs, galleryUri property) and LabelResolution logic
(fallback definitions, server overrides, severity ordering, worst-wins).

Also fix GrainTests target to generate Info.plist automatically.

* test: add DateFormatting, FacetFeature coding, and AnyCodable tests

- DateFormattingTests: parse with/without fractional seconds, invalid
strings, relativeTime for minutes/hours/days/weeks
- FacetCodingTests: decode mention/link/tag variants, unknown type
throws, round-trip encoding, full Facet with ByteSlice
- AnyCodableTests: init from each type, JSON decode/encode round-trips,
dictValue property, nested structures, null fallback

* test: add PhotoModels, FeedEndpoint model, and StoryStatusCache tests

- PhotoModelsTests: cameraName, lensName, settingsLine formatting,
hasDisplayableData with various EXIF field combinations
- FeedEndpointModelTests: PinnedFeed feedName/feedValue computed
properties, defaults array, colon-separated value extraction
- StoryStatusCacheTests: update/replace cache, hasStory lookups,
author retrieval, didsWithStories set

* test: add ViewModel tests with MockURLProtocol infrastructure

- MockURLProtocol: URLProtocol subclass for intercepting network
requests with configurable JSON/error responses
- NotificationsViewModelTests: loadInitial, markAsSeen optimistic
update + rollback, pagination
- ProfileDetailViewModelTests: follow/unfollow optimistic updates,
rollback on failure, follower count clamping, guard clauses

* feat: add xcbeautify, SwiftFormat, and SwiftLint tooling

Pipe all xcodebuild commands through xcbeautify for formatted output
with test pass/fail counts. Add format, format-fix, lint, and lint-fix
just recipes.

* chore: reset story view on every app load for local builds

* chore: format only staged files in pre-commit hook

* test uses prod api

* fix: pre-commit hook stages only formatted files, not all modified

* test: add ViewedStoryStorage tests and fix date test edge cases

Cover markViewed, isViewed, hasViewedAll, and firstUnviewedIndex
with 14 unit tests. Fix flaky date formatting tests that used
exact boundary values.

* feat: own story always shows unread (colored) ring

Force the story ring to display as unviewed for the current user
across all views: GalleryCardView, GalleryDetailView,
NotificationsView, FollowListView, ProfileView, and SearchView.
Pass userDID as a prop to NotificationRow and CommentRow since
they lack AuthManager in their environment.

* feat: story viewer unread mode, animations, and strip overhaul

Unread-only mode:
- Auto-detect from starting author's viewed state (colored ring = unread mode)
- Start at first unread story, navigate only to authors with unreads
- Own story excluded from navigation queue, only viewable via "Your story"
- Own story always starts at index 0 (all stories treated as unread)
- Mark stories as viewed at 25% progress instead of 50%

Story strip:
- "Your story" shows colored ring when user has stories, tap to view
- Long press "Your story" to create when stories exist
- Own story filtered from regular author list
- Deferred sort: re-sort only on viewer dismiss with animated reorder
- Wave lift effect radiates from gap where read card departed
- Read cards slide under unreads via zIndex layering

Animations:
- Drag-to-dismiss throws story downward with velocity-based duration
- Author transitions: directional shrink + slide + grow on all switches
- Strip invalidation triggers ring refresh without pull-to-refresh

Refactoring:
- Extract startTimerIfSafe, canNavigate, presentStories, findAuthorIndex
- Remove unused startIndex init parameter
- Simplify authorHasUnreads (own-DID check was dead code)

* feat: prefetch carousel images with Nuke ImagePrefetcher

Preloads fullsize URLs for the first two images on appear and
the next two ahead of the current page on swipe, cancelling
in-flight requests when the card disappears.

* Update .gitignore

* feat: add blur-up thumb placeholders for images

Show a blurred thumbnail as placeholder while fullsize loads,
replacing the gray rectangle. Applied to gallery carousel and
story viewer.

* feat: tiered carousel prefetch with priority levels

Replace simple next-image prefetch with engagement-gated strategy:
page 0 prefetches first 2 fullsize, page 1 adds thumbs, page 2+
fetches all remaining. Uses Nuke ImageRequest priority levels.

* feat: add feed-level image prefetch and prefetch unit tests

Prefetch first image of next 3 galleries as user scrolls the feed.
Trigger loadMore 5 items from end instead of at the last item.
Add 19 unit tests for carousel, feed, and story prefetch planning.

* feat: add story image prefetching with priority queue

Prefetch upcoming story images based on engagement depth:
next 2 current author stories at high priority, first of next
author at high, rest of stack at normal, and look-ahead authors
at low priority.

* feat: add image cache size and clear button in settings

Show current Nuke disk cache size in human-readable format
and provide a clear button that wipes both memory and disk cache.

* chore: don't track generated code, default build to production API

Remove project.pbxproj from git (managed by xcodegen). Update
justfile so just build uses PRODUCTION_API by default, add
separate build-local for dev API builds.

Save and close the editor to continue.

* test: add priority correctness, boundary, and dedup tests

Verify priority levels are set correctly per tier, story prefetch
at last index still gets next author, and no duplicate URLs.

* fix: set visible images to veryHigh priority over prefetch

LazyImage was loading at default .normal priority while prefetch
requests fired at .high, starving the currently visible image.
Now visible images use .veryHigh so they always load first.

* feat: pause story timer until image loads, tap location chip to copy

Timer no longer starts until the full-resolution image has loaded.
A spinner overlays the blurred thumbnail during loading. The label
reveal callback also gates on image load.

Location chip: tap copies location name to clipboard with a brief
checkmark animation. Resets instantly on story/author change via
.id(story.uri) to avoid a lingering reverse animation.

* chore: add privacy manifest and photo library permission

- Add PrivacyInfo.xcprivacy declaring UserDefaults API access and
collected data types (photos, location, user content, name)
- Add NSPhotoLibraryUsageDescription to Info.plist and project.yml
to cover photo picker usage in gallery/story creation

* feat: add search spinner and refactor legal text in login view

- Show ProgressView while searching for handle suggestions
- Extract legal markdown to static constant, render via LocalizedStringKey

* feat: move Edit Profile button to profile page, remove from Settings

Self-view profile now shows [Edit Profile] [Germ DM] matching the
layout of the follow/DM row shown for other users. Saving refreshes
the profile in place. Removed the redundant Settings entry per HIG
guidance on keeping infrequently-changed options in settings.

* chore: restore story viewer status bar and remove local networking flag

- Remove .statusBarHidden() from StoryViewer so system status bar
remains visible during story playback (HIG compliance)
- Remove NSAllowsLocalNetworking from Info.plist and project.yml

* feat: add Privacy section to Settings with location, camera data, and suggested users toggles

* feat: add use-photo-location button to story creation

* feat: show EXIF per photo inline with thumbnail, add per-upload camera data toggle

* fix: skip wave animation when story strip order is unchanged

* feat: show progress bars and header overlay while stories are loading

* feat: tap own avatar to create story, long-press as shortcut, show plus badge

* feat: add LocalZoomableViewer, ReorderablePhotoGrid, and shared drag-to-reorder modifier

* feat: add PhotoEditor section, upgrade strip with selection and lift physics

* refactor: reorganize create gallery form — UUID selection, merged gallery section, section reorder

* test: add PhotoEditor tests for grid target index calculation and selection stability

* fix: keep create form scrollable when interacting with photo viewer and strip

* feat: split EXIF into fields and render two-row layout with focal/aperture right-aligned

Add alt text count row to gallery section.

* feat: add alt text indicator on grid thumbnails, use simultaneousGesture for tap

* chore: make team ID configurable via env var, add dev setup docs

- Load APPLE_TEAM_ID from .env (via dotenv-load in justfile)
- Extract sim_sign and team_id variables in justfile to reduce repetition
- Update DEVELOPMENT_TEAM in project.yml to 54P9BCDR92 with Automatic signing
- Add .env.example with instructions for contributors
- Add docs/development.md with full setup and build reference
- Add Grain-Debug.entitlements (empty, required for debug signing)
- Gitignore .env

* chore: add LFS post-checkout, post-commit, post-merge hooks

* Update GalleryCardView.swift

* fix: story viewer swipe-back in reads mode, drop phantom h3ToCity reference

* feat: add App Shortcuts via App Intents for Siri/Spotlight

Exposes 5 shortcuts discoverable at install time: Open Feed,
Search, Open Notifications, Open My Profile, Create Gallery.
Routing uses NotificationCenter so openAppWhenRun timing is reliable.

* perf: skip network tasks in Xcode preview canvas

Adds isPreview guard to all initial-load .task blocks so previews
render instantly instead of hanging on network timeouts.

* fix: use static let for openAppWhenRun to silence concurrency warning

static var on a global is nonisolated mutable state; static let
satisfies the same AppIntent protocol requirement without the warning.

Also adds #Preview to AvatarView, ExpandableDescriptionView, RichTextView.

* feat: add #Preview to all Views

Covers all 35 view files — components, create flow, feed, gallery,
notifications, profile, search, settings, and stories. Screen-level
views show loading/empty state; component views use inline sample data.

* fix: move GalleryCardView preview gallery init to top-level helper

@Previewable can't handle a multi-line inline initializer; calling a
private function satisfies the macro's single-expression requirement.

* feat: photo editor drag z-index, weighted animations, strip auto-scroll

- Dragged thumbnail renders on top via .zIndex(1) in both grid and strip
- Reorder spring response 0.28→0.45, damping 0.65→0.7 for heavier feel
- ReorderablePhotoStrip wraps in ScrollViewReader; scrolls to selected
photo with .easeInOut(0.3) whenever selectedPhotoID changes

* fix: show ProgressView in GalleryDetailView and ProfileView previews

When isPreview guard skips loading, viewModel data is nil and isLoading
stays false — the content gate renders nothing. Changed to an else
fallback so the canvas shows a spinner instead of a white screen.

* fix: use overlay for dragged grid cell to guarantee top z-index

LazyVGrid doesn't reliably respect .zIndex between sibling cells.
Render dragged item as a grid overlay instead — positioned by
computing its natural cell origin (col/row * step) + drag offset.
The in-grid copy is hidden (opacity 0) to preserve layout space.

Also updates PhotoEditor preview to 9 colored rects with first
item selected via .onAppear.

* feat: smooth grid reorder via matchedGeometryEffect

Uses matchedGeometryEffect to track each cell's frame in a shared
namespace. When items.move() is called, SwiftUI interpolates frames
smoothly between old and new positions — same mechanism as
UICollectionView move animations. Bump displacement spring to
response:0.35 dampingFraction:0.88 for a clean glide.

* chore: update PhotoEditor preview — 15 photos, icon, dark mode

15 colored rect thumbnails with germ-logo as first item (falls back
to color if asset missing). Dark mode color scheme by default.

* fix: move alt text bubble to bottom-right, mirror icon; drop germ-logo from preview

* fix: inject LabelDefinitionsCache into CreateGalleryView preview

* feat: full-width photo strip and grid in gallery create form

Remove list row insets from the photo strip and grid rows so both span
edge-to-edge. Strip content gets 16pt horizontal padding so thumbnails
don't start flush against the screen edge. Also consolidates preview
helpers to use PreviewData and fixes a MainActor isolation error in the
CreateGalleryView preview.

* feat: rich preview content across all major views

- Add PreviewData.swift with shared fake profiles, galleries, photos,
comments, and gradient UIImage generation
- ProfileView: inject mock profile + galleries when isPreview
- GalleryDetailView: inject mock gallery + comments when isPreview
- GalleryCardView: show two gallery cards with real fake metadata
- CreateGalleryView: preview wrapper shows form + PhotoEditor with 15
gradient photo items pre-populated
- PhotoEditor/Grid/Strip: use PreviewData.photoItems (gradient thumbs
with some alt text) instead of solid-color rects; dark mode default

* feat: enrich story strip, notifications, and story viewer previews

- PreviewData: add storyAuthors (5 users) and notifications (5 items
covering favorite, comment, follow, and mention reasons)
- StoryStripView: show 5 story authors instead of 1, with own userDid
- NotificationsView: pre-populate ViewModel with mock notifications
and unseenCount=3 so the badge and list are both visible
- StoryViewer: use PreviewData.storyAuthors instead of bare placeholder

* chore: configure DEVELOPMENT_ASSET_PATHS for Preview Content folder

* feat: load stock photos into PreviewData with file:// URLs

* feat: inject mock galleries into Feed/Hashtag/Location view previews

* chore: add stock photos to Preview Content (development assets only)

* chore: remove unassigned legacy app icon files

Delete 57.png and 114.png from AppIcon.appiconset — these pre-iOS 7 icon sizes have no slot in Contents.json and caused an Xcode "unassigned children" warning.

* feat: smooth story transitions with native SwiftUI slide animation

Use .id(story.uri) on the image ZStack and .transition(.asymmetric) to let SwiftUI animate story swaps as proper view insertions/removals. A spring(response: 0.28, dampingFraction: 0.88) drives the transition, with direction tracked via nextStoryFromTrailing so forward/back taps slide in from the correct edge.

* chore: remove deprecated 76x76@1x iPad app icon slot

* fix: restore includeExif state vars dropped during rebase

The Edit Profile removal commit was authored against an older branch
state without the EXIF preference feature, so its rebase auto-merged
a removal of @State includeExif and hasLoadedExifPref that left the
Photos section in SettingsView and the .task block in CreateGalleryView
referencing undeclared vars. Restore the declarations so both files
compile against origin/main's EXIF feature.

* fix: mark UITabBar badge appearance setup as @MainActor

Swift 6 strict concurrency flags UITabBarItemAppearance.normal/selected
as main-actor isolated, so mutating their badge properties from the
nonisolated static let initializer closure was an error. Annotating
the static let @MainActor is safe because it is only read from
MainTabView.body, which is already isolated to the main actor.

* Update MainTabView.swift

---------

Co-authored-by: Chad Miller <chadtmiller15@gmail.com>

authored by

Hima
Chad Miller
and committed by
GitHub
89eba0e2 42de5d64

+5710 -1602
+5
.env.example
··· 1 + # Copy this file to .env and fill in your values 2 + # .env is gitignored and never committed 3 + 4 + # Apple Developer Team ID (find at developer.apple.com → Account → Membership) 5 + APPLE_TEAM_ID=XXXXXXXXXX
+1
.gitattributes
··· 2 2 *.ttf filter=lfs diff=lfs merge=lfs -text 3 3 *.svg filter=lfs diff=lfs merge=lfs -text 4 4 *.icon/** filter=lfs diff=lfs merge=lfs -text 5 + *.jpg filter=lfs diff=lfs merge=lfs -text
+3
.githooks/post-checkout
··· 1 + #!/bin/sh 2 + command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } 3 + git lfs post-checkout "$@"
+3
.githooks/post-commit
··· 1 + #!/bin/sh 2 + command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } 3 + git lfs post-commit "$@"
+3
.githooks/post-merge
··· 1 + #!/bin/sh 2 + command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } 3 + git lfs post-merge "$@"
+9
.githooks/pre-commit
··· 1 + #!/bin/sh 2 + 3 + # Only format/lint staged Swift files 4 + FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.swift') 5 + [ -z "$FILES" ] && exit 0 6 + 7 + echo "$FILES" | xargs swiftformat 2>/dev/null 8 + echo "$FILES" | xargs swiftlint lint --fix 2>/dev/null | grep -v "^Linting\|^Done linting" 9 + echo "$FILES" | xargs git add
+24
.githooks/pre-push
··· 1 + #!/bin/sh 2 + 3 + # Git LFS 4 + command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } 5 + git lfs pre-push "$@" 6 + 7 + # Collect Swift files changed in the commits being pushed 8 + EMPTY_TREE=$(git hash-object -t tree /dev/null) 9 + FILES="" 10 + while read -r local_ref local_sha remote_ref remote_sha; do 11 + [ "$local_sha" = "0000000000000000000000000000000000000000000000000000000000000000" ] && continue 12 + base=$( [ "$remote_sha" = "0000000000000000000000000000000000000000000000000000000000000000" ] && echo "$EMPTY_TREE" || echo "$remote_sha" ) 13 + FILES=$(printf "%s\n%s" "$FILES" "$(git diff --name-only --diff-filter=ACM "$base" "$local_sha" -- '*.swift')") 14 + done 15 + FILES=$(echo "$FILES" | sort -u | grep -v '^$') 16 + [ -z "$FILES" ] && exit 0 17 + 18 + FORMAT_OUT=$(echo "$FILES" | xargs swiftformat --lint 2>/dev/null | grep -v "^SwiftFormat\|^Reading\|^0/") 19 + [ -n "$FORMAT_OUT" ] && echo "$FORMAT_OUT" && echo "⚠ Run 'just format-fix' to fix." 20 + 21 + LINT_OUT=$(echo "$FILES" | xargs swiftlint lint 2>/dev/null | grep -v "^Linting\|^Done linting") 22 + [ -n "$LINT_OUT" ] && echo "$LINT_OUT" 23 + 24 + exit 0
+3
.gitignore
··· 33 33 # Icon/logo working files 34 34 icons/ 35 35 logo-options/ 36 + 37 + # Local environment variables 38 + .env
+3
.swiftformat
··· 1 + --swiftversion 6.0 2 + --disable redundantSendable 3 + --disable redundantLet
+68
.swiftlint.yml
··· 1 + disabled_rules: 2 + # Formatting — owned by SwiftFormat, not SwiftLint 3 + - line_length 4 + - trailing_comma 5 + - trailing_whitespace 6 + - vertical_whitespace 7 + - opening_brace 8 + - closing_brace 9 + - closure_end_indentation 10 + - closure_spacing 11 + - closure_parameter_position 12 + - colon 13 + - comma 14 + - comma_inheritance 15 + - function_name_whitespace 16 + - return_arrow_whitespace 17 + - statement_position 18 + - trailing_newline 19 + - trailing_semicolon 20 + - leading_whitespace 21 + - control_statement 22 + - empty_parentheses_with_trailing_closure 23 + - comment_spacing 24 + - attribute_name_spacing 25 + - attributes 26 + - collection_alignment 27 + - computed_accessors_order 28 + - empty_enum_arguments 29 + - empty_parameters 30 + - implicit_return 31 + - multiline_arguments 32 + - multiline_parameters 33 + - switch_case_alignment 34 + - void_return 35 + - multiple_closures_with_trailing_closure 36 + # Conflicts with @ViewBuilder — let _ = is required there 37 + - redundant_discardable_let 38 + # Not useful at this scale 39 + - nesting 40 + - large_tuple 41 + - file_length 42 + - function_parameter_count 43 + 44 + # SwiftUI views naturally have long bodies 45 + function_body_length: 46 + warning: 100 47 + error: 250 48 + 49 + type_body_length: 50 + warning: 400 51 + error: 600 52 + 53 + cyclomatic_complexity: 54 + warning: 15 55 + error: 25 56 + ignores_case_statements: true 57 + 58 + identifier_name: 59 + min_length: 2 60 + excluded: 61 + - id 62 + - x 63 + - y 64 + - z 65 + - i 66 + - j 67 + - k 68 + - v
+3
.zed/extensions.json
··· 1 + { 2 + "extensions": ["swift", "just"] 3 + }
+13
.zed/settings.json
··· 1 + { 2 + "languages": { 3 + "Swift": { 4 + "format_on_save": "on", 5 + "formatter": { 6 + "external": { 7 + "command": "swiftformat", 8 + "arguments": ["--stdinpath", "{buffer_path}"] 9 + } 10 + } 11 + } 12 + } 13 + }
+53 -30
Grain/API/AuthManager.swift
··· 20 20 private var client: XRPCClient? 21 21 private var refreshTask: Task<Void, Error>? 22 22 23 - #if targetEnvironment(simulator) 24 - static let serverURL = URL(string: "http://127.0.0.1:3000")! 23 + #if PRODUCTION_API || !targetEnvironment(simulator) 24 + static let serverURL = URL(string: "https://grain.social")! 25 25 #else 26 - static let serverURL = URL(string: "https://grain.social")! 26 + static let serverURL = URL(string: "http://127.0.0.1:3000")! 27 27 #endif 28 28 static let clientID = "grain-native://app" 29 29 static let redirectURI = "grain://oauth/callback" ··· 32 32 // Restore session from Keychain — allow expired tokens since we can refresh 33 33 if TokenStorage.accessToken != nil, 34 34 let did = TokenStorage.userDID, 35 - TokenStorage.refreshToken != nil { 36 - self.isAuthenticated = true 37 - self.userDID = did 38 - self.userHandle = TokenStorage.userHandle 39 - self.userAvatar = TokenStorage.userAvatar 40 - self.dpop = try? DPoP.loadOrCreate() 35 + TokenStorage.refreshToken != nil 36 + { 37 + isAuthenticated = true 38 + userDID = did 39 + userHandle = TokenStorage.userHandle 40 + userAvatar = TokenStorage.userAvatar 41 + dpop = try? DPoP.loadOrCreate() 41 42 } 42 43 } 43 44 ··· 51 52 52 53 // Generate PKCE code verifier + challenge 53 54 let verifier = generateCodeVerifier() 54 - self.codeVerifier = verifier 55 + codeVerifier = verifier 55 56 let challenge = generateCodeChallenge(verifier: verifier) 56 57 57 58 // Step 1: Pushed Authorization Request ··· 61 62 "response_type": "code", 62 63 "code_challenge": challenge, 63 64 "code_challenge_method": "S256", 64 - "scope": "atproto blob:image/* repo:social.grain.gallery repo:social.grain.gallery.item repo:social.grain.photo repo:social.grain.photo.exif repo:social.grain.actor.profile repo:social.grain.graph.follow repo:social.grain.favorite repo:social.grain.comment repo:social.grain.story repo:app.bsky.feed.post?action=create" 65 + "scope": [ 66 + "atproto", 67 + "blob:image/*", 68 + "repo:social.grain.gallery", 69 + "repo:social.grain.gallery.item", 70 + "repo:social.grain.photo", 71 + "repo:social.grain.photo.exif", 72 + "repo:social.grain.actor.profile", 73 + "repo:social.grain.graph.follow", 74 + "repo:social.grain.favorite", 75 + "repo:social.grain.comment", 76 + "repo:social.grain.story", 77 + "repo:app.bsky.feed.post?action=create", 78 + ].joined(separator: " "), 65 79 ] 66 80 if createAccount { 67 81 parBody["prompt"] = "create" 68 82 #if DEBUG 69 - parBody["login_hint"] = "localhost:2583" 83 + parBody["login_hint"] = "localhost:2583" 70 84 #else 71 - parBody["login_hint"] = "selfhosted.social" 85 + parBody["login_hint"] = "selfhosted.social" 72 86 #endif 73 87 } else if !handle.isEmpty { 74 88 parBody["login_hint"] = handle ··· 88 102 // Handle DPoP nonce requirement on PAR 89 103 if let httpResp = parHTTPResponse as? HTTPURLResponse, 90 104 httpResp.statusCode == 400, 91 - let nonce = httpResp.value(forHTTPHeaderField: "DPoP-Nonce") { 105 + let nonce = httpResp.value(forHTTPHeaderField: "DPoP-Nonce") 106 + { 92 107 let retryProof = try await dpop.createProof(httpMethod: "POST", url: parURL, nonce: nonce) 93 108 parRequest.setValue(retryProof, forHTTPHeaderField: "DPoP") 94 109 (parData, parHTTPResponse) = try await URLSession.shared.data(for: parRequest) 95 110 } 96 111 97 112 if let httpResp = parHTTPResponse as? HTTPURLResponse, 98 - !(200...299).contains(httpResp.statusCode) { 113 + !(200 ... 299).contains(httpResp.statusCode) 114 + { 99 115 throw XRPCError.httpError(statusCode: httpResp.statusCode, body: parData) 100 116 } 101 117 ··· 105 121 var authComponents = URLComponents(url: Self.serverURL.appendingPathComponent("oauth/authorize"), resolvingAgainstBaseURL: false)! 106 122 authComponents.queryItems = [ 107 123 URLQueryItem(name: "request_uri", value: parResponse.requestUri), 108 - URLQueryItem(name: "client_id", value: Self.clientID) 124 + URLQueryItem(name: "client_id", value: Self.clientID), 109 125 ] 110 126 111 127 let authURL = authComponents.url! ··· 125 141 126 142 // Step 3: Exchange code for tokens 127 143 guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), 128 - let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { 144 + let code = components.queryItems?.first(where: { $0.name == "code" })?.value 145 + else { 129 146 throw XRPCError.invalidURL 130 147 } 131 148 ··· 147 164 let task = Task { @MainActor [weak self] in 148 165 guard let self else { throw XRPCError.unauthorized } 149 166 defer { self.refreshTask = nil } 150 - try await self.performRefresh() 167 + try await performRefresh() 151 168 } 152 169 refreshTask = task 153 170 try await task.value ··· 166 183 let body: [String: String] = [ 167 184 "grant_type": "refresh_token", 168 185 "refresh_token": refreshToken, 169 - "client_id": Self.clientID 186 + "client_id": Self.clientID, 170 187 ] 171 188 request.httpBody = body.urlEncoded.data(using: .utf8) 172 189 ··· 178 195 // Handle DPoP nonce requirement 179 196 if let httpResp = response as? HTTPURLResponse, 180 197 httpResp.statusCode == 400, 181 - let nonce = httpResp.value(forHTTPHeaderField: "DPoP-Nonce") { 198 + let nonce = httpResp.value(forHTTPHeaderField: "DPoP-Nonce") 199 + { 182 200 let retryProof = try await dpop.createProof(httpMethod: "POST", url: tokenURL, nonce: nonce) 183 201 request.setValue(retryProof, forHTTPHeaderField: "DPoP") 184 202 (data, response) = try await URLSession.shared.data(for: request) ··· 188 206 throw XRPCError.unauthorized 189 207 } 190 208 191 - guard (200...299).contains(httpResponse.statusCode) else { 209 + guard (200 ... 299).contains(httpResponse.statusCode) else { 192 210 let bodyStr = String(data: data, encoding: .utf8) ?? "no body" 193 211 logger.error("Token refresh failed (\(httpResponse.statusCode)): \(bodyStr)") 194 212 if httpResponse.statusCode == 401 { ··· 247 265 "code": code, 248 266 "redirect_uri": Self.redirectURI, 249 267 "client_id": Self.clientID, 250 - "code_verifier": codeVerifier ?? "" 268 + "code_verifier": codeVerifier ?? "", 251 269 ] 252 270 request.httpBody = body.urlEncoded.data(using: .utf8) 253 271 ··· 259 277 // Handle DPoP nonce retry 260 278 if let httpResponse = response as? HTTPURLResponse, 261 279 httpResponse.statusCode == 400, 262 - let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 280 + let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") 281 + { 263 282 let retryProof = try await dpop.createProof(httpMethod: "POST", url: tokenURL, nonce: nonce) 264 283 var retryRequest = request 265 284 retryRequest.setValue(retryProof, forHTTPHeaderField: "DPoP") ··· 286 305 } 287 306 288 307 func fetchAvatarIfNeeded() async { 289 - if userAvatar != nil && avatarImage == nil { 308 + if userAvatar != nil, avatarImage == nil { 290 309 await downloadAvatarImage() 291 310 } 292 - if userAvatar == nil && userDID != nil { 311 + if userAvatar == nil, userDID != nil { 293 312 await fetchAndStoreAvatar() 294 313 } 295 314 } ··· 372 391 final class WebAuthContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { 373 392 static let shared = WebAuthContextProvider() 374 393 375 - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 376 - UIApplication.shared.connectedScenes 394 + func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { 395 + let scene = UIApplication.shared.connectedScenes 377 396 .compactMap { $0 as? UIWindowScene } 378 - .flatMap(\.windows) 379 - .first(where: \.isKeyWindow) ?? ASPresentationAnchor() 397 + .first { $0.activationState == .foregroundActive } 398 + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first 399 + if let scene { 400 + return ASPresentationAnchor(windowScene: scene) 401 + } 402 + preconditionFailure("No window scene available for ASPresentationAnchor") 380 403 } 381 404 } 382 405 383 406 // MARK: - Helpers 384 407 385 - extension Dictionary where Key == String, Value == String { 408 + extension [String: String] { 386 409 var urlEncoded: String { 387 410 map { key, value in 388 411 let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key
+13 -13
Grain/API/DPoP.swift
··· 1 - import Foundation 2 1 import CryptoKit 2 + import Foundation 3 3 import Security 4 4 5 5 /// DPoP (Demonstration of Proof-of-Possession) proof generator using ES256. ··· 12 12 self.privateKey = privateKey 13 13 let publicKey = privateKey.publicKey 14 14 let rawRepresentation = publicKey.rawRepresentation 15 - let x = rawRepresentation.prefix(32) 16 - let y = rawRepresentation.suffix(32) 15 + let xCoord = rawRepresentation.prefix(32) 16 + let yCoord = rawRepresentation.suffix(32) 17 17 18 - self.publicJWK = [ 18 + publicJWK = [ 19 19 "kty": "EC", 20 20 "crv": "P-256", 21 - "x": x.base64URLEncoded(), 22 - "y": y.base64URLEncoded() 21 + "x": xCoord.base64URLEncoded(), 22 + "y": yCoord.base64URLEncoded(), 23 23 ] 24 24 25 25 // JWK thumbprint (RFC 7638) — lexicographic JSON of required members 26 - let thumbprintInput = #"{"crv":"P-256","kty":"EC","x":"\#(x.base64URLEncoded())","y":"\#(y.base64URLEncoded())"}"# 26 + let thumbprintInput = #"{"crv":"P-256","kty":"EC","x":"\#(xCoord.base64URLEncoded())","y":"\#(yCoord.base64URLEncoded())"}"# 27 27 let hash = SHA256.hash(data: Data(thumbprintInput.utf8)) 28 - self.thumbprint = Data(hash).base64URLEncoded() 28 + thumbprint = Data(hash).base64URLEncoded() 29 29 } 30 30 31 31 /// Create a DPoP proof JWT. ··· 45 45 let header: [String: Any] = [ 46 46 "typ": "dpop+jwt", 47 47 "alg": "ES256", 48 - "jwk": publicJWK 48 + "jwk": publicJWK, 49 49 ] 50 50 51 51 // Payload ··· 53 53 "jti": UUID().uuidString, 54 54 "htm": httpMethod.uppercased(), 55 55 "htu": htu, 56 - "iat": Int(Date().timeIntervalSince1970) 56 + "iat": Int(Date().timeIntervalSince1970), 57 57 ] 58 58 59 59 if let accessToken { ··· 96 96 let query: [String: Any] = [ 97 97 kSecClass as String: kSecClassGenericPassword, 98 98 kSecAttrService as String: keychainService, 99 - kSecAttrAccount as String: keychainAccount 99 + kSecAttrAccount as String: keychainAccount, 100 100 ] 101 101 SecItemDelete(query as CFDictionary) 102 102 } ··· 106 106 kSecClass as String: kSecClassGenericPassword, 107 107 kSecAttrService as String: keychainService, 108 108 kSecAttrAccount as String: keychainAccount, 109 - kSecReturnData as String: true 109 + kSecReturnData as String: true, 110 110 ] 111 111 var result: AnyObject? 112 112 let status = SecItemCopyMatching(query as CFDictionary, &result) ··· 120 120 kSecAttrService as String: keychainService, 121 121 kSecAttrAccount as String: keychainAccount, 122 122 kSecValueData as String: key.rawRepresentation, 123 - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock 123 + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, 124 124 ] 125 125 let status = SecItemAdd(query as CFDictionary, nil) 126 126 guard status == errSecSuccess else {
+6 -2
Grain/API/Endpoints/DiscoveryEndpoints.swift
··· 8 8 let name: String 9 9 let h3Index: String 10 10 let galleryCount: Int 11 - var id: String { h3Index } 11 + var id: String { 12 + h3Index 13 + } 12 14 } 13 15 14 16 struct GetCamerasResponse: Codable, Sendable { ··· 18 20 struct CameraItem: Codable, Sendable, Identifiable { 19 21 let camera: String 20 22 let photoCount: Int 21 - var id: String { camera } 23 + var id: String { 24 + camera 25 + } 22 26 } 23 27 24 28 extension XRPCClient {
+4 -4
Grain/API/Endpoints/FeedEndpoints.swift
··· 1 1 import Foundation 2 2 3 - /// Response types for feed-related XRPC queries. 3 + // Response types for feed-related XRPC queries. 4 4 5 5 struct GetFeedResponse: Codable, Sendable { 6 6 var items: [GrainGallery]? ··· 117 117 } 118 118 119 119 func searchGalleries( 120 - query q: String, 120 + query queryString: String, 121 121 limit: Int = 30, 122 122 cursor: String? = nil, 123 123 fuzzy: Bool = true, 124 124 auth: AuthContext? = nil 125 125 ) async throws -> SearchGalleriesResponse { 126 - var params = ["q": q, "limit": String(limit), "fuzzy": String(fuzzy)] 126 + var params = ["q": queryString, "limit": String(limit), "fuzzy": String(fuzzy)] 127 127 if let cursor { params["cursor"] = cursor } 128 - return try await self.query("social.grain.unspecced.searchGalleries", params: params, auth: auth, as: SearchGalleriesResponse.self) 128 + return try await query("social.grain.unspecced.searchGalleries", params: params, auth: auth, as: SearchGalleriesResponse.self) 129 129 } 130 130 }
+3 -1
Grain/API/Endpoints/ModerationEndpoints.swift
··· 6 6 var blurs: String? 7 7 var defaultSetting: String? 8 8 9 - var id: String { identifier } 9 + var id: String { 10 + identifier 11 + } 10 12 11 13 var displayName: String { 12 14 locales?.first?.name ?? identifier
+16 -8
Grain/API/Endpoints/ProfileEndpoints.swift
··· 1 1 import Foundation 2 2 3 - /// Response types for profile-related XRPC queries. 3 + // Response types for profile-related XRPC queries. 4 4 5 5 struct GetFollowersResponse: Codable, Sendable { 6 6 var totalCount: Int? ··· 34 34 var description: String? 35 35 var avatar: String? 36 36 var viewer: ActorViewerState? 37 - var id: String { did } 37 + var id: String { 38 + did 39 + } 38 40 } 39 41 40 42 struct FollowingItem: Codable, Sendable, Identifiable { ··· 44 46 var description: String? 45 47 var avatar: String? 46 48 var viewer: ActorViewerState? 47 - var id: String { did } 49 + var id: String { 50 + did 51 + } 48 52 } 49 53 50 54 struct SuggestedItem: Codable, Sendable, Identifiable { ··· 54 58 var description: String? 55 59 var avatar: String? 56 60 var followersCount: Int? 57 - var id: String { did } 61 + var id: String { 62 + did 63 + } 58 64 } 59 65 60 66 struct ProfileSearchResult: Codable, Sendable, Identifiable { ··· 63 69 var displayName: String? 64 70 var description: String? 65 71 var avatar: String? 66 - var id: String { did } 72 + var id: String { 73 + did 74 + } 67 75 } 68 76 69 77 // MARK: - Convenience Extensions ··· 97 105 try await query("social.grain.unspecced.getSuggestedFollows", params: ["actor": actor, "limit": String(limit)], auth: auth, as: GetSuggestedFollowsResponse.self) 98 106 } 99 107 100 - func searchProfiles(query q: String, limit: Int = 30, cursor: String? = nil, auth: AuthContext? = nil) async throws -> SearchProfilesResponse { 101 - var params = ["q": q, "limit": String(limit)] 108 + func searchProfiles(query queryString: String, limit: Int = 30, cursor: String? = nil, auth: AuthContext? = nil) async throws -> SearchProfilesResponse { 109 + var params = ["q": queryString, "limit": String(limit)] 102 110 if let cursor { params["cursor"] = cursor } 103 - return try await self.query("social.grain.unspecced.searchProfiles", params: params, auth: auth, as: SearchProfilesResponse.self) 111 + return try await query("social.grain.unspecced.searchProfiles", params: params, auth: auth, as: SearchProfilesResponse.self) 104 112 } 105 113 }
+1 -2
Grain/API/PushManager.swift
··· 1 1 import Foundation 2 + import os 2 3 import UIKit 3 4 import UserNotifications 4 - import os 5 5 6 6 private let logger = Logger(subsystem: "social.grain.grain", category: "Push") 7 7 ··· 104 104 logger.error("Failed to register push token: \(error)") 105 105 } 106 106 } 107 - 108 107 } 109 108 110 109 private struct RegisterTokenInput: Encodable {
+7 -13
Grain/API/TokenStorage.swift
··· 2 2 @preconcurrency import KeychainAccess 3 3 4 4 /// Secure storage for OAuth tokens using Keychain. 5 - struct TokenStorage { 5 + enum TokenStorage { 6 6 private static let keychain = Keychain(service: "social.grain.oauth") 7 7 8 8 static var accessToken: String? { 9 9 get { try? keychain.get("access_token") } 10 10 set { 11 - if let newValue { try? keychain.set(newValue, key: "access_token") } 12 - else { try? keychain.remove("access_token") } 11 + if let newValue { try? keychain.set(newValue, key: "access_token") } else { try? keychain.remove("access_token") } 13 12 } 14 13 } 15 14 16 15 static var refreshToken: String? { 17 16 get { try? keychain.get("refresh_token") } 18 17 set { 19 - if let newValue { try? keychain.set(newValue, key: "refresh_token") } 20 - else { try? keychain.remove("refresh_token") } 18 + if let newValue { try? keychain.set(newValue, key: "refresh_token") } else { try? keychain.remove("refresh_token") } 21 19 } 22 20 } 23 21 24 22 static var userDID: String? { 25 23 get { try? keychain.get("user_did") } 26 24 set { 27 - if let newValue { try? keychain.set(newValue, key: "user_did") } 28 - else { try? keychain.remove("user_did") } 25 + if let newValue { try? keychain.set(newValue, key: "user_did") } else { try? keychain.remove("user_did") } 29 26 } 30 27 } 31 28 32 29 static var userHandle: String? { 33 30 get { try? keychain.get("user_handle") } 34 31 set { 35 - if let newValue { try? keychain.set(newValue, key: "user_handle") } 36 - else { try? keychain.remove("user_handle") } 32 + if let newValue { try? keychain.set(newValue, key: "user_handle") } else { try? keychain.remove("user_handle") } 37 33 } 38 34 } 39 35 ··· 44 40 return Date(timeIntervalSince1970: interval) 45 41 } 46 42 set { 47 - if let newValue { try? keychain.set(String(newValue.timeIntervalSince1970), key: "token_expires_at") } 48 - else { try? keychain.remove("token_expires_at") } 43 + if let newValue { try? keychain.set(String(newValue.timeIntervalSince1970), key: "token_expires_at") } else { try? keychain.remove("token_expires_at") } 49 44 } 50 45 } 51 46 ··· 57 52 static var userAvatar: String? { 58 53 get { try? keychain.get("user_avatar") } 59 54 set { 60 - if let newValue { try? keychain.set(newValue, key: "user_avatar") } 61 - else { try? keychain.remove("user_avatar") } 55 + if let newValue { try? keychain.set(newValue, key: "user_avatar") } else { try? keychain.remove("user_avatar") } 62 56 } 63 57 } 64 58
+17 -15
Grain/API/XRPCClient.swift
··· 13 13 var errorDescription: String? { 14 14 switch self { 15 15 case .invalidURL: "Invalid URL" 16 - case .httpError(let code, _): "HTTP error \(code)" 17 - case .decodingError(let error): "Decoding error: \(error.localizedDescription)" 16 + case let .httpError(code, _): "HTTP error \(code)" 17 + case let .decodingError(error): "Decoding error: \(error.localizedDescription)" 18 18 case .unauthorized: "Unauthorized" 19 19 case .dpopNonceRequired: "DPoP nonce required" 20 20 } ··· 32 32 init(baseURL: URL, session: URLSession = .shared, onUnauthorized: (@Sendable () async throws -> AuthContext?)? = nil) { 33 33 self.baseURL = baseURL 34 34 self.session = session 35 - self.decoder = JSONDecoder() 36 - self.encoder = JSONEncoder() 35 + decoder = JSONDecoder() 36 + encoder = JSONEncoder() 37 37 self.onUnauthorized = onUnauthorized 38 38 } 39 39 ··· 58 58 } 59 59 60 60 /// Execute an XRPC procedure (POST request). 61 - func procedure<I: Encodable, O: Decodable>( 61 + func procedure<O: Decodable>( 62 62 _ nsid: String, 63 - input: I, 63 + input: some Encodable, 64 64 auth: AuthContext? = nil, 65 65 as type: O.Type 66 66 ) async throws -> O { ··· 74 74 } 75 75 76 76 /// Execute an XRPC procedure with no response body. 77 - func procedure<I: Encodable>( 77 + func procedure( 78 78 _ nsid: String, 79 - input: I, 79 + input: some Encodable, 80 80 auth: AuthContext? = nil 81 81 ) async throws { 82 82 let url = baseURL.appendingPathComponent("xrpc/\(nsid)") ··· 116 116 117 117 do { 118 118 return try await execute(req, as: type) 119 - } catch XRPCError.dpopNonceRequired(let nonce) where retryCount < 2 { 119 + } catch let XRPCError.dpopNonceRequired(nonce) where retryCount < 2 { 120 120 logger.info("DPoP nonce required, retrying with nonce") 121 121 var updatedAuth = auth 122 122 updatedAuth?.nonce = nonce ··· 151 151 152 152 if httpResponse.statusCode == 400, 153 153 let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"), 154 - retryCount < 2 { 154 + retryCount < 2 155 + { 155 156 logger.info("DPoP nonce required (void), retrying") 156 157 var updatedAuth = auth 157 158 updatedAuth?.nonce = nonce ··· 160 161 let (_, retryResponse) = try await session.data(for: retryReq) 161 162 guard let retryHttp = retryResponse as? HTTPURLResponse else { return } 162 163 if retryHttp.statusCode == 401 { throw XRPCError.unauthorized } 163 - guard (200...299).contains(retryHttp.statusCode) else { 164 + guard (200 ... 299).contains(retryHttp.statusCode) else { 164 165 throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil) 165 166 } 166 167 return ··· 177 178 let (_, retryResponse) = try await session.data(for: retryReq) 178 179 guard let retryHttp = retryResponse as? HTTPURLResponse else { return } 179 180 if retryHttp.statusCode == 401 { throw XRPCError.unauthorized } 180 - guard (200...299).contains(retryHttp.statusCode) else { 181 + guard (200 ... 299).contains(retryHttp.statusCode) else { 181 182 throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil) 182 183 } 183 184 return ··· 197 198 throw XRPCError.unauthorized 198 199 } 199 200 200 - guard (200...299).contains(httpResponse.statusCode) else { 201 + guard (200 ... 299).contains(httpResponse.statusCode) else { 201 202 logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")") 202 203 throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data) 203 204 } ··· 211 212 212 213 // Check for DPoP nonce requirement 213 214 if httpResponse.statusCode == 400, 214 - let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 215 + let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") 216 + { 215 217 throw XRPCError.dpopNonceRequired(nonce: nonce) 216 218 } 217 219 ··· 226 228 throw XRPCError.unauthorized 227 229 } 228 230 229 - guard (200...299).contains(httpResponse.statusCode) else { 231 + guard (200 ... 299).contains(httpResponse.statusCode) else { 230 232 logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")") 231 233 throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data) 232 234 }
+16 -17
Grain/AppDelegate.swift
··· 8 8 var onNotificationTap: ((DeepLink) -> Void)? 9 9 10 10 func application( 11 - _ application: UIApplication, 12 - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil 11 + _: UIApplication, 12 + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil 13 13 ) -> Bool { 14 14 UNUserNotificationCenter.current().delegate = self 15 15 return true 16 16 } 17 17 18 18 func application( 19 - _ application: UIApplication, 19 + _: UIApplication, 20 20 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data 21 21 ) { 22 22 pushManager?.didRegisterForRemoteNotifications(deviceToken: deviceToken) 23 23 } 24 24 25 25 func application( 26 - _ application: UIApplication, 26 + _: UIApplication, 27 27 didFailToRegisterForRemoteNotificationsWithError error: Error 28 28 ) { 29 29 pushManager?.didFailToRegisterForRemoteNotifications(error: error) 30 30 } 31 31 32 - // Show notifications even when app is in foreground 32 + /// Show notifications even when app is in foreground 33 33 nonisolated func userNotificationCenter( 34 - _ center: UNUserNotificationCenter, 35 - willPresent notification: UNNotification 34 + _: UNUserNotificationCenter, 35 + willPresent _: UNNotification 36 36 ) async -> UNNotificationPresentationOptions { 37 37 [.banner, .sound] 38 38 } 39 39 40 - // Handle notification tap — route to appropriate view 40 + /// Handle notification tap — route to appropriate view 41 41 nonisolated func userNotificationCenter( 42 - _ center: UNUserNotificationCenter, 42 + _: UNUserNotificationCenter, 43 43 didReceive response: UNNotificationResponse 44 44 ) async { 45 45 let userInfo = response.notification.request.content.userInfo 46 46 guard let type = userInfo["type"] as? String else { return } 47 47 48 - let deepLink: DeepLink? 49 - switch type { 48 + let deepLink: DeepLink? = switch type { 50 49 case "gallery-favorite", "gallery-comment", "comment-reply": 51 50 if let uri = userInfo["uri"] as? String { 52 - deepLink = parseGalleryUri(uri) 51 + parseGalleryUri(uri) 53 52 } else { 54 - deepLink = nil 53 + nil 55 54 } 56 55 case "follow": 57 56 if let did = userInfo["did"] as? String { 58 - deepLink = .profile(did: did) 57 + .profile(did: did) 59 58 } else { 60 - deepLink = nil 59 + nil 61 60 } 62 61 default: 63 - deepLink = nil 62 + nil 64 63 } 65 64 66 65 if let deepLink { ··· 70 69 } 71 70 } 72 71 73 - nonisolated private func parseGalleryUri(_ uri: String) -> DeepLink? { 72 + private nonisolated func parseGalleryUri(_ uri: String) -> DeepLink? { 74 73 // at://did:plc:xxx/social.grain.gallery/rkey 75 74 let parts = uri.replacingOccurrences(of: "at://", with: "").split(separator: "/") 76 75 guard parts.count >= 3 else { return nil }
-3
Grain/Assets.xcassets/AppIcon.appiconset/114.png
··· 1 - version https://git-lfs.github.com/spec/v1 2 - oid sha256:abffa4e113c833dad910a749810b1d7e10ecc8d287093b39fbccbcb85163f9b9 3 - size 25757
-3
Grain/Assets.xcassets/AppIcon.appiconset/57.png
··· 1 - version https://git-lfs.github.com/spec/v1 2 - oid sha256:cc64e3db10bff254f40ac83311c72402336c033726b643bef250dcda9c0389e9 3 - size 6706
-3
Grain/Assets.xcassets/AppIcon.appiconset/76.png
··· 1 - version https://git-lfs.github.com/spec/v1 2 - oid sha256:61409d1cbcc77de4936712663baf2a889d7284efaa90fc25c63c03f39fc9e738 3 - size 10594
-6
Grain/Assets.xcassets/AppIcon.appiconset/Contents.json
··· 49 49 "size" : "20x20" 50 50 }, 51 51 { 52 - "filename" : "76.png", 53 - "idiom" : "ipad", 54 - "scale" : "1x", 55 - "size" : "76x76" 56 - }, 57 - { 58 52 "filename" : "152.png", 59 53 "idiom" : "ipad", 60 54 "scale" : "2x",
+8
Grain/Assets.xcassets/germ-logo.imageset/Contents.json
··· 4 4 "filename" : "germ-logo.png", 5 5 "idiom" : "universal", 6 6 "scale" : "1x" 7 + }, 8 + { 9 + "idiom" : "universal", 10 + "scale" : "2x" 11 + }, 12 + { 13 + "idiom" : "universal", 14 + "scale" : "3x" 7 15 } 8 16 ], 9 17 "info" : {
+5 -1
Grain/Assets.xcassets/login-bg.imageset/Contents.json
··· 9 9 "filename" : "login-bg@2x.jpg", 10 10 "idiom" : "universal", 11 11 "scale" : "2x" 12 + }, 13 + { 14 + "idiom" : "universal", 15 + "scale" : "3x" 12 16 } 13 17 ], 14 18 "info" : { 15 19 "author" : "xcode", 16 20 "version" : 1 17 21 } 18 - } 22 + }
Grain/Assets.xcassets/login-bg.imageset/login-bg.jpg

This is a binary file and will not be displayed.

Grain/Assets.xcassets/login-bg.imageset/login-bg@2x.jpg

This is a binary file and will not be displayed.

+4 -5
Grain/DeepLink.swift
··· 8 8 static func from(url: URL) -> DeepLink? { 9 9 // Normalize: for grain:// scheme, host is the first segment (e.g. grain://profile/did/...) 10 10 // For https, path starts with /profile/did/... 11 - var segments: [String] 12 - if url.scheme == "grain", let host = url.host { 13 - segments = [host] + url.pathComponents.filter { $0 != "/" } 11 + let segments: [String] = if url.scheme == "grain", let host = url.host { 12 + [host] + url.pathComponents.filter { $0 != "/" } 14 13 } else { 15 - segments = url.pathComponents.filter { $0 != "/" } 14 + url.pathComponents.filter { $0 != "/" } 16 15 } 17 16 18 17 guard segments.first == "profile", segments.count >= 2 else { return nil } ··· 30 29 } 31 30 32 31 var galleryUri: String? { 33 - if case .gallery(let did, let rkey) = self { 32 + if case let .gallery(did, rkey) = self { 34 33 return "at://\(did)/social.grain.gallery/\(rkey)" 35 34 } 36 35 return nil
+6
Grain/Grain-Debug.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + </dict> 6 + </plist>
+9
Grain/GrainApp.swift
··· 1 + import Nuke 1 2 import SwiftUI 2 3 3 4 @main 4 5 struct GrainApp: App { 6 + init() { 7 + var config = ImagePipeline.Configuration.withDataCache 8 + if let dataCache = try? DataCache(name: "social.grain.images") { 9 + config.dataCache = dataCache 10 + } 11 + ImagePipeline.shared = ImagePipeline(configuration: config) 12 + } 13 + 5 14 @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate 6 15 @State private var authManager = AuthManager() 7 16 @State private var pushManager = PushManager()
+2 -5
Grain/Info.plist
··· 29 29 </array> 30 30 <key>CFBundleVersion</key> 31 31 <string>$(CURRENT_PROJECT_VERSION)</string> 32 - <key>NSAppTransportSecurity</key> 33 - <dict> 34 - <key>NSAllowsLocalNetworking</key> 35 - <true/> 36 - </dict> 37 32 <key>NSCameraUsageDescription</key> 38 33 <string>Grain needs camera access to take photos for your stories and galleries.</string> 34 + <key>NSPhotoLibraryUsageDescription</key> 35 + <string>Grain needs photo library access to select photos for your galleries and stories.</string> 39 36 <key>UIAppFonts</key> 40 37 <array> 41 38 <string>Syne-Variable.ttf</string>
+3 -3
Grain/Models/Common/Facet.swift
··· 44 44 func encode(to encoder: Encoder) throws { 45 45 var container = encoder.container(keyedBy: CodingKeys.self) 46 46 switch self { 47 - case .mention(let did): 47 + case let .mention(did): 48 48 try container.encode("app.bsky.richtext.facet#mention", forKey: .type) 49 49 try container.encode(did, forKey: .did) 50 - case .link(let uri): 50 + case let .link(uri): 51 51 try container.encode("app.bsky.richtext.facet#link", forKey: .type) 52 52 try container.encode(uri, forKey: .uri) 53 - case .tag(let tag): 53 + case let .tag(tag): 54 54 try container.encode("app.bsky.richtext.facet#tag", forKey: .type) 55 55 try container.encode(tag, forKey: .tag) 56 56 }
+3 -1
Grain/Models/Views/CommentModels.swift
··· 13 13 var replyTo: String? 14 14 let createdAt: String 15 15 16 - var id: String { uri } 16 + var id: String { 17 + uri 18 + } 17 19 }
+3 -1
Grain/Models/Views/GalleryModels.swift
··· 22 22 var crossPost: CrossPostInfo? 23 23 var labelRevealed: Bool = false 24 24 25 - var id: String { uri } 25 + var id: String { 26 + uri 27 + } 26 28 27 29 private enum CodingKeys: String, CodingKey { 28 30 case uri, cid, title, description, cameras, location, address, facets, creator, record, items, favCount, commentCount, labels, createdAt, indexedAt, viewer, crossPost
+3 -1
Grain/Models/Views/NotificationModels.swift
··· 12 12 var commentText: String? 13 13 var replyToText: String? 14 14 15 - var id: String { uri } 15 + var id: String { 16 + uri 17 + } 16 18 17 19 var reasonType: NotificationReason { 18 20 NotificationReason(rawValue: reason) ?? .unknown
+7 -5
Grain/Models/Views/PhotoModels.swift
··· 11 11 var exif: GrainExif? 12 12 var gallery: PhotoGalleryState? 13 13 14 - var id: String { uri } 14 + var id: String { 15 + uri 16 + } 15 17 } 16 18 17 19 /// social.grain.photo.defs#exifView ··· 33 35 var model: String? 34 36 35 37 var cameraName: String? { 36 - let parts = [make, model].compactMap { $0 }.filter { !$0.isEmpty } 38 + let parts = [make, model].compactMap(\.self).filter { !$0.isEmpty } 37 39 return parts.isEmpty ? nil : parts.joined(separator: " ") 38 40 } 39 41 40 42 var lensName: String? { 41 43 if let lensModel, !lensModel.isEmpty { return lensModel } 42 - let parts = [lensMake, lensModel].compactMap { $0 }.filter { !$0.isEmpty } 44 + let parts = [lensMake, lensModel].compactMap(\.self).filter { !$0.isEmpty } 43 45 return parts.isEmpty ? nil : parts.joined(separator: " ") 44 46 } 45 47 ··· 48 50 focalLengthIn35mmFormat, 49 51 fNumber, 50 52 exposureTime, 51 - iSO.map { "ISO \($0)" } 52 - ].compactMap { $0 }.filter { !$0.isEmpty } 53 + iSO.map { "ISO \($0)" }, 54 + ].compactMap(\.self).filter { !$0.isEmpty } 53 55 return parts.isEmpty ? nil : parts.joined(separator: " · ") 54 56 } 55 57
+6 -2
Grain/Models/Views/ProfileModels.swift
··· 11 11 var avatar: String? 12 12 var createdAt: String? 13 13 14 - var id: String { did } 14 + var id: String { 15 + did 16 + } 15 17 } 16 18 17 19 /// social.grain.actor.defs#profileViewDetailed ··· 32 34 var labels: [ATLabel]? 33 35 var messageMe: MessageMe? 34 36 35 - var id: String { did } 37 + var id: String { 38 + did 39 + } 36 40 } 37 41 38 42 /// social.grain.actor.defs#viewerState
+10 -3
Grain/Models/Views/StoryModels.swift
··· 14 14 var labels: [ATLabel]? 15 15 var crossPost: CrossPostInfo? 16 16 17 - var id: String { uri } 18 - var storyUri: String { uri } 17 + var id: String { 18 + uri 19 + } 20 + 21 + var storyUri: String { 22 + uri 23 + } 19 24 } 20 25 21 26 extension GrainStory: StoryIdentifiable {} ··· 26 31 let storyCount: Int 27 32 let latestAt: String 28 33 29 - var id: String { profile.did } 34 + var id: String { 35 + profile.did 36 + } 30 37 }
+3
Grain/Preview Content/ACE_EMD_F40PH_Fremont_-_San_Jose.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:65ca14243646ee7b86aa424de47c5d5a3733326048e47496fbc5f9b2ccb163f5 3 + size 1822993
+3
Grain/Preview Content/C-141_Starlifter_contrail.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:3334fb8bd4fd6854f0bedfab9b4b9c491d23c301eaa781fca65a7b73b795970c 3 + size 658218
+3
Grain/Preview Content/Endeavour_after_STS-126_on_SCA_over_Mojave_from_above.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:2752b6dfdb24b18db636fc93287bc59214eba5acf7f0fda1ee531565526d2a49 3 + size 1554487
+3
Grain/Preview Content/Mount_Hood_reflected_in_Mirror_Lake,_Oregon.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:b469818c43c2286e2079a068691ac80465c97f3da7a8c3a31d2dcacc6414e1fb 3 + size 2422123
+3
Grain/Preview Content/Mt_Herschel,_Antarctica,_Jan_2006.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:ead080ff271fde2a6eb5098029f9cba37f760f95ba5d759408b23fbd1deed85c 3 + size 2238521
+3
Grain/Preview Content/Penguin_in_Antarctica_jumping_out_of_the_water.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:de73c89e5ba4cb64d3992c2522e2d577334ddc1b21128ea700cd958ed1577d50 3 + size 7787200
+3
Grain/Preview Content/Portland_Japanese_Garden_maple.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:9f18335fe4abb5aeae9c0bfff117c186a4cb0649eb6cacb4c37f18c8f76c1e87 3 + size 3789138
+3
Grain/Preview Content/Union_Bank_Tower,_Portland_(2024)-L1006272.jpg
··· 1 + version https://git-lfs.github.com/spec/v1 2 + oid sha256:1308412605368c8d2057cccec3ea5f8fdaf25f3d95202a305a69464c0d796bb8 3 + size 22806091
+73
Grain/PrivacyInfo.xcprivacy
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>NSPrivacyTracking</key> 6 + <false/> 7 + <key>NSPrivacyTrackingDomains</key> 8 + <array/> 9 + <key>NSPrivacyAccessedAPITypes</key> 10 + <array> 11 + <dict> 12 + <key>NSPrivacyAccessedAPIType</key> 13 + <string>NSPrivacyAccessedAPICategoryUserDefaults</string> 14 + <key>NSPrivacyAccessedAPITypeReasons</key> 15 + <array> 16 + <!-- CA92.1: Access info from the same app that wrote it --> 17 + <string>CA92.1</string> 18 + </array> 19 + </dict> 20 + </array> 21 + <key>NSPrivacyCollectedDataTypes</key> 22 + <array> 23 + <dict> 24 + <key>NSPrivacyCollectedDataType</key> 25 + <string>NSPrivacyCollectedDataTypePhotosOrVideos</string> 26 + <key>NSPrivacyCollectedDataTypeLinked</key> 27 + <true/> 28 + <key>NSPrivacyCollectedDataTypeTracking</key> 29 + <false/> 30 + <key>NSPrivacyCollectedDataTypePurposes</key> 31 + <array> 32 + <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> 33 + </array> 34 + </dict> 35 + <dict> 36 + <key>NSPrivacyCollectedDataType</key> 37 + <string>NSPrivacyCollectedDataTypeCoarseLocation</string> 38 + <key>NSPrivacyCollectedDataTypeLinked</key> 39 + <true/> 40 + <key>NSPrivacyCollectedDataTypeTracking</key> 41 + <false/> 42 + <key>NSPrivacyCollectedDataTypePurposes</key> 43 + <array> 44 + <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> 45 + </array> 46 + </dict> 47 + <dict> 48 + <key>NSPrivacyCollectedDataType</key> 49 + <string>NSPrivacyCollectedDataTypeOtherUserContent</string> 50 + <key>NSPrivacyCollectedDataTypeLinked</key> 51 + <true/> 52 + <key>NSPrivacyCollectedDataTypeTracking</key> 53 + <false/> 54 + <key>NSPrivacyCollectedDataTypePurposes</key> 55 + <array> 56 + <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> 57 + </array> 58 + </dict> 59 + <dict> 60 + <key>NSPrivacyCollectedDataType</key> 61 + <string>NSPrivacyCollectedDataTypeName</string> 62 + <key>NSPrivacyCollectedDataTypeLinked</key> 63 + <true/> 64 + <key>NSPrivacyCollectedDataTypeTracking</key> 65 + <false/> 66 + <key>NSPrivacyCollectedDataTypePurposes</key> 67 + <array> 68 + <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string> 69 + </array> 70 + </dict> 71 + </array> 72 + </dict> 73 + </plist>
Grain/Resources/login-bg.jpg

This is a binary file and will not be displayed.

+127
Grain/Shortcuts/AppShortcuts.swift
··· 1 + import AppIntents 2 + import Foundation 3 + 4 + // MARK: - Shortcut action routing 5 + 6 + extension Notification.Name { 7 + static let grainShortcutAction = Notification.Name("GrainShortcutAction") 8 + } 9 + 10 + enum GrainShortcutAction: String { 11 + case feed, search, notifications, profile, createGallery 12 + } 13 + 14 + // MARK: - Intents 15 + 16 + struct OpenFeedIntent: AppIntent { 17 + static let title: LocalizedStringResource = "Open Feed" 18 + static let description = IntentDescription("Browse your photo feed in Grain.") 19 + static let openAppWhenRun = true 20 + 21 + @MainActor 22 + func perform() async throws -> some IntentResult { 23 + NotificationCenter.default.post(name: .grainShortcutAction, object: GrainShortcutAction.feed.rawValue) 24 + return .result() 25 + } 26 + } 27 + 28 + struct OpenSearchIntent: AppIntent { 29 + static let title: LocalizedStringResource = "Search" 30 + static let description = IntentDescription("Search for photos, profiles, and hashtags in Grain.") 31 + static let openAppWhenRun = true 32 + 33 + @MainActor 34 + func perform() async throws -> some IntentResult { 35 + NotificationCenter.default.post(name: .grainShortcutAction, object: GrainShortcutAction.search.rawValue) 36 + return .result() 37 + } 38 + } 39 + 40 + struct OpenNotificationsIntent: AppIntent { 41 + static let title: LocalizedStringResource = "Open Notifications" 42 + static let description = IntentDescription("Check your latest activity in Grain.") 43 + static let openAppWhenRun = true 44 + 45 + @MainActor 46 + func perform() async throws -> some IntentResult { 47 + NotificationCenter.default.post(name: .grainShortcutAction, object: GrainShortcutAction.notifications.rawValue) 48 + return .result() 49 + } 50 + } 51 + 52 + struct OpenProfileIntent: AppIntent { 53 + static let title: LocalizedStringResource = "Open My Profile" 54 + static let description = IntentDescription("View your profile and galleries in Grain.") 55 + static let openAppWhenRun = true 56 + 57 + @MainActor 58 + func perform() async throws -> some IntentResult { 59 + NotificationCenter.default.post(name: .grainShortcutAction, object: GrainShortcutAction.profile.rawValue) 60 + return .result() 61 + } 62 + } 63 + 64 + struct CreateGalleryIntent: AppIntent { 65 + static let title: LocalizedStringResource = "Create Gallery" 66 + static let description = IntentDescription("Start posting a new photo gallery in Grain.") 67 + static let openAppWhenRun = true 68 + 69 + @MainActor 70 + func perform() async throws -> some IntentResult { 71 + NotificationCenter.default.post(name: .grainShortcutAction, object: GrainShortcutAction.createGallery.rawValue) 72 + return .result() 73 + } 74 + } 75 + 76 + // MARK: - Shortcuts provider 77 + 78 + struct GrainShortcuts: AppShortcutsProvider { 79 + static var appShortcuts: [AppShortcut] { 80 + AppShortcut( 81 + intent: OpenFeedIntent(), 82 + phrases: [ 83 + "Open feed in \(.applicationName)", 84 + "Show \(.applicationName) feed", 85 + ], 86 + shortTitle: "Open Feed", 87 + systemImageName: "photo.on.rectangle" 88 + ) 89 + AppShortcut( 90 + intent: OpenSearchIntent(), 91 + phrases: [ 92 + "Search in \(.applicationName)", 93 + "Open \(.applicationName) search", 94 + ], 95 + shortTitle: "Search", 96 + systemImageName: "magnifyingglass" 97 + ) 98 + AppShortcut( 99 + intent: OpenNotificationsIntent(), 100 + phrases: [ 101 + "Open \(.applicationName) notifications", 102 + "Show \(.applicationName) notifications", 103 + ], 104 + shortTitle: "Notifications", 105 + systemImageName: "bell" 106 + ) 107 + AppShortcut( 108 + intent: OpenProfileIntent(), 109 + phrases: [ 110 + "Open my \(.applicationName) profile", 111 + "Show my \(.applicationName) profile", 112 + ], 113 + shortTitle: "My Profile", 114 + systemImageName: "person" 115 + ) 116 + AppShortcut( 117 + intent: CreateGalleryIntent(), 118 + phrases: [ 119 + "Create a gallery in \(.applicationName)", 120 + "New \(.applicationName) gallery", 121 + "Post to \(.applicationName)", 122 + ], 123 + shortTitle: "Create Gallery", 124 + systemImageName: "plus.square.on.square" 125 + ) 126 + } 127 + }
+20 -20
Grain/Utilities/AnyCodable.swift
··· 16 16 17 17 init(_ value: some Sendable) { 18 18 switch value { 19 - case let b as Bool: storage = .bool(b) 20 - case let i as Int: storage = .int(i) 21 - case let d as Double: storage = .double(d) 22 - case let s as String: storage = .string(s) 23 - case let a as [AnyCodable]: storage = .array(a) 24 - case let a as [String]: 25 - storage = .array(a.map { AnyCodable($0) }) 26 - case let a as [[String: AnyCodable]]: 27 - storage = .array(a.map { AnyCodable($0) }) 28 - case let d as [String: AnyCodable]: storage = .dict(d) 29 - case let d as [String: String]: 30 - storage = .dict(d.mapValues { AnyCodable($0) }) 19 + case let boolVal as Bool: storage = .bool(boolVal) 20 + case let intVal as Int: storage = .int(intVal) 21 + case let doubleVal as Double: storage = .double(doubleVal) 22 + case let stringVal as String: storage = .string(stringVal) 23 + case let arrayVal as [AnyCodable]: storage = .array(arrayVal) 24 + case let stringArray as [String]: 25 + storage = .array(stringArray.map { AnyCodable($0) }) 26 + case let dictArray as [[String: AnyCodable]]: 27 + storage = .array(dictArray.map { AnyCodable($0) }) 28 + case let dictVal as [String: AnyCodable]: storage = .dict(dictVal) 29 + case let dictVal as [String: String]: 30 + storage = .dict(dictVal.mapValues { AnyCodable($0) }) 31 31 default: storage = .null 32 32 } 33 33 } ··· 54 54 } 55 55 56 56 var dictValue: [String: AnyCodable]? { 57 - if case .dict(let d) = storage { return d } 57 + if case let .dict(dictVal) = storage { return dictVal } 58 58 return nil 59 59 } 60 60 61 61 var stringValue: String? { 62 - if case .string(let s) = storage { return s } 62 + if case let .string(s) = storage { return s } 63 63 return nil 64 64 } 65 65 ··· 67 67 var container = encoder.singleValueContainer() 68 68 switch storage { 69 69 case .null: try container.encodeNil() 70 - case .bool(let v): try container.encode(v) 71 - case .int(let v): try container.encode(v) 72 - case .double(let v): try container.encode(v) 73 - case .string(let v): try container.encode(v) 74 - case .array(let v): try container.encode(v) 75 - case .dict(let v): try container.encode(v) 70 + case let .bool(val): try container.encode(val) 71 + case let .int(val): try container.encode(val) 72 + case let .double(val): try container.encode(val) 73 + case let .string(val): try container.encode(val) 74 + case let .array(val): try container.encode(val) 75 + case let .dict(val): try container.encode(val) 76 76 } 77 77 } 78 78 }
+5 -5
Grain/Utilities/DateFormatting.swift
··· 3 3 enum DateFormatting { 4 4 /// Produce an ISO 8601 string with fractional seconds (matches JS `toISOString()`). 5 5 static func nowISO() -> String { 6 - let f = ISO8601DateFormatter() 7 - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 8 - return f.string(from: Date()) 6 + let formatter = ISO8601DateFormatter() 7 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 8 + return formatter.string(from: Date()) 9 9 } 10 10 11 11 /// Parse an ISO 8601 string with or without fractional seconds. ··· 22 22 if interval < 60 { return "now" } 23 23 if interval < 3600 { return "\(Int(interval / 60))m" } 24 24 if interval < 86400 { return "\(Int(interval / 3600))h" } 25 - if interval < 604800 { return "\(Int(interval / 86400))d" } 26 - if interval < 2_592_000 { return "\(Int(interval / 604800))w" } 25 + if interval < 604_800 { return "\(Int(interval / 86400))d" } 26 + if interval < 2_592_000 { return "\(Int(interval / 604_800))w" } 27 27 let df = DateFormatter() 28 28 df.dateFormat = "MMM d" 29 29 return df.string(from: date)
+175
Grain/Utilities/ImagePrefetchPlanning.swift
··· 1 + import Foundation 2 + import Nuke 3 + 4 + struct PrioritizedRequests { 5 + let high: [ImageRequest] 6 + let normal: [ImageRequest] 7 + let low: [ImageRequest] 8 + 9 + var all: [ImageRequest] { 10 + high + normal + low 11 + } 12 + } 13 + 14 + enum ImagePrefetchPlanning { 15 + // MARK: - Carousel 16 + 17 + /// Tiered prefetch URLs for a gallery photo carousel. 18 + /// 19 + /// - Page 0 (on appear): fullsize #0, #1 at high 20 + /// - Page 1: fullsize #2 at high, all thumbs at normal 21 + /// - Page 2+: next fullsize at high, remaining fullsize at normal 22 + static func carouselPrefetchRequests( 23 + photos: [(thumb: String, fullsize: String)], 24 + currentPage: Int 25 + ) -> PrioritizedRequests { 26 + guard !photos.isEmpty, currentPage >= 0, currentPage < photos.count else { 27 + return PrioritizedRequests(high: [], normal: [], low: []) 28 + } 29 + 30 + var high: [ImageRequest] = [] 31 + var normal: [ImageRequest] = [] 32 + let low: [ImageRequest] = [] 33 + 34 + if currentPage == 0 { 35 + // Prefetch first 2 fullsize 36 + for i in 0 ..< min(2, photos.count) { 37 + if let url = URL(string: photos[i].fullsize) { 38 + high.append(ImageRequest(url: url, priority: .high)) 39 + } 40 + } 41 + } else if currentPage == 1 { 42 + // Fullsize #2 at high 43 + if photos.count > 2, let url = URL(string: photos[2].fullsize) { 44 + high.append(ImageRequest(url: url, priority: .high)) 45 + } 46 + // All thumbs at normal 47 + for photo in photos { 48 + if let url = URL(string: photo.thumb) { 49 + normal.append(ImageRequest(url: url, priority: .normal)) 50 + } 51 + } 52 + } else { 53 + // Page 2+: next fullsize at high, rest at normal 54 + for i in (currentPage + 1) ..< photos.count { 55 + if let url = URL(string: photos[i].fullsize) { 56 + let priority: ImageRequest.Priority = i == currentPage + 1 ? .high : .normal 57 + if priority == .high { 58 + high.append(ImageRequest(url: url, priority: .high)) 59 + } else { 60 + normal.append(ImageRequest(url: url, priority: .normal)) 61 + } 62 + } 63 + } 64 + } 65 + 66 + return PrioritizedRequests(high: high, normal: normal, low: low) 67 + } 68 + 69 + // MARK: - Feed 70 + 71 + /// Prefetch first image (thumb + fullsize) of upcoming galleries in the feed. 72 + /// 73 + /// - i+1: thumb at high, fullsize at high 74 + /// - i+2: thumb at high, fullsize at normal 75 + /// - i+3: thumb at high, fullsize at low 76 + static func feedPrefetchRequests( 77 + galleries: [(firstThumb: String?, firstFullsize: String?)], 78 + currentIndex: Int 79 + ) -> PrioritizedRequests { 80 + guard currentIndex >= 0, currentIndex < galleries.count else { 81 + return PrioritizedRequests(high: [], normal: [], low: []) 82 + } 83 + 84 + var high: [ImageRequest] = [] 85 + var normal: [ImageRequest] = [] 86 + var low: [ImageRequest] = [] 87 + 88 + for offset in 1 ... 3 { 89 + let idx = currentIndex + offset 90 + guard idx < galleries.count else { break } 91 + let gallery = galleries[idx] 92 + 93 + // Thumbs are tiny — always high priority 94 + if let thumb = gallery.firstThumb, let url = URL(string: thumb) { 95 + high.append(ImageRequest(url: url, priority: .high)) 96 + } 97 + 98 + if let fullsize = gallery.firstFullsize, let url = URL(string: fullsize) { 99 + switch offset { 100 + case 1: 101 + high.append(ImageRequest(url: url, priority: .high)) 102 + case 2: 103 + normal.append(ImageRequest(url: url, priority: .normal)) 104 + default: 105 + low.append(ImageRequest(url: url, priority: .low)) 106 + } 107 + } 108 + } 109 + 110 + return PrioritizedRequests(high: high, normal: normal, low: low) 111 + } 112 + 113 + // MARK: - Stories 114 + 115 + /// Prefetch story images with priority queue: 116 + /// 1. Next 2 stories of current author — high 117 + /// 2. First story of next author — high 118 + /// 3. Rest of current author's stack — normal 119 + /// 4. Next 2 stories of author-after-next — normal 120 + /// 5. First story of next 2 authors beyond — low 121 + static func storyPrefetchRequests( 122 + currentStories: [(thumb: String, fullsize: String)], 123 + currentStoryIndex: Int, 124 + nextAuthorStories: [(thumb: String, fullsize: String)]?, 125 + secondNextAuthorStories: [(thumb: String, fullsize: String)]?, 126 + thirdNextFirstStory: (thumb: String, fullsize: String)?, 127 + fourthNextFirstStory: (thumb: String, fullsize: String)? 128 + ) -> PrioritizedRequests { 129 + var high: [ImageRequest] = [] 130 + var normal: [ImageRequest] = [] 131 + var low: [ImageRequest] = [] 132 + 133 + // 1. Next 2 stories of current author — high 134 + let storyEnd = min(currentStoryIndex + 3, currentStories.count) 135 + for i in stride(from: currentStoryIndex + 1, to: storyEnd, by: 1) where i < currentStories.count { 136 + if let url = URL(string: currentStories[i].fullsize) { 137 + high.append(ImageRequest(url: url, priority: .high)) 138 + } 139 + } 140 + 141 + // 2. First story of next author — high 142 + if let first = nextAuthorStories?.first, let url = URL(string: first.fullsize) { 143 + high.append(ImageRequest(url: url, priority: .high)) 144 + } 145 + 146 + // 3. Rest of current author's stack — normal 147 + let restStart = currentStoryIndex + 3 148 + if restStart < currentStories.count { 149 + for i in restStart ..< currentStories.count { 150 + if let url = URL(string: currentStories[i].fullsize) { 151 + normal.append(ImageRequest(url: url, priority: .normal)) 152 + } 153 + } 154 + } 155 + 156 + // 4. Next 2 stories of author-after-next — normal 157 + if let stories = secondNextAuthorStories { 158 + for i in 0 ..< min(2, stories.count) { 159 + if let url = URL(string: stories[i].fullsize) { 160 + normal.append(ImageRequest(url: url, priority: .normal)) 161 + } 162 + } 163 + } 164 + 165 + // 5. First story of next 2 authors beyond — low 166 + if let story = thirdNextFirstStory, let url = URL(string: story.fullsize) { 167 + low.append(ImageRequest(url: url, priority: .low)) 168 + } 169 + if let story = fourthNextFirstStory, let url = URL(string: story.fullsize) { 170 + low.append(ImageRequest(url: url, priority: .low)) 171 + } 172 + 173 + return PrioritizedRequests(high: high, normal: normal, low: low) 174 + } 175 + }
+5 -3
Grain/Utilities/ImageProcessing.swift
··· 20 20 var lo: CGFloat = 0 21 21 var hi: CGFloat = 1 22 22 23 - for _ in 0..<10 { 23 + for _ in 0 ..< 10 { 24 24 let mid = (lo + hi) / 2 25 25 guard let data = scaled.jpegData(compressionQuality: mid) else { break } 26 26 if data.count <= maxBytes { ··· 50 50 static func extractGPS(from data: Data) -> (latitude: Double, longitude: Double)? { 51 51 guard let source = CGImageSourceCreateWithData(data as CFData, nil), 52 52 let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any], 53 - let gpsDict = properties[kCGImagePropertyGPSDictionary as String] as? [String: Any] else { 53 + let gpsDict = properties[kCGImagePropertyGPSDictionary as String] as? [String: Any] 54 + else { 54 55 return nil 55 56 } 56 57 57 58 guard let latitude = gpsDict[kCGImagePropertyGPSLatitude as String] as? Double, 58 59 let latRef = gpsDict[kCGImagePropertyGPSLatitudeRef as String] as? String, 59 60 let longitude = gpsDict[kCGImagePropertyGPSLongitude as String] as? Double, 60 - let lonRef = gpsDict[kCGImagePropertyGPSLongitudeRef as String] as? String else { 61 + let lonRef = gpsDict[kCGImagePropertyGPSLongitudeRef as String] as? String 62 + else { 61 63 return nil 62 64 } 63 65
+11 -13
Grain/Utilities/LabelResolution.swift
··· 52 52 for label in labels { 53 53 guard let val = label.val, !val.isEmpty else { continue } 54 54 55 - let resolution: LabelResolution 56 - if let def = definitions.first(where: { $0.identifier == val }) { 57 - resolution = resolveFromDefinition(val: val, def: def) 55 + let resolution: LabelResolution = if let def = definitions.first(where: { $0.identifier == val }) { 56 + resolveFromDefinition(val: val, def: def) 58 57 } else if let fallback = fallbackDefinitions[val] { 59 - resolution = resolveFromFallback(val: val, fallback: fallback) 58 + resolveFromFallback(val: val, fallback: fallback) 60 59 } else { 61 60 // Unknown label — treat as badge warning 62 - resolution = LabelResolution(action: .badge, label: val, name: val) 61 + LabelResolution(action: .badge, label: val, name: val) 63 62 } 64 63 65 64 if resolution.action > worst.action { ··· 79 78 } 80 79 81 80 private func resolveFromFallback(val: String, fallback: (blurs: String, defaultSetting: String, name: String)) -> LabelResolution { 82 - return resolveAction(val: val, name: fallback.name, blurs: fallback.blurs, setting: fallback.defaultSetting) 81 + resolveAction(val: val, name: fallback.name, blurs: fallback.blurs, setting: fallback.defaultSetting) 83 82 } 84 83 85 84 private func resolveAction(val: String, name: String, blurs: String, setting: String) -> LabelResolution { 86 - let action: LabelAction 87 - switch (blurs, setting) { 85 + let action: LabelAction = switch (blurs, setting) { 88 86 case (_, "hide") where blurs != "none": 89 - action = blurs == "media" ? .warnMedia : .hide 87 + blurs == "media" ? .warnMedia : .hide 90 88 case ("content", "warn"): 91 - action = .warnContent 89 + .warnContent 92 90 case ("media", "warn"): 93 - action = .warnMedia 91 + .warnMedia 94 92 case ("none", "warn"): 95 - action = .badge 93 + .badge 96 94 default: 97 - action = .none 95 + .none 98 96 } 99 97 return LabelResolution(action: action, label: val, name: name) 100 98 }
+2 -2
Grain/Utilities/LiquidGlass.swift
··· 3 3 extension View { 4 4 /// Applies iOS 26 Liquid Glass effect. 5 5 func liquidGlass() -> some View { 6 - self.glassEffect(.regular.interactive()) 6 + glassEffect(.regular.interactive()) 7 7 } 8 8 9 9 /// Applies iOS 26 Liquid Glass effect in a circular shape. 10 10 func liquidGlassCircle() -> some View { 11 - self.clipShape(Circle()) 11 + clipShape(Circle()) 12 12 .glassEffect(.regular) 13 13 } 14 14 }
+27 -27
Grain/Utilities/LocationServices.swift
··· 15 15 self.placeId = placeId 16 16 17 17 if let lat = json["lat"] as? String, let lon = json["lon"] as? String, 18 - let latD = Double(lat), let lonD = Double(lon) { 19 - self.latitude = latD 20 - self.longitude = lonD 18 + let latD = Double(lat), let lonD = Double(lon) 19 + { 20 + latitude = latD 21 + longitude = lonD 21 22 } else if let lat = json["lat"] as? Double, let lon = json["lon"] as? Double { 22 - self.latitude = lat 23 - self.longitude = lon 23 + latitude = lat 24 + longitude = lon 24 25 } else { 25 26 return nil 26 27 } ··· 28 29 let addr = json["address"] as? [String: Any] 29 30 let city = addr?["city"] as? String ?? addr?["town"] as? String ?? addr?["village"] as? String 30 31 32 + var locationParts: [String] = [] 33 + if let city { locationParts.append(city) } 34 + if let state = addr?["state"] as? String { locationParts.append(state) } 35 + if let country = addr?["country"] as? String { locationParts.append(country) } 36 + 31 37 if let placeName = json["name"] as? String, !placeName.isEmpty { 32 - self.name = placeName 38 + name = placeName 33 39 } else { 34 - var parts: [String] = [] 35 - if let city { parts.append(city) } 36 - if let state = addr?["state"] as? String { parts.append(state) } 37 - if let country = addr?["country"] as? String { parts.append(country) } 38 - self.name = parts.isEmpty 40 + name = locationParts.isEmpty 39 41 ? (json["display_name"] as? String ?? "Unknown").components(separatedBy: ",").first ?? "Unknown" 40 - : parts.joined(separator: ", ") 42 + : locationParts.joined(separator: ", ") 41 43 } 42 44 43 - var contextParts: [String] = [] 44 - if let city { contextParts.append(city) } 45 - if let state = addr?["state"] as? String { contextParts.append(state) } 46 - if let country = addr?["country"] as? String { contextParts.append(country) } 47 - self.context = contextParts.isEmpty ? nil : contextParts.joined(separator: ", ") 45 + context = locationParts.isEmpty ? nil : locationParts.joined(separator: ", ") 48 46 49 47 if let countryCode = (addr?["country_code"] as? String)?.uppercased() { 50 - var a: [String: AnyCodable] = ["country": AnyCodable(countryCode)] 51 - if let city { a["locality"] = AnyCodable(city) } 52 - if let state = addr?["state"] as? String { a["region"] = AnyCodable(state) } 48 + var addressFields: [String: AnyCodable] = ["country": AnyCodable(countryCode)] 49 + if let city { addressFields["locality"] = AnyCodable(city) } 50 + if let state = addr?["state"] as? String { addressFields["region"] = AnyCodable(state) } 53 51 if let road = addr?["road"] as? String { 54 52 if let houseNumber = addr?["house_number"] as? String { 55 - a["street"] = AnyCodable("\(houseNumber) \(road)") 53 + addressFields["street"] = AnyCodable("\(houseNumber) \(road)") 56 54 } else { 57 - a["street"] = AnyCodable(road) 55 + addressFields["street"] = AnyCodable(road) 58 56 } 59 57 } 60 - if let postcode = addr?["postcode"] as? String { a["postalCode"] = AnyCodable(postcode) } 61 - self.address = a 58 + if let postcode = addr?["postcode"] as? String { addressFields["postalCode"] = AnyCodable(postcode) } 59 + address = addressFields 62 60 } else { 63 - self.address = nil 61 + address = nil 64 62 } 65 63 } 66 64 } ··· 95 93 request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent") 96 94 97 95 guard let (data, _) = try? await URLSession.shared.data(for: request), 98 - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { 96 + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] 97 + else { 99 98 return nil 100 99 } 101 100 ··· 120 119 request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent") 121 120 122 121 guard let (data, _) = try? await URLSession.shared.data(for: request), 123 - let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { 122 + let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] 123 + else { 124 124 return [] 125 125 } 126 126
+309
Grain/Utilities/PreviewData.swift
··· 1 + #if DEBUG 2 + import UIKit 3 + 4 + // MARK: - Shared preview content used across all #Preview blocks 5 + 6 + enum PreviewData { 7 + // MARK: - Profiles 8 + 9 + static let profile = GrainProfileDetailed( 10 + cid: "cid1", 11 + did: "did:plc:prevuser1", 12 + handle: "yuki.grain.social", 13 + displayName: "Yuki Tanaka", 14 + description: "Analog photographer based in Tokyo 🇯🇵\nLeica M6 · Mamiya RB67 · Kodak Portra\n#35mm #film #analog #streetphoto", 15 + avatar: nil, 16 + cameras: ["Leica M6", "Mamiya RB67"], 17 + followersCount: 2847, 18 + followsCount: 412, 19 + galleryCount: 63, 20 + viewer: ActorViewerState(following: nil, followedBy: "at://preview/follow/1") 21 + ) 22 + 23 + static let profile2 = GrainProfile( 24 + cid: "cid2", did: "did:plc:prevuser2", 25 + handle: "marcus.grain.social", displayName: "Marcus Webb" 26 + ) 27 + 28 + static let profile3 = GrainProfile( 29 + cid: "cid3", did: "did:plc:prevuser3", 30 + handle: "sofia.grain.social", displayName: "Sofia Reyes" 31 + ) 32 + 33 + // MARK: - Bundle image URL helper 34 + 35 + static func bundleImageURL(_ name: String, ext: String = "jpg") -> String { 36 + Bundle.main.url(forResource: name, withExtension: ext)?.absoluteString ?? "" 37 + } 38 + 39 + // MARK: - Photos (file:// URLs → real images in preview via Nuke LazyImage) 40 + 41 + static let photos: [GrainPhoto] = [ 42 + GrainPhoto( 43 + uri: "at://did:plc:prevuser1/social.grain.photo/p1", 44 + cid: "cid", 45 + thumb: bundleImageURL("Portland_Japanese_Garden_maple"), 46 + fullsize: bundleImageURL("Portland_Japanese_Garden_maple"), 47 + alt: "Japanese Garden, Portland", 48 + aspectRatio: AspectRatio(width: 4, height: 3) 49 + ), 50 + GrainPhoto( 51 + uri: "at://did:plc:prevuser1/social.grain.photo/p2", 52 + cid: "cid", 53 + thumb: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon"), 54 + fullsize: bundleImageURL("Mount_Hood_reflected_in_Mirror_Lake,_Oregon"), 55 + alt: "Mirror Lake, Oregon", 56 + aspectRatio: AspectRatio(width: 3, height: 2) 57 + ), 58 + GrainPhoto( 59 + uri: "at://did:plc:prevuser1/social.grain.photo/p3", 60 + cid: "cid", 61 + thumb: bundleImageURL("Mt_Herschel,_Antarctica,_Jan_2006"), 62 + fullsize: bundleImageURL("Mt_Herschel,_Antarctica,_Jan_2006"), 63 + alt: "Mt. Herschel, Antarctica", 64 + aspectRatio: AspectRatio(width: 4, height: 3) 65 + ), 66 + GrainPhoto( 67 + uri: "at://did:plc:prevuser1/social.grain.photo/p4", 68 + cid: "cid", 69 + thumb: bundleImageURL("Penguin_in_Antarctica_jumping_out_of_the_water"), 70 + fullsize: bundleImageURL("Penguin_in_Antarctica_jumping_out_of_the_water"), 71 + alt: "Penguin launching from ice shelf", 72 + aspectRatio: AspectRatio(width: 1, height: 1) 73 + ), 74 + GrainPhoto( 75 + uri: "at://did:plc:prevuser1/social.grain.photo/p5", 76 + cid: "cid", 77 + thumb: bundleImageURL("Union_Bank_Tower,_Portland_(2024)-L1006272"), 78 + fullsize: bundleImageURL("Union_Bank_Tower,_Portland_(2024)-L1006272"), 79 + alt: "Union Bank Tower, Portland", 80 + aspectRatio: AspectRatio(width: 2, height: 3) 81 + ), 82 + GrainPhoto( 83 + uri: "at://did:plc:prevuser1/social.grain.photo/p6", 84 + cid: "cid", 85 + thumb: bundleImageURL("ACE_EMD_F40PH_Fremont_-_San_Jose"), 86 + fullsize: bundleImageURL("ACE_EMD_F40PH_Fremont_-_San_Jose"), 87 + alt: "ACE train, Fremont–San Jose", 88 + aspectRatio: AspectRatio(width: 16, height: 9) 89 + ), 90 + GrainPhoto( 91 + uri: "at://did:plc:prevuser1/social.grain.photo/p7", 92 + cid: "cid", 93 + thumb: bundleImageURL("C-141_Starlifter_contrail"), 94 + fullsize: bundleImageURL("C-141_Starlifter_contrail"), 95 + alt: "C-141 Starlifter contrail", 96 + aspectRatio: AspectRatio(width: 16, height: 9) 97 + ), 98 + GrainPhoto( 99 + uri: "at://did:plc:prevuser1/social.grain.photo/p8", 100 + cid: "cid", 101 + thumb: bundleImageURL("Endeavour_after_STS-126_on_SCA_over_Mojave_from_above"), 102 + fullsize: bundleImageURL("Endeavour_after_STS-126_on_SCA_over_Mojave_from_above"), 103 + alt: "Space Shuttle Endeavour over Mojave", 104 + aspectRatio: AspectRatio(width: 4, height: 3) 105 + ), 106 + ] 107 + 108 + // MARK: - Galleries 109 + 110 + static let gallery1 = GrainGallery( 111 + uri: "at://did:plc:prevuser1/social.grain.gallery/r1", 112 + cid: "cid", 113 + title: "Golden Hour, Kyoto", 114 + description: "Shot on Leica M6 with Kodak Portra 400 during autumn in Kyoto. #analog #japan #35mm #film", 115 + cameras: ["Leica M6"], 116 + creator: GrainProfile( 117 + cid: "cid", did: "did:plc:prevuser1", 118 + handle: "yuki.grain.social", displayName: "Yuki Tanaka" 119 + ), 120 + items: photos, 121 + favCount: 184, 122 + commentCount: 12, 123 + indexedAt: "2025-01-10T18:30:00Z" 124 + ) 125 + 126 + static let gallery2 = GrainGallery( 127 + uri: "at://did:plc:prevuser2/social.grain.gallery/r2", 128 + cid: "cid", 129 + title: "Lower East Side", 130 + description: "Sunday morning light on Orchard St. #nyc #street #leica", 131 + cameras: ["Leica Q3"], 132 + creator: GrainProfile( 133 + cid: "cid", did: "did:plc:prevuser2", 134 + handle: "marcus.grain.social", displayName: "Marcus Webb" 135 + ), 136 + items: Array(photos.prefix(2)), 137 + favCount: 97, 138 + commentCount: 5, 139 + indexedAt: "2025-01-08T12:00:00Z" 140 + ) 141 + 142 + static let gallery3 = GrainGallery( 143 + uri: "at://did:plc:prevuser3/social.grain.gallery/r3", 144 + cid: "cid", 145 + title: "Oaxaca Market", 146 + description: "Colors, light, and life. Shot on Fuji Velvia 50. #mexico #analog #color", 147 + cameras: ["Nikon FM2"], 148 + creator: GrainProfile( 149 + cid: "cid", did: "did:plc:prevuser3", 150 + handle: "sofia.grain.social", displayName: "Sofia Reyes" 151 + ), 152 + items: Array(photos.prefix(3)), 153 + favCount: 231, 154 + commentCount: 18, 155 + indexedAt: "2025-01-05T09:00:00Z" 156 + ) 157 + 158 + static let galleries: [GrainGallery] = [gallery1, gallery2, gallery3] 159 + 160 + // MARK: - Comments 161 + 162 + static let comments: [GrainComment] = [ 163 + GrainComment( 164 + uri: "at://did:plc:prevuser2/social.grain.comment/c1", 165 + cid: "cid", 166 + author: profile2, 167 + text: "The light in the third frame is unreal. What film stock did you use?", 168 + createdAt: "2025-01-10T19:00:00Z" 169 + ), 170 + GrainComment( 171 + uri: "at://did:plc:prevuser3/social.grain.comment/c2", 172 + cid: "cid", 173 + author: profile3, 174 + text: "Portra always delivers in that golden hour window 🙌", 175 + replyTo: "at://did:plc:prevuser2/social.grain.comment/c1", 176 + createdAt: "2025-01-10T19:15:00Z" 177 + ), 178 + GrainComment( 179 + uri: "at://did:plc:prevuser2/social.grain.comment/c3", 180 + cid: "cid", 181 + author: profile2, 182 + text: "Worth every penny shooting on film. Love this series.", 183 + createdAt: "2025-01-10T20:00:00Z" 184 + ), 185 + ] 186 + 187 + // MARK: - PhotoItems (UIImage-based, for editor/grid/strip previews) 188 + 189 + static var photoItems: [PhotoItem] { 190 + let stockImages: [(name: String, alt: String)] = [ 191 + ("Portland_Japanese_Garden_maple", "Japanese Garden, Portland"), 192 + ("Mount_Hood_reflected_in_Mirror_Lake,_Oregon", "Mirror Lake, Oregon"), 193 + ("Mt_Herschel,_Antarctica,_Jan_2006", "Mt. Herschel, Antarctica"), 194 + ("Penguin_in_Antarctica_jumping_out_of_the_water", "Penguin launching from ice shelf"), 195 + ("Union_Bank_Tower,_Portland_(2024)-L1006272", "Union Bank Tower, Portland"), 196 + ("ACE_EMD_F40PH_Fremont_-_San_Jose", "ACE train, Fremont–San Jose"), 197 + ("C-141_Starlifter_contrail", "C-141 Starlifter contrail"), 198 + ("Endeavour_after_STS-126_on_SCA_over_Mojave_from_above", "Space Shuttle Endeavour over Mojave"), 199 + ] 200 + let fallbackColors: [([CGColor], String)] = [ 201 + ([UIColor.systemBrown.cgColor, UIColor.systemOrange.cgColor], ""), 202 + ([UIColor.systemMint.cgColor, UIColor.systemTeal.cgColor], ""), 203 + ([UIColor.systemCyan.cgColor, UIColor.systemBlue.cgColor], ""), 204 + ([UIColor.systemGray.cgColor, UIColor.systemGray3.cgColor], ""), 205 + ([UIColor.systemPink.cgColor, UIColor.systemRed.cgColor], "Market stalls, morning light"), 206 + ([UIColor.systemGreen.cgColor, UIColor.systemMint.cgColor], ""), 207 + ([UIColor.systemOrange.cgColor, UIColor.systemYellow.cgColor], ""), 208 + ] 209 + var items: [PhotoItem] = stockImages.compactMap { entry in 210 + guard let path = Bundle.main.url(forResource: entry.name, withExtension: "jpg")?.path, 211 + let fullImage = UIImage(contentsOfFile: path) else { return nil } 212 + let thumb = PhotoItem.makeThumbnail(from: fullImage) 213 + var item = PhotoItem(thumbnail: thumb, source: .camera(fullImage)) 214 + item.alt = entry.alt 215 + return item 216 + } 217 + // Pad with gradient fallbacks if fewer than 15 items total 218 + for (colors, label) in fallbackColors { 219 + let thumb = gradientThumb(colors: colors) 220 + var item = PhotoItem(thumbnail: thumb, source: .camera(thumb)) 221 + item.alt = label 222 + items.append(item) 223 + } 224 + return items 225 + } 226 + 227 + // MARK: - Story authors 228 + 229 + static let storyAuthors: [GrainStoryAuthor] = [ 230 + GrainStoryAuthor(profile: GrainProfile(cid: "c1", did: "did:plc:prevuser1", handle: "yuki.grain.social", displayName: "Yuki"), storyCount: 3, latestAt: "2025-01-10T18:00:00Z"), 231 + GrainStoryAuthor(profile: GrainProfile(cid: "c2", did: "did:plc:prevuser2", handle: "marcus.grain.social", displayName: "Marcus"), storyCount: 1, latestAt: "2025-01-10T15:00:00Z"), 232 + GrainStoryAuthor(profile: GrainProfile(cid: "c3", did: "did:plc:prevuser3", handle: "sofia.grain.social", displayName: "Sofia"), storyCount: 2, latestAt: "2025-01-10T12:00:00Z"), 233 + GrainStoryAuthor(profile: GrainProfile(cid: "c4", did: "did:plc:prevuser4", handle: "kai.grain.social", displayName: "Kai"), storyCount: 1, latestAt: "2025-01-10T10:00:00Z"), 234 + GrainStoryAuthor(profile: GrainProfile(cid: "c5", did: "did:plc:prevuser5", handle: "leo.grain.social", displayName: "Leo"), storyCount: 4, latestAt: "2025-01-10T08:00:00Z"), 235 + ] 236 + 237 + // MARK: - Notifications 238 + 239 + static let notifications: [GrainNotification] = [ 240 + GrainNotification( 241 + uri: "at://did:plc:prevuser2/social.grain.notification/n1", 242 + reason: "gallery-favorite", 243 + createdAt: "2025-01-10T19:30:00Z", 244 + author: profile2, 245 + galleryUri: gallery1.uri, 246 + galleryTitle: gallery1.title, 247 + galleryThumb: "" 248 + ), 249 + GrainNotification( 250 + uri: "at://did:plc:prevuser3/social.grain.notification/n2", 251 + reason: "gallery-comment", 252 + createdAt: "2025-01-10T19:00:00Z", 253 + author: profile3, 254 + galleryUri: gallery1.uri, 255 + galleryTitle: gallery1.title, 256 + galleryThumb: "", 257 + commentText: "The light in the third frame is unreal. What film stock?" 258 + ), 259 + GrainNotification( 260 + uri: "at://did:plc:prevuser4/social.grain.notification/n3", 261 + reason: "follow", 262 + createdAt: "2025-01-10T18:00:00Z", 263 + author: GrainProfile(cid: "c4", did: "did:plc:prevuser4", handle: "kai.grain.social", displayName: "Kai Müller") 264 + ), 265 + GrainNotification( 266 + uri: "at://did:plc:prevuser2/social.grain.notification/n4", 267 + reason: "gallery-favorite", 268 + createdAt: "2025-01-09T12:00:00Z", 269 + author: profile2, 270 + galleryUri: gallery2.uri, 271 + galleryTitle: gallery2.title, 272 + galleryThumb: "" 273 + ), 274 + GrainNotification( 275 + uri: "at://did:plc:prevuser5/social.grain.notification/n5", 276 + reason: "gallery-comment-mention", 277 + createdAt: "2025-01-09T10:00:00Z", 278 + author: GrainProfile(cid: "c5", did: "did:plc:prevuser5", handle: "leo.grain.social", displayName: "Leo Park"), 279 + galleryUri: gallery1.uri, 280 + galleryTitle: gallery1.title, 281 + galleryThumb: "", 282 + commentText: "Tagged you in a comment: @yuki.grain.social beautiful work!" 283 + ), 284 + ] 285 + 286 + // MARK: - Image generation 287 + 288 + static func gradientThumb( 289 + colors: [CGColor], 290 + size: CGSize = CGSize(width: 300, height: 300) 291 + ) -> UIImage { 292 + UIGraphicsImageRenderer(size: size).image { ctx in 293 + let cgCtx = ctx.cgContext 294 + let colorSpace = CGColorSpaceCreateDeviceRGB() 295 + guard let gradient = CGGradient( 296 + colorsSpace: colorSpace, 297 + colors: colors as CFArray, 298 + locations: nil 299 + ) else { return } 300 + cgCtx.drawLinearGradient( 301 + gradient, 302 + start: .zero, 303 + end: CGPoint(x: size.width, y: size.height), 304 + options: [] 305 + ) 306 + } 307 + } 308 + } 309 + #endif
+7
Grain/Utilities/PreviewHelpers.swift
··· 1 + import Foundation 2 + 3 + /// True when code is running inside an Xcode preview canvas. 4 + /// Use to skip network calls that would block or slow down previews. 5 + var isPreview: Bool { 6 + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 7 + }
+10 -4
Grain/Utilities/RecentSearchStorage.swift
··· 5 5 var displayName: String? 6 6 var handle: String? 7 7 var avatar: String? 8 - var id: String { did } 8 + var id: String { 9 + did 10 + } 9 11 } 10 12 11 13 struct RecentTextSearch: Codable, Identifiable, Equatable { 12 14 let query: String 13 - var id: String { query } 15 + var id: String { 16 + query 17 + } 14 18 } 15 19 16 20 @Observable ··· 62 66 63 67 private func load() { 64 68 if let data = UserDefaults.standard.data(forKey: Self.profilesKey), 65 - let decoded = try? JSONDecoder().decode([RecentProfileSearch].self, from: data) { 69 + let decoded = try? JSONDecoder().decode([RecentProfileSearch].self, from: data) 70 + { 66 71 profiles = decoded 67 72 } 68 73 if let data = UserDefaults.standard.data(forKey: Self.textKey), 69 - let decoded = try? JSONDecoder().decode([RecentTextSearch].self, from: data) { 74 + let decoded = try? JSONDecoder().decode([RecentTextSearch].self, from: data) 75 + { 70 76 textSearches = decoded 71 77 } 72 78 }
+24 -13
Grain/Utilities/ViewedStoryStorage.swift
··· 10 10 private static let authorKey = "viewedStoryAuthors" 11 11 12 12 init() { 13 + #if DEBUG 14 + Self.wipeDefaults() 15 + #endif 13 16 load() 14 17 } 18 + 19 + #if DEBUG 20 + private static func wipeDefaults() { 21 + UserDefaults.standard.removeObject(forKey: urisKey) 22 + UserDefaults.standard.removeObject(forKey: authorKey) 23 + } 24 + #endif 15 25 16 26 private static let dateFormatter: ISO8601DateFormatter = { 17 - let f = ISO8601DateFormatter() 18 - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 19 - return f 27 + let formatter = ISO8601DateFormatter() 28 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 29 + return formatter 20 30 }() 21 31 22 32 private static let dateFormatterNoFrac: ISO8601DateFormatter = { 23 - let f = ISO8601DateFormatter() 24 - f.formatOptions = [.withInternetDateTime] 25 - return f 33 + let formatter = ISO8601DateFormatter() 34 + formatter.formatOptions = [.withInternetDateTime] 35 + return formatter 26 36 }() 27 37 28 38 private static func parseDate(_ string: String) -> Date? { ··· 34 44 viewedUris.insert(uri) 35 45 if let existing = authorLastViewed[authorDid], 36 46 let existingDate = Self.parseDate(existing), 37 - let newDate = Self.parseDate(createdAt) { 47 + let newDate = Self.parseDate(createdAt) 48 + { 38 49 if newDate > existingDate { 39 50 authorLastViewed[authorDid] = createdAt 40 51 } ··· 66 77 /// Find the index of the first unviewed story in a list. 67 78 /// Returns 0 if all stories have been viewed (replay from start). 68 79 func firstUnviewedIndex(in stories: [any StoryIdentifiable]) -> Int { 69 - for (index, story) in stories.enumerated() { 70 - if !viewedUris.contains(story.storyUri) { 71 - return index 72 - } 80 + for (index, story) in stories.enumerated() where !viewedUris.contains(story.storyUri) { 81 + return index 73 82 } 74 83 return 0 75 84 } ··· 87 96 88 97 private func load() { 89 98 if let data = UserDefaults.standard.data(forKey: Self.urisKey), 90 - let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) { 99 + let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) 100 + { 91 101 viewedUris = decoded 92 102 } 93 103 if let data = UserDefaults.standard.data(forKey: Self.authorKey), 94 - let decoded = try? JSONDecoder().decode([String: String].self, from: data) { 104 + let decoded = try? JSONDecoder().decode([String: String].self, from: data) 105 + { 95 106 authorLastViewed = decoded 96 107 } 97 108 }
+6 -7
Grain/ViewModels/GalleryDetailViewModel.swift
··· 21 21 error = nil 22 22 23 23 do { 24 - let g = try await client.getGallery(uri: uri, auth: auth) 25 - let c = try await client.getGalleryThread(gallery: uri, auth: auth) 26 - gallery = g.gallery 27 - comments = c.comments 28 - commentCursor = c.cursor 29 - hasMoreComments = c.cursor != nil 24 + let galleryResponse = try await client.getGallery(uri: uri, auth: auth) 25 + let commentsResponse = try await client.getGalleryThread(gallery: uri, auth: auth) 26 + gallery = galleryResponse.gallery 27 + comments = commentsResponse.comments 28 + commentCursor = commentsResponse.cursor 29 + hasMoreComments = commentsResponse.cursor != nil 30 30 } catch { 31 31 self.error = error 32 32 } ··· 47 47 } 48 48 isLoading = false 49 49 } 50 - 51 50 }
+24 -19
Grain/ViewModels/ProfileDetailViewModel.swift
··· 23 23 error = nil 24 24 25 25 do { 26 - async let p = client.getActorProfile(actor: did, viewer: viewer, auth: auth) 27 - async let f = client.getFeed(feed: "actor", actor: did, auth: auth) 28 - async let s = client.getStories(actor: did, auth: auth) 29 - async let kf: [FollowerItem] = { 26 + async let profileFetch = client.getActorProfile(actor: did, viewer: viewer, auth: auth) 27 + async let feedFetch = client.getFeed(feed: "actor", actor: did, auth: auth) 28 + async let storiesFetch = client.getStories(actor: did, auth: auth) 29 + async let knownFollowersFetch: [FollowerItem] = { 30 30 guard let viewer, viewer != did else { return [] } 31 31 let response = try? await client.getKnownFollowers(actor: did, viewer: viewer, auth: auth) 32 32 return response?.items ?? [] 33 33 }() 34 34 35 - let profileResult = try await p 36 - let feedResult = try await f 37 - let storiesResult = try await s 35 + let profileResult = try await profileFetch 36 + let feedResult = try await feedFetch 37 + let storiesResult = try await storiesFetch 38 38 39 39 profile = profileResult 40 40 galleries = feedResult.items ?? [] 41 41 galleryCursor = feedResult.cursor 42 42 hasMoreGalleries = feedResult.cursor != nil 43 43 stories = storiesResult.stories 44 - knownFollowers = await kf 44 + knownFollowers = await knownFollowersFetch 45 45 } catch { 46 46 self.error = error 47 47 } ··· 74 74 75 75 if let followUri { 76 76 // Optimistic unfollow 77 - self.profile?.viewer?.following = nil 78 - self.profile?.followersCount = max((prevCount ?? 1) - 1, 0) 77 + profile?.viewer?.following = nil 78 + profile?.followersCount = max((prevCount ?? 1) - 1, 0) 79 79 80 80 let rkey = followUri.split(separator: "/").last.map(String.init) ?? "" 81 81 do { 82 82 try await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: auth) 83 83 } catch { 84 - self.profile?.viewer = prevViewer 85 - self.profile?.followersCount = prevCount 84 + profile?.viewer = prevViewer 85 + profile?.followersCount = prevCount 86 86 } 87 87 } else { 88 88 // Optimistic follow 89 - self.profile?.viewer = ActorViewerState(following: "pending") 90 - self.profile?.followersCount = (prevCount ?? 0) + 1 89 + profile?.viewer = ActorViewerState(following: "pending") 90 + profile?.followersCount = (prevCount ?? 0) + 1 91 91 92 92 let record = AnyCodable([ 93 93 "subject": did, 94 - "createdAt": DateFormatting.nowISO() 94 + "createdAt": DateFormatting.nowISO(), 95 95 ]) 96 96 let repo = TokenStorage.userDID ?? "" 97 97 do { 98 - let response = try await client.createRecord(collection: "social.grain.graph.follow", repo: repo, record: record, auth: auth) 99 - self.profile?.viewer?.following = response.uri 98 + let response = try await client.createRecord( 99 + collection: "social.grain.graph.follow", 100 + repo: repo, 101 + record: record, 102 + auth: auth 103 + ) 104 + profile?.viewer?.following = response.uri 100 105 } catch { 101 - self.profile?.viewer = prevViewer 102 - self.profile?.followersCount = prevCount 106 + profile?.viewer = prevViewer 107 + profile?.followersCount = prevCount 103 108 } 104 109 } 105 110 }
+4 -4
Grain/ViewModels/SearchViewModel.swift
··· 24 24 25 25 func loadDiscovery(auth: AuthContext? = nil) async { 26 26 do { 27 - let l = try await client.getLocations(auth: auth) 28 - let c = try await client.getCameras(auth: auth) 29 - locations = l.locations ?? [] 30 - cameras = c.cameras ?? [] 27 + let locationsResponse = try await client.getLocations(auth: auth) 28 + let camerasResponse = try await client.getCameras(auth: auth) 29 + locations = locationsResponse.locations ?? [] 30 + cameras = camerasResponse.cameras ?? [] 31 31 } catch {} 32 32 } 33 33
+10 -1
Grain/Views/Components/AvatarView.swift
··· 1 - import SwiftUI 2 1 import NukeUI 2 + import SwiftUI 3 3 4 4 struct AvatarView: View { 5 5 let url: String? ··· 32 32 } 33 33 } 34 34 } 35 + 36 + #Preview { 37 + HStack(spacing: 20) { 38 + AvatarView(url: nil) 39 + AvatarView(url: nil, size: 48) 40 + AvatarView(url: nil, size: 80) 41 + } 42 + .padding() 43 + }
+13 -3
Grain/Views/Components/CameraPicker.swift
··· 12 12 return picker 13 13 } 14 14 15 - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} 15 + func updateUIViewController(_: UIImagePickerController, context _: Context) {} 16 16 17 17 func makeCoordinator() -> Coordinator { 18 18 Coordinator(dismiss: dismiss, onImagePicked: onImagePicked) ··· 27 27 self.onImagePicked = onImagePicked 28 28 } 29 29 30 - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { 30 + func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { 31 31 if let image = info[.originalImage] as? UIImage { 32 32 onImagePicked(image) 33 33 } 34 34 dismiss() 35 35 } 36 36 37 - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { 37 + func imagePickerControllerDidCancel(_: UIImagePickerController) { 38 38 dismiss() 39 39 } 40 40 } 41 41 } 42 + 43 + #Preview { 44 + // CameraPicker wraps UIImagePickerController — camera unavailable in simulator. 45 + // Preview shows the picker presented over a placeholder. 46 + @Previewable @State var show = true 47 + Color.gray.opacity(0.1) 48 + .sheet(isPresented: $show) { 49 + CameraPicker { _ in } 50 + } 51 + }
+8
Grain/Views/Components/ContentLabelPicker.swift
··· 51 51 } 52 52 } 53 53 } 54 + 55 + #Preview { 56 + @Previewable @State var labels = Set<String>() 57 + Form { 58 + ContentLabelPicker(selectedLabels: $labels) 59 + } 60 + .environment(LabelDefinitionsCache()) 61 + }
+19
Grain/Views/Components/ContentWarningOverlay.swift
··· 74 74 .background(.quaternary, in: .capsule) 75 75 } 76 76 } 77 + 78 + #Preview("ContentWarningOverlay") { 79 + ContentWarningOverlay(name: "Nudity", action: .hide) {} 80 + .frame(height: 200) 81 + } 82 + 83 + #Preview("MediaWarningOverlay") { 84 + MediaWarningOverlay(name: "Sexual Content") {} 85 + .frame(height: 200) 86 + .background(Color.gray.opacity(0.2)) 87 + } 88 + 89 + #Preview("LabelBadge") { 90 + HStack { 91 + LabelBadge(name: "sensitive") 92 + LabelBadge(name: "gore") 93 + } 94 + .padding() 95 + }
+15 -2
Grain/Views/Components/CustomFullScreenCover.swift
··· 1 1 import SwiftUI 2 2 3 3 extension View { 4 - func customFullScreenCover<Content: View>( 4 + func customFullScreenCover( 5 5 isPresented: Binding<Bool>, 6 6 transition: AnyTransition = .scale(scale: 0.9).combined(with: .opacity), 7 7 animation: Animation = .easeInOut(duration: 0.25), 8 - @ViewBuilder content: @escaping () -> Content 8 + @ViewBuilder content: @escaping () -> some View 9 9 ) -> some View { 10 10 modifier( 11 11 CustomFullScreenCoverModifier( ··· 57 57 } 58 58 } 59 59 } 60 + 61 + #Preview { 62 + @Previewable @State var show = false 63 + VStack { 64 + Button("Show Cover") { show = true } 65 + } 66 + .customFullScreenCover(isPresented: $show) { 67 + ZStack { 68 + Color.blue.ignoresSafeArea() 69 + Text("Custom Cover").foregroundStyle(.white) 70 + } 71 + } 72 + }
+17
Grain/Views/Components/ExifInfoView.swift
··· 31 31 } 32 32 } 33 33 } 34 + 35 + #Preview { 36 + ExifInfoView(exif: GrainExif( 37 + uri: "at://preview", 38 + cid: "cid", 39 + photo: "at://preview/photo", 40 + createdAt: "2024-06-15T18:00:00Z", 41 + exposureTime: "1/500", 42 + fNumber: "f/2.0", 43 + focalLengthIn35mmFormat: "35mm", 44 + iSO: 200, 45 + lensModel: "Summilux-M 35mm f/1.4", 46 + make: "Leica", 47 + model: "M11" 48 + )) 49 + .padding() 50 + }
+13 -1
Grain/Views/Components/ExpandableDescriptionView.swift
··· 44 44 }) 45 45 ) 46 46 47 - if isTruncated && !isExpanded { 47 + if isTruncated, !isExpanded { 48 48 Button { 49 49 withAnimation(.easeInOut(duration: 0.2)) { 50 50 isExpanded = true ··· 59 59 } 60 60 } 61 61 } 62 + 63 + #Preview { 64 + VStack(alignment: .leading, spacing: 20) { 65 + ExpandableDescriptionView(text: "Short caption.") 66 + ExpandableDescriptionView( 67 + text: "A much longer caption that keeps going well past two lines. Photographed at golden hour in the hills above the city. Shot with #35mm film, developed in Rodinal. See @alice.grain.social for the full series.", 68 + onMentionTap: { _ in }, 69 + onHashtagTap: { _ in } 70 + ) 71 + } 72 + .padding() 73 + }
+313 -251
Grain/Views/Components/GalleryCardView.swift
··· 1 + import Nuke 1 2 import os 2 3 import SwiftUI 3 4 ··· 22 23 23 24 init(position: CGPoint) { 24 25 self.position = position 25 - self.rotation = Double.random(in: -20...20) 26 + rotation = Double.random(in: -20 ... 20) 26 27 } 27 28 28 29 func start() { ··· 110 111 @State private var showCopiedToast = false 111 112 @State private var shareWiggle = false 112 113 @State private var didLongPressShare = false 114 + @State private var prefetcher = ImagePrefetcher() 113 115 114 116 private var isFavorited: Bool { 115 117 gallery.viewer?.fav != nil ··· 126 128 127 129 var body: some View { 128 130 let lr = labelResult 129 - if (lr.action == .hide || lr.action == .warnContent) && !gallery.labelRevealed { 131 + if lr.action == .hide || lr.action == .warnContent, !gallery.labelRevealed { 130 132 VStack(spacing: 0) { 131 133 ContentWarningOverlay(name: lr.name, action: lr.action) { 132 134 gallery.labelRevealed = true ··· 138 140 } 139 141 } 140 142 141 - @ViewBuilder 142 143 private func cardContent(lr: LabelResolution) -> some View { 143 144 VStack(alignment: .leading, spacing: 0) { 144 - // Header — tappable for navigation 145 - HStack(spacing: 8) { 146 - StoryRingView(hasStory: storyStatusCache.hasStory(for: gallery.creator.did), viewed: viewedStories.hasViewedAll(did: gallery.creator.did, storyStatusCache: storyStatusCache), size: 32) { 147 - AvatarView(url: gallery.creator.avatar, size: 32) 148 - } 149 - .onTapGesture { 150 - if let author = storyStatusCache.author(for: gallery.creator.did) { 151 - onStoryTap?(author) 152 - } else { 153 - onProfileTap?(gallery.creator.did) 154 - } 155 - } 156 - .onLongPressGesture { 145 + cardHeader 146 + if let photos = gallery.items, !photos.isEmpty { 147 + photoCarousel(photos: photos, lr: lr) 148 + } 149 + engagementRow 150 + captionSection(lr: lr) 151 + } 152 + .overlay { 153 + copiedToastOverlay 154 + } 155 + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 156 + } 157 + 158 + private var cardHeader: some View { 159 + HStack(spacing: 8) { 160 + let hasStory = storyStatusCache.hasStory(for: gallery.creator.did) 161 + let allViewed = gallery.creator.did != auth.userDID && viewedStories.hasViewedAll(did: gallery.creator.did, storyStatusCache: storyStatusCache) 162 + StoryRingView(hasStory: hasStory, viewed: allViewed, size: 32) { 163 + AvatarView(url: gallery.creator.avatar, size: 32) 164 + } 165 + .onTapGesture { 166 + if let author = storyStatusCache.author(for: gallery.creator.did) { 167 + onStoryTap?(author) 168 + } else { 157 169 onProfileTap?(gallery.creator.did) 158 170 } 171 + } 172 + .onLongPressGesture { 173 + onProfileTap?(gallery.creator.did) 174 + } 159 175 160 - VStack(alignment: .leading, spacing: 3) { 161 - HStack(spacing: 4) { 162 - Text(gallery.creator.displayName ?? gallery.creator.handle) 163 - .font(.subheadline.weight(.semibold)) 164 - .lineLimit(1) 165 - Text("@\(gallery.creator.handle)") 166 - .font(.caption) 167 - .foregroundStyle(.secondary) 168 - .lineLimit(1) 169 - Text("· \(DateFormatting.relativeTime(gallery.createdAt ?? gallery.indexedAt))") 170 - .font(.caption) 171 - .foregroundStyle(.secondary) 172 - .fixedSize() 173 - } 174 - if let location = gallery.location, let locationName = location.name ?? gallery.address?.locality { 175 - Text(locationName) 176 - .font(.caption) 177 - .foregroundStyle(.secondary) 178 - .lineLimit(1) 179 - .onTapGesture { 180 - onLocationTap?(location.value, locationName) 181 - } 182 - } 176 + VStack(alignment: .leading, spacing: 3) { 177 + HStack(spacing: 4) { 178 + Text(gallery.creator.displayName ?? gallery.creator.handle) 179 + .font(.subheadline.weight(.semibold)) 180 + .lineLimit(1) 181 + Text("@\(gallery.creator.handle)") 182 + .font(.caption) 183 + .foregroundStyle(.secondary) 184 + .lineLimit(1) 185 + Text("· \(DateFormatting.relativeTime(gallery.createdAt ?? gallery.indexedAt))") 186 + .font(.caption) 187 + .foregroundStyle(.secondary) 188 + .fixedSize() 189 + } 190 + if let location = gallery.location, let locationName = location.name ?? gallery.address?.locality { 191 + Text(locationName) 192 + .font(.caption) 193 + .foregroundStyle(.secondary) 194 + .lineLimit(1) 195 + .onTapGesture { 196 + onLocationTap?(location.value, locationName) 197 + } 183 198 } 184 - 185 - Spacer() 186 199 } 187 - .padding(.horizontal, 12) 188 - .padding(.vertical, 8) 189 - .contentShape(Rectangle()) 190 - .onTapGesture { onProfileTap?(gallery.creator.did) } 191 200 192 - // Photo carousel — tappable for navigation 193 - if let photos = gallery.items, !photos.isEmpty { 194 - let hasPortrait = photos.contains { $0.aspectRatio.ratio < 1 } 195 - let hasMixedRatios = Set(photos.map { Int($0.aspectRatio.ratio * 100) }).count > 1 196 - let carouselRatio = hasMixedRatios 197 - ? max(photos.map(\.aspectRatio.ratio).min() ?? 1, 0.56) 198 - : photos[currentPage].aspectRatio.ratio 199 - 200 - GeometryReader { geo in 201 - let height = geo.size.width / carouselRatio 201 + Spacer() 202 + } 203 + .padding(.horizontal, 12) 204 + .padding(.vertical, 8) 205 + .contentShape(Rectangle()) 206 + .onTapGesture { onProfileTap?(gallery.creator.did) } 207 + } 202 208 203 - ZStack(alignment: .bottom) { 204 - TabView(selection: $currentPage) { 205 - ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in 206 - ZoomableImage( 207 - url: photo.fullsize, 208 - aspectRatio: photo.aspectRatio.ratio, 209 - onDoubleTap: { point in doubleTapLike(at: point) } 210 - ) 211 - .tag(index) 212 - } 213 - } 214 - .tabViewStyle(.page(indexDisplayMode: .never)) 215 - .overlay { 216 - if lr.action == .warnMedia && !gallery.labelRevealed { 217 - Rectangle().fill(Color(.secondarySystemBackground)) 218 - } 219 - } 220 - .allowsHitTesting(lr.action != .warnMedia || gallery.labelRevealed) 221 - 222 - // Page indicator (abbreviated like web — max 5 visible dots) 223 - if photos.count > 1 { 224 - HStack(spacing: 5) { 225 - let total = photos.count 226 - let maxVisible = 5 227 - let start = total <= maxVisible ? 0 : min(max(currentPage - 2, 0), total - maxVisible) 228 - let end = total <= maxVisible ? total : start + maxVisible 209 + @ViewBuilder 210 + private func photoCarousel(photos: [GrainPhoto], lr: LabelResolution) -> some View { 211 + let hasPortrait = photos.contains { $0.aspectRatio.ratio < 1 } 212 + let hasMixedRatios = Set(photos.map { Int($0.aspectRatio.ratio * 100) }).count > 1 213 + let carouselRatio = hasMixedRatios 214 + ? max(photos.map(\.aspectRatio.ratio).min() ?? 1, 0.56) 215 + : photos[currentPage].aspectRatio.ratio 229 216 230 - ForEach(start..<end, id: \.self) { index in 231 - let distance = abs(index - currentPage) 232 - let currentIsLandscape = photos[currentPage].aspectRatio.ratio >= 1 233 - let dotColor: Color = hasPortrait && currentIsLandscape ? .secondary : .white 234 - Circle() 235 - .fill(dotColor.opacity(index == currentPage ? 1.0 : distance == 1 ? 0.5 : distance == 2 ? 0.3 : 0.2)) 236 - .frame( 237 - width: distance <= 1 ? 6 : distance == 2 ? 4 : 3, 238 - height: distance <= 1 ? 6 : distance == 2 ? 4 : 3 239 - ) 240 - .animation(.easeInOut(duration: 0.2), value: currentPage) 241 - } 242 - } 243 - .padding(.vertical, 8) 244 - } 217 + GeometryReader { geo in 218 + let height = geo.size.width / carouselRatio 245 219 246 - // Alt text overlay — centered, tap to dismiss 247 - if showingAlt, let alt = photos[currentPage].alt, !alt.isEmpty { 248 - Color.black.opacity(0.6) 249 - .frame(maxWidth: .infinity, maxHeight: .infinity) 250 - .onTapGesture { 251 - withAnimation(.easeInOut(duration: 0.2)) { 252 - showingAlt = false 253 - } 254 - } 255 - Text(alt) 256 - .font(.subheadline) 257 - .foregroundStyle(.white) 258 - .multilineTextAlignment(.center) 259 - .padding(20) 260 - .frame(maxWidth: .infinity, maxHeight: .infinity) 261 - .allowsHitTesting(false) 220 + ZStack(alignment: .bottom) { 221 + TabView(selection: $currentPage) { 222 + ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in 223 + ZoomableImage( 224 + url: photo.fullsize, 225 + thumbURL: photo.thumb, 226 + aspectRatio: photo.aspectRatio.ratio, 227 + onDoubleTap: { point in doubleTapLike(at: point) } 228 + ) 229 + .tag(index) 262 230 } 263 - 264 - // ALT button — bottom right 265 - if let alt = photos[currentPage].alt, !alt.isEmpty { 266 - VStack { 267 - Spacer() 268 - HStack { 269 - Spacer() 270 - Button { 271 - withAnimation(.easeInOut(duration: 0.2)) { 272 - showingAlt.toggle() 273 - } 274 - } label: { 275 - Text("ALT") 276 - .font(.caption2.weight(.bold)) 277 - .padding(.horizontal, 6) 278 - .padding(.vertical, 3) 279 - .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 280 - .foregroundStyle(.white) 281 - } 282 - } 283 - .padding(8) 284 - } 231 + } 232 + .tabViewStyle(.page(indexDisplayMode: .never)) 233 + .overlay { 234 + if lr.action == .warnMedia, !gallery.labelRevealed { 235 + Rectangle().fill(Color(.secondarySystemBackground)) 285 236 } 237 + } 238 + .allowsHitTesting(lr.action != .warnMedia || gallery.labelRevealed) 286 239 287 - // Double-tap heart animations 288 - ForEach(hearts) { heart in 289 - DoubleTapHeartView(state: heart) 290 - .onChange(of: heart.isComplete) { 291 - hearts.removeAll { $0.isComplete } 292 - } 293 - } 240 + pageIndicator(photos: photos, hasPortrait: hasPortrait) 241 + altTextOverlay(photos: photos) 242 + altButton(photos: photos) 294 243 295 - // Media warning overlay 296 - if lr.action == .warnMedia && !gallery.labelRevealed { 297 - MediaWarningOverlay(name: lr.name) { 298 - withAnimation { gallery.labelRevealed = true } 244 + // Double-tap heart animations 245 + ForEach(hearts) { heart in 246 + DoubleTapHeartView(state: heart) 247 + .onChange(of: heart.isComplete) { 248 + hearts.removeAll { $0.isComplete } 299 249 } 300 - } 250 + } 251 + 252 + // Media warning overlay 253 + if lr.action == .warnMedia, !gallery.labelRevealed { 254 + MediaWarningOverlay(name: lr.name) { 255 + withAnimation { gallery.labelRevealed = true } 301 256 } 302 - .frame(height: height) 303 257 } 304 - .aspectRatio(carouselRatio, contentMode: .fit) 305 - .onChange(of: currentPage) { 306 - showingAlt = false 258 + } 259 + .frame(height: height) 260 + } 261 + .aspectRatio(carouselRatio, contentMode: .fit) 262 + .onAppear { 263 + prefetchCarousel(photos: photos, page: 0) 264 + } 265 + .onChange(of: currentPage) { 266 + showingAlt = false 267 + prefetchCarousel(photos: photos, page: currentPage) 268 + } 269 + .onDisappear { 270 + prefetcher.stopPrefetching() 271 + } 272 + } 273 + 274 + @ViewBuilder 275 + private func pageIndicator(photos: [GrainPhoto], hasPortrait: Bool) -> some View { 276 + if photos.count > 1 { 277 + HStack(spacing: 5) { 278 + let total = photos.count 279 + let maxVisible = 5 280 + let start = total <= maxVisible ? 0 : min(max(currentPage - 2, 0), total - maxVisible) 281 + let end = total <= maxVisible ? total : start + maxVisible 282 + 283 + ForEach(start ..< end, id: \.self) { index in 284 + let distance = abs(index - currentPage) 285 + let currentIsLandscape = photos[currentPage].aspectRatio.ratio >= 1 286 + let dotColor: Color = hasPortrait && currentIsLandscape ? .secondary : .white 287 + Circle() 288 + .fill(dotColor.opacity(index == currentPage ? 1.0 : distance == 1 ? 0.5 : distance == 2 ? 0.3 : 0.2)) 289 + .frame( 290 + width: distance <= 1 ? 6 : distance == 2 ? 4 : 3, 291 + height: distance <= 1 ? 6 : distance == 2 ? 4 : 3 292 + ) 293 + .animation(.easeInOut(duration: 0.2), value: currentPage) 307 294 } 308 295 } 296 + .padding(.vertical, 8) 297 + } 298 + } 309 299 310 - // Engagement row 311 - HStack(spacing: 16) { 312 - Button { 313 - guard !isFavoriting else { return } 314 - isFavoriting = true 315 - Task { 316 - await toggleFavorite() 317 - isFavoriting = false 300 + @ViewBuilder 301 + private func altTextOverlay(photos: [GrainPhoto]) -> some View { 302 + if showingAlt, let alt = photos[currentPage].alt, !alt.isEmpty { 303 + Color.black.opacity(0.6) 304 + .frame(maxWidth: .infinity, maxHeight: .infinity) 305 + .onTapGesture { 306 + withAnimation(.easeInOut(duration: 0.2)) { 307 + showingAlt = false 318 308 } 319 - } label: { 320 - HStack(spacing: 5) { 321 - Image(systemName: isFavorited ? "heart.fill" : "heart") 322 - .font(.system(size: 22)) 323 - Text("\(gallery.favCount ?? 0)") 309 + } 310 + Text(alt) 311 + .font(.subheadline) 312 + .foregroundStyle(.white) 313 + .multilineTextAlignment(.center) 314 + .padding(20) 315 + .frame(maxWidth: .infinity, maxHeight: .infinity) 316 + .allowsHitTesting(false) 317 + } 318 + } 319 + 320 + @ViewBuilder 321 + private func altButton(photos: [GrainPhoto]) -> some View { 322 + if let alt = photos[currentPage].alt, !alt.isEmpty { 323 + VStack { 324 + Spacer() 325 + HStack { 326 + Spacer() 327 + Button { 328 + withAnimation(.easeInOut(duration: 0.2)) { 329 + showingAlt.toggle() 330 + } 331 + } label: { 332 + Text("ALT") 333 + .font(.caption2.weight(.bold)) 334 + .padding(.horizontal, 6) 335 + .padding(.vertical, 3) 336 + .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 337 + .foregroundStyle(.white) 324 338 } 325 339 } 326 - .foregroundStyle(isFavorited ? Color("AccentColor") : .secondary) 340 + .padding(8) 341 + } 342 + } 343 + } 327 344 328 - Button { 329 - onNavigate() 330 - } label: { 331 - HStack(spacing: 5) { 332 - Image(systemName: "bubble.right") 333 - .font(.system(size: 20)) 334 - Text("\(gallery.commentCount ?? 0)") 335 - } 345 + private var engagementRow: some View { 346 + HStack(spacing: 16) { 347 + Button { 348 + guard !isFavoriting else { return } 349 + isFavoriting = true 350 + Task { 351 + await toggleFavorite() 352 + isFavoriting = false 336 353 } 337 - .foregroundStyle(.secondary) 354 + } label: { 355 + HStack(spacing: 5) { 356 + Image(systemName: isFavorited ? "heart.fill" : "heart") 357 + .font(.system(size: 22)) 358 + Text("\(gallery.favCount ?? 0)") 359 + } 360 + } 361 + .foregroundStyle(isFavorited ? Color("AccentColor") : .secondary) 338 362 339 - ShareLink(item: galleryShareURL) { 340 - Image(systemName: "paperplane") 363 + Button { 364 + onNavigate() 365 + } label: { 366 + HStack(spacing: 5) { 367 + Image(systemName: "bubble.right") 341 368 .font(.system(size: 20)) 342 - .rotationEffect(.degrees(shareWiggle ? -15 : 0)) 343 - .animation( 344 - shareWiggle 345 - ? .easeInOut(duration: 0.08).repeatCount(5, autoreverses: true) 346 - : .default, 347 - value: shareWiggle 348 - ) 369 + Text("\(gallery.commentCount ?? 0)") 349 370 } 350 - .foregroundStyle(.secondary) 351 - .disabled(didLongPressShare) 352 - .simultaneousGesture( 353 - LongPressGesture(minimumDuration: 0.5) 354 - .onEnded { _ in 355 - didLongPressShare = true 356 - UIPasteboard.general.url = galleryShareURL 357 - let generator = UIImpactFeedbackGenerator(style: .medium) 358 - generator.impactOccurred() 359 - shareWiggle = true 360 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 361 - shareWiggle = false 362 - } 363 - showCopiedToast = true 364 - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 365 - showCopiedToast = false 366 - } 367 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 368 - didLongPressShare = false 369 - } 370 - } 371 - ) 372 - 373 - Spacer() 374 371 } 375 - .font(.subheadline) 376 - .padding(.horizontal, 12) 377 - .padding(.top, 12) 378 - .padding(.bottom, 4) 372 + .foregroundStyle(.secondary) 379 373 380 - // EXIF info 381 - if let photos = gallery.items, !photos.isEmpty, 382 - let exif = photos[currentPage].exif, 383 - exif.hasDisplayableData { 384 - ExifInfoView(exif: exif) 385 - .padding(.horizontal, 12) 386 - .padding(.top, 8) 374 + ShareLink(item: galleryShareURL) { 375 + Image(systemName: "paperplane") 376 + .font(.system(size: 20)) 377 + .rotationEffect(.degrees(shareWiggle ? -15 : 0)) 378 + .animation( 379 + shareWiggle 380 + ? .easeInOut(duration: 0.08).repeatCount(5, autoreverses: true) 381 + : .default, 382 + value: shareWiggle 383 + ) 387 384 } 385 + .foregroundStyle(.secondary) 386 + .disabled(didLongPressShare) 387 + .simultaneousGesture( 388 + LongPressGesture(minimumDuration: 0.5) 389 + .onEnded { _ in 390 + didLongPressShare = true 391 + UIPasteboard.general.url = galleryShareURL 392 + let generator = UIImpactFeedbackGenerator(style: .medium) 393 + generator.impactOccurred() 394 + shareWiggle = true 395 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 396 + shareWiggle = false 397 + } 398 + showCopiedToast = true 399 + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 400 + showCopiedToast = false 401 + } 402 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 403 + didLongPressShare = false 404 + } 405 + } 406 + ) 388 407 389 - // Title & description 390 - VStack(alignment: .leading, spacing: 4) { 391 - Text(gallery.title ?? "") 392 - .font(.subheadline.weight(.semibold)) 393 - .lineLimit(1) 394 - .contentShape(Rectangle()) 395 - .onTapGesture { onNavigate() } 408 + Spacer() 409 + } 410 + .font(.subheadline) 411 + .padding(.horizontal, 12) 412 + .padding(.top, 12) 413 + .padding(.bottom, 4) 414 + } 415 + 416 + @ViewBuilder 417 + private func captionSection(lr: LabelResolution) -> some View { 418 + // EXIF info 419 + if let photos = gallery.items, !photos.isEmpty, 420 + let exif = photos[currentPage].exif, 421 + exif.hasDisplayableData 422 + { 423 + ExifInfoView(exif: exif) 424 + .padding(.horizontal, 12) 425 + .padding(.top, 8) 426 + } 427 + 428 + // Title & description 429 + VStack(alignment: .leading, spacing: 4) { 430 + Text(gallery.title ?? "") 431 + .font(.subheadline.weight(.semibold)) 432 + .lineLimit(1) 433 + .contentShape(Rectangle()) 434 + .onTapGesture { onNavigate() } 396 435 397 - if let description = gallery.description, !description.isEmpty { 398 - ExpandableDescriptionView( 399 - text: description, 400 - onMentionTap: onProfileTap, 401 - onHashtagTap: onHashtagTap 402 - ) 403 - } 436 + if let description = gallery.description, !description.isEmpty { 437 + ExpandableDescriptionView( 438 + text: description, 439 + onMentionTap: onProfileTap, 440 + onHashtagTap: onHashtagTap 441 + ) 442 + } 404 443 405 - if lr.action == .badge { 406 - LabelBadge(name: lr.name) 407 - } 444 + if lr.action == .badge { 445 + LabelBadge(name: lr.name) 408 446 } 409 - .padding(.horizontal, 12) 410 - .padding(.top, 8) 411 - .padding(.bottom, 16) 412 447 } 413 - .overlay { 414 - if showCopiedToast { 415 - HStack(spacing: 6) { 416 - Image(systemName: "doc.on.doc.fill") 417 - .font(.caption) 418 - Text("Link copied") 419 - .font(.subheadline.weight(.medium)) 420 - } 421 - .padding(.horizontal, 16) 422 - .padding(.vertical, 10) 423 - .background(.ultraThinMaterial, in: Capsule()) 424 - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 425 - .transition(.scale.combined(with: .opacity)) 448 + .padding(.horizontal, 12) 449 + .padding(.top, 8) 450 + .padding(.bottom, 16) 451 + } 452 + 453 + @ViewBuilder 454 + private var copiedToastOverlay: some View { 455 + if showCopiedToast { 456 + HStack(spacing: 6) { 457 + Image(systemName: "doc.on.doc.fill") 458 + .font(.caption) 459 + Text("Link copied") 460 + .font(.subheadline.weight(.medium)) 426 461 } 462 + .padding(.horizontal, 16) 463 + .padding(.vertical, 10) 464 + .background(.ultraThinMaterial, in: Capsule()) 465 + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 466 + .transition(.scale.combined(with: .opacity)) 427 467 } 428 - .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 468 + } 469 + 470 + private func prefetchCarousel(photos: [GrainPhoto], page: Int) { 471 + let input = photos.map { (thumb: $0.thumb, fullsize: $0.fullsize) } 472 + let plan = ImagePrefetchPlanning.carouselPrefetchRequests(photos: input, currentPage: page) 473 + prefetcher.startPrefetching(with: plan.all) 429 474 } 430 475 431 476 private func doubleTapLike(at point: CGPoint) { ··· 479 524 } 480 525 } 481 526 527 + #Preview { 528 + @Previewable @State var gallery = PreviewData.gallery1 529 + ScrollView { 530 + GalleryCardView( 531 + gallery: $gallery, 532 + client: XRPCClient(baseURL: AuthManager.serverURL) 533 + ) 534 + GalleryCardView( 535 + gallery: .constant(PreviewData.gallery2), 536 + client: XRPCClient(baseURL: AuthManager.serverURL) 537 + ) 538 + } 539 + .environment(AuthManager()) 540 + .environment(StoryStatusCache()) 541 + .environment(ViewedStoryStorage()) 542 + .environment(LabelDefinitionsCache()) 543 + }
+49 -30
Grain/Views/Components/MentionAutocomplete.swift
··· 4 4 let handle: String 5 5 let displayName: String? 6 6 let avatar: String? 7 - var id: String { handle } 7 + var id: String { 8 + handle 9 + } 8 10 } 9 11 10 12 /// Detects @mention queries in text and provides autocomplete suggestions. ··· 12 14 @MainActor 13 15 final class MentionAutocompleteState { 14 16 var suggestions: [MentionSuggestion] = [] 15 - var isActive: Bool { activeQuery != nil } 17 + var isActive: Bool { 18 + activeQuery != nil 19 + } 20 + 16 21 private(set) var activeQuery: String? 17 22 private var searchTask: Task<Void, Never>? 18 23 ··· 52 57 private func extractMentionQuery(from text: String) -> String? { 53 58 // Find the last @ that's either at the start or preceded by whitespace 54 59 guard let atIndex = text.lastIndex(of: "@") else { return nil } 55 - let beforeAt = text[text.startIndex..<atIndex] 56 - if !beforeAt.isEmpty && !beforeAt.last!.isWhitespace { return nil } 60 + let beforeAt = text[text.startIndex ..< atIndex] 61 + if !beforeAt.isEmpty, !beforeAt.last!.isWhitespace { return nil } 57 62 let after = String(text[text.index(after: atIndex)...]) 58 63 // Must not contain spaces (still typing the handle) 59 64 guard !after.contains(" ") else { return nil } ··· 78 83 79 84 guard let (data, _) = try? await URLSession.shared.data(from: url), 80 85 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 81 - let actors = json["actors"] as? [[String: Any]] else { 86 + let actors = json["actors"] as? [[String: Any]] 87 + else { 82 88 return 83 89 } 84 90 ··· 100 106 let onSelect: (MentionSuggestion) -> Void 101 107 102 108 var body: some View { 103 - if state.isActive && !state.suggestions.isEmpty { 109 + if state.isActive, !state.suggestions.isEmpty { 104 110 ScrollView(.horizontal, showsIndicators: false) { 105 - GlassEffectContainer(spacing: 8) { 106 - HStack(spacing: 8) { 107 - ForEach(state.suggestions) { suggestion in 108 - Button { 109 - onSelect(suggestion) 110 - } label: { 111 - HStack(spacing: 6) { 112 - AvatarView(url: suggestion.avatar, size: 24) 113 - VStack(alignment: .leading, spacing: 0) { 114 - Text(suggestion.displayName ?? suggestion.handle) 115 - .font(.caption.weight(.medium)) 116 - .foregroundStyle(.primary) 117 - .lineLimit(1) 118 - Text("@\(suggestion.handle)") 119 - .font(.caption2) 120 - .foregroundStyle(.secondary) 121 - .lineLimit(1) 122 - } 111 + GlassEffectContainer(spacing: 8) { 112 + HStack(spacing: 8) { 113 + ForEach(state.suggestions) { suggestion in 114 + Button { 115 + onSelect(suggestion) 116 + } label: { 117 + HStack(spacing: 6) { 118 + AvatarView(url: suggestion.avatar, size: 24) 119 + VStack(alignment: .leading, spacing: 0) { 120 + Text(suggestion.displayName ?? suggestion.handle) 121 + .font(.caption.weight(.medium)) 122 + .foregroundStyle(.primary) 123 + .lineLimit(1) 124 + Text("@\(suggestion.handle)") 125 + .font(.caption2) 126 + .foregroundStyle(.secondary) 127 + .lineLimit(1) 123 128 } 124 - .padding(.horizontal, 10) 125 - .padding(.vertical, 6) 126 - .glassEffect(.regular.interactive()) 127 129 } 128 - .buttonStyle(.plain) 130 + .padding(.horizontal, 10) 131 + .padding(.vertical, 6) 132 + .glassEffect(.regular.interactive()) 129 133 } 134 + .buttonStyle(.plain) 130 135 } 131 136 } 132 - .padding(.horizontal, 12) 133 - .padding(.vertical, 6) 134 137 } 138 + .padding(.horizontal, 12) 139 + .padding(.vertical, 6) 140 + } 135 141 } 136 142 } 137 143 } 144 + 145 + #Preview { 146 + let state = MentionAutocompleteState() 147 + state.suggestions = [ 148 + MentionSuggestion(handle: "alice.grain.social", displayName: "Alice", avatar: nil), 149 + MentionSuggestion(handle: "bob.grain.social", displayName: "Bob", avatar: nil), 150 + ] 151 + return VStack { 152 + Spacer() 153 + MentionSuggestionOverlay(state: state) { _ in } 154 + } 155 + .frame(height: 200) 156 + }
+10 -1
Grain/Views/Components/ReportView.swift
··· 42 42 43 43 Section("Details (optional)") { 44 44 TextField("Provide additional context...", text: $reason, axis: .vertical) 45 - .lineLimit(3...6) 45 + .lineLimit(3 ... 6) 46 46 } 47 47 48 48 if let error { ··· 108 108 isSubmitting = false 109 109 } 110 110 } 111 + 112 + #Preview { 113 + ReportView( 114 + client: XRPCClient(baseURL: AuthManager.serverURL), 115 + subjectUri: "at://did:plc:preview/social.grain.gallery/r1", 116 + subjectCid: "cid" 117 + ) 118 + .environment(AuthManager()) 119 + }
+32 -20
Grain/Views/Components/RichTextView.swift
··· 11 11 var onHashtagTap: ((String) -> Void)? 12 12 13 13 private var attributedString: AttributedString { 14 - let segments: [Segment] 15 - if let facets, !facets.isEmpty { 16 - segments = segmentsFromFacets(text: text, facets: facets) 14 + let segments: [Segment] = if let facets, !facets.isEmpty { 15 + segmentsFromFacets(text: text, facets: facets) 17 16 } else { 18 - segments = segmentsFromRegex(text: text) 17 + segmentsFromRegex(text: text) 19 18 } 20 19 21 20 var result = AttributedString() 22 21 for segment in segments { 23 22 var part: AttributedString 24 23 switch segment { 25 - case .plain(let str): 24 + case let .plain(str): 26 25 part = AttributedString(str) 27 26 part.foregroundColor = color 28 - case .link(let str, let url): 27 + case let .link(str, url): 29 28 part = AttributedString(str) 30 29 if let linkURL = URL(string: url) { 31 30 part.link = linkURL 32 31 } 33 32 part.foregroundColor = Color("AccentColor") 34 - case .mention(let str, let did): 33 + case let .mention(str, did): 35 34 part = AttributedString(str) 36 35 let encoded = did.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? did 37 36 part.link = URL(string: "grain-mention://\(encoded)") 38 37 part.foregroundColor = Color("AccentColor") 39 - case .hashtag(let str, let tag): 38 + case let .hashtag(str, tag): 40 39 part = AttributedString(str) 41 40 let encoded = tag.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? tag 42 41 part.link = URL(string: "grain-hashtag://\(encoded)") ··· 85 84 guard start >= cursor, end > start else { continue } 86 85 87 86 if start > cursor { 88 - let plain = String(bytes: utf8[cursor..<start], encoding: .utf8) ?? "" 87 + let plain = String(bytes: utf8[cursor ..< start], encoding: .utf8) ?? "" 89 88 segments.append(.plain(plain)) 90 89 } 91 90 92 - let slice = String(bytes: utf8[start..<end], encoding: .utf8) ?? "" 91 + let slice = String(bytes: utf8[start ..< end], encoding: .utf8) ?? "" 93 92 if let feature = facet.features.first { 94 93 switch feature { 95 - case .link(let uri): 94 + case let .link(uri): 96 95 segments.append(.link(slice, url: uri)) 97 - case .mention(let did): 96 + case let .mention(did): 98 97 segments.append(.mention(slice, did: did)) 99 - case .tag(let tag): 98 + case let .tag(tag): 100 99 segments.append(.hashtag(slice, tag: tag)) 101 100 } 102 101 } else { ··· 129 128 130 129 if let regex = try? NSRegularExpression(pattern: urlPattern) { 131 130 let nsRange = NSRange(text.startIndex..., in: text) 132 - for m in regex.matches(in: text, range: nsRange) { 133 - if let range = Range(m.range, in: text) { 131 + for matchResult in regex.matches(in: text, range: nsRange) { 132 + if let range = Range(matchResult.range, in: text) { 134 133 let str = String(text[range]) 135 134 matches.append(Match(range: range, segment: .link(str, url: str))) 136 135 } ··· 139 138 140 139 if let regex = try? NSRegularExpression(pattern: mentionPattern) { 141 140 let nsRange = NSRange(text.startIndex..., in: text) 142 - for m in regex.matches(in: text, range: nsRange) { 143 - if let range = Range(m.range, in: text) { 141 + for matchResult in regex.matches(in: text, range: nsRange) { 142 + if let range = Range(matchResult.range, in: text) { 144 143 if matches.contains(where: { $0.range.overlaps(range) }) { continue } 145 144 let str = String(text[range]) 146 145 matches.append(Match(range: range, segment: .mention(str, did: String(str.dropFirst())))) ··· 150 149 151 150 if let regex = try? NSRegularExpression(pattern: hashtagPattern) { 152 151 let nsRange = NSRange(text.startIndex..., in: text) 153 - for m in regex.matches(in: text, range: nsRange) { 154 - if let range = Range(m.range, in: text) { 152 + for matchResult in regex.matches(in: text, range: nsRange) { 153 + if let range = Range(matchResult.range, in: text) { 155 154 if matches.contains(where: { $0.range.overlaps(range) }) { continue } 156 155 let str = String(text[range]) 157 156 matches.append(Match(range: range, segment: .hashtag(str, tag: String(str.dropFirst())))) ··· 166 165 167 166 for match in matches { 168 167 if match.range.lowerBound > cursor { 169 - segments.append(.plain(String(text[cursor..<match.range.lowerBound]))) 168 + segments.append(.plain(String(text[cursor ..< match.range.lowerBound]))) 170 169 } 171 170 segments.append(match.segment) 172 171 cursor = match.range.upperBound ··· 186 185 case mention(String, did: String) 187 186 case hashtag(String, tag: String) 188 187 } 188 + 189 + #Preview { 190 + VStack(alignment: .leading, spacing: 12) { 191 + RichTextView(text: "Plain text with no links.") 192 + RichTextView( 193 + text: "Check out @alice.grain.social and the #35mm tag.", 194 + onMentionTap: { _ in }, 195 + onHashtagTap: { _ in } 196 + ) 197 + RichTextView(text: "Visit https://grain.social for more.") 198 + } 199 + .padding() 200 + }
+13 -1
Grain/Views/Components/SuggestedFollowsView.swift
··· 85 85 guard let authContext = await auth.authContext() else { return } 86 86 let record = AnyCodable([ 87 87 "subject": item.did, 88 - "createdAt": DateFormatting.nowISO() 88 + "createdAt": DateFormatting.nowISO(), 89 89 ]) 90 90 let repo = TokenStorage.userDID ?? "" 91 91 do { ··· 96 96 } 97 97 } 98 98 } 99 + 100 + #Preview { 101 + @Previewable @State var suggestions = [ 102 + SuggestedItem(did: "did:plc:a", handle: "alice.bsky.social", displayName: "Alice", description: "Photographer"), 103 + SuggestedItem(did: "did:plc:b", handle: "bob.bsky.social", displayName: "Bob"), 104 + ] 105 + SuggestedFollowsView( 106 + client: XRPCClient(baseURL: AuthManager.serverURL), 107 + suggestions: $suggestions 108 + ) 109 + .environment(AuthManager()) 110 + }
+28 -6
Grain/Views/Components/ZoomableImage.swift
··· 1 + import Nuke 1 2 import NukeUI 2 3 import SwiftUI 3 4 ··· 79 80 return view 80 81 } 81 82 82 - func updateUIView(_ uiView: UIView, context: Context) {} 83 + func updateUIView(_: UIView, context _: Context) {} 83 84 84 85 func makeCoordinator() -> Coordinator { 85 86 Coordinator(self) ··· 94 95 self.parent = parent 95 96 } 96 97 97 - nonisolated func gestureRecognizer( 98 - _ gestureRecognizer: UIGestureRecognizer, 98 + @MainActor func gestureRecognizer( 99 + _: UIGestureRecognizer, 99 100 shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer 100 101 ) -> Bool { 101 102 let dominated = otherGestureRecognizer.view is UIScrollView ··· 135 136 switch gesture.state { 136 137 case .changed: 137 138 guard parent.zoomState.scale > 1 else { return } 138 - let t = gesture.translation(in: gesture.view) 139 - parent.zoomState.offset = CGSize(width: t.x, height: t.y) 139 + let translation = gesture.translation(in: gesture.view) 140 + parent.zoomState.offset = CGSize(width: translation.x, height: translation.y) 140 141 case .ended, .cancelled: 141 142 parent.onEnded() 142 143 default: ··· 150 151 151 152 struct ZoomableImage: View { 152 153 let url: String 154 + var thumbURL: String? 153 155 let aspectRatio: CGFloat 154 156 var onDoubleTap: ((CGPoint) -> Void)? 155 157 @Environment(ImageZoomState.self) private var zoomState: ImageZoomState? ··· 157 159 @State private var snapBackTask: Task<Void, Never>? 158 160 159 161 var body: some View { 160 - LazyImage(url: URL(string: url)) { state in 162 + LazyImage(request: ImageRequest(url: URL(string: url), priority: .veryHigh)) { state in 161 163 if let image = state.image { 162 164 image 163 165 .resizable() 164 166 .aspectRatio(aspectRatio, contentMode: .fit) 167 + } else if let thumbURL { 168 + LazyImage(url: URL(string: thumbURL)) { thumbState in 169 + if let thumb = thumbState.image { 170 + thumb 171 + .resizable() 172 + .aspectRatio(aspectRatio, contentMode: .fit) 173 + .blur(radius: 20) 174 + .clipped() 175 + } else { 176 + Rectangle() 177 + .fill(.quaternary) 178 + .aspectRatio(aspectRatio, contentMode: .fit) 179 + } 180 + } 165 181 } else { 166 182 Rectangle() 167 183 .fill(.quaternary) ··· 212 228 } 213 229 } 214 230 231 + #Preview { 232 + ZoomableImage(url: "", aspectRatio: 4 / 3) 233 + .environment(ImageZoomState()) 234 + .frame(maxWidth: .infinity) 235 + .padding() 236 + }
+495 -281
Grain/Views/Create/CreateGalleryView.swift
··· 23 23 @State private var photoItems: [PhotoItem] = [] 24 24 @State private var mentionState = MentionAutocompleteState() 25 25 @State private var postToBluesky = false 26 + @State private var selectedLabels: Set<String> = [] 27 + @State private var selectedPhotoID: UUID? 28 + @State private var photoLocationResult: NominatimResult? 29 + @State private var sendExif = true 26 30 @State private var includeExif = true 27 - @State private var selectedLabels: Set<String> = [] 31 + @AppStorage("privacy.includeLocation") private var includeLocation = true 32 + @AppStorage("privacy.includeCameraData") private var includeCameraData = true 28 33 29 34 let client: XRPCClient 30 35 var onCreated: (() -> Void)? ··· 35 40 var body: some View { 36 41 NavigationStack { 37 42 Form { 38 - Section("Photos") { 39 - PhotosPicker( 40 - selection: $selectedPhotos, 41 - maxSelectionCount: 20, 42 - matching: .images 43 - ) { 44 - Label("Select Photos", systemImage: "photo.on.rectangle.angled") 45 - } 46 - 47 - Button { 48 - showCamera = true 49 - } label: { 50 - Label("Take Photo", systemImage: "camera") 51 - } 52 - 53 - if !photoItems.isEmpty { 54 - ReorderablePhotoStrip(items: $photoItems) 55 - } 56 - } 57 - 58 - if !photoItems.isEmpty { 59 - Section("Alt Text") { 60 - ForEach($photoItems) { $item in 61 - HStack(alignment: .top, spacing: 12) { 62 - Image(uiImage: item.thumbnail) 63 - .resizable() 64 - .scaledToFill() 65 - .frame(width: 60, height: 60) 66 - .clipShape(RoundedRectangle(cornerRadius: 8)) 67 - 68 - TextField("Describe this photo...", text: $item.alt, axis: .vertical) 69 - .font(.subheadline) 70 - .lineLimit(2...4) 71 - } 72 - .padding(.vertical, 4) 73 - } 74 - } 75 - } 76 - 77 - Section(header: Text("Details"), footer: Text("Title is required.")) { 78 - VStack(alignment: .leading, spacing: 4) { 79 - TextField("Add a title...", text: $title) 80 - Text("\(title.count)/\(maxTitle)") 81 - .font(.caption2) 82 - .foregroundStyle(title.count > maxTitle ? .red : .secondary) 83 - .frame(maxWidth: .infinity, alignment: .trailing) 84 - } 85 - 86 - VStack(alignment: .leading, spacing: 4) { 87 - TextField("Add a description. Supports @mentions, #hashtags, and links.", text: $description, axis: .vertical) 88 - .lineLimit(3...6) 89 - .onChange(of: description) { mentionState.update(text: description) } 90 - Text("\(description.count)/\(maxDescription)") 91 - .font(.caption2) 92 - .foregroundStyle(description.count > maxDescription ? .red : .secondary) 93 - .frame(maxWidth: .infinity, alignment: .trailing) 94 - } 95 - } 96 - 97 - Section("Location") { 98 - if let loc = resolvedLocation { 99 - HStack { 100 - Label(loc.name, systemImage: "mappin.and.ellipse") 101 - .font(.subheadline) 102 - .lineLimit(1) 103 - Spacer() 104 - Button { 105 - resolvedLocation = nil 106 - locationQuery = "" 107 - } label: { 108 - Image(systemName: "xmark.circle.fill") 109 - .foregroundStyle(.secondary) 110 - } 111 - } 112 - } else { 113 - HStack { 114 - Image(systemName: "magnifyingglass") 115 - .foregroundStyle(.secondary) 116 - TextField("Search for a location...", text: $locationQuery) 117 - .textInputAutocapitalization(.never) 118 - .onChange(of: locationQuery) { 119 - locationSearchTask?.cancel() 120 - let query = locationQuery 121 - locationSearchTask = Task { 122 - try? await Task.sleep(for: .milliseconds(300)) 123 - guard !Task.isCancelled else { return } 124 - await searchLocation(query: query) 125 - } 126 - } 127 - if isSearchingLocation { 128 - ProgressView() 129 - .controlSize(.small) 130 - } 131 - } 132 - 133 - ForEach(locationSuggestions, id: \.placeId) { result in 134 - Button { 135 - selectLocation(result) 136 - } label: { 137 - VStack(alignment: .leading, spacing: 2) { 138 - Text(result.name) 139 - .font(.subheadline) 140 - .foregroundStyle(.primary) 141 - if let context = result.context { 142 - Text(context) 143 - .font(.caption) 144 - .foregroundStyle(.secondary) 145 - } 146 - } 147 - } 148 - } 149 - } 150 - } 151 - 43 + photosSection 44 + gallerySection 45 + photoEditorSection 152 46 ContentLabelPicker(selectedLabels: $selectedLabels) 153 - 154 47 Section { 155 48 Toggle("Post to Bluesky", isOn: $postToBluesky) 156 49 } 157 - 158 - if let errorMessage { 159 - Section { 160 - Text(errorMessage) 161 - .foregroundStyle(.red) 162 - .font(.caption) 163 - } 164 - } 50 + cameraDataSection 51 + errorSection 165 52 } 53 + .onAppear { sendExif = includeCameraData } 166 54 .safeAreaInset(edge: .bottom) { 167 55 MentionSuggestionOverlay(state: mentionState) { suggestion in 168 56 mentionState.complete(handle: suggestion.handle, in: &description) ··· 171 59 .onChange(of: selectedPhotos) { 172 60 Task { 173 61 await loadPickerPhotos() 62 + if let id = selectedPhotoID, !photoItems.contains(where: { $0.id == id }) { 63 + selectedPhotoID = photoItems.first?.id 64 + } else if selectedPhotoID == nil { 65 + selectedPhotoID = photoItems.first?.id 66 + } 174 67 await detectLocation() 175 68 } 176 69 } 177 70 .fullScreenCover(isPresented: $showCamera) { 178 71 CameraPicker { image in 179 72 let thumb = PhotoItem.makeThumbnail(from: image) 180 - photoItems.append(PhotoItem(thumbnail: thumb, source: .camera(image))) 73 + let item = PhotoItem(thumbnail: thumb, source: .camera(image)) 74 + photoItems.append(item) 75 + if selectedPhotoID == nil { selectedPhotoID = item.id } 181 76 } 182 77 .ignoresSafeArea() 183 78 } ··· 212 107 } 213 108 } 214 109 110 + // MARK: - Form Sections 111 + 112 + private var photosSection: some View { 113 + Section("Photos") { 114 + PhotosPicker( 115 + selection: $selectedPhotos, 116 + maxSelectionCount: 20, 117 + matching: .images 118 + ) { 119 + Label("Select Photos", systemImage: "photo.on.rectangle.angled") 120 + } 121 + 122 + if photoItems.isEmpty { 123 + Button { 124 + showCamera = true 125 + } label: { 126 + Label("Take Photo", systemImage: "camera") 127 + } 128 + } 129 + } 130 + } 131 + 132 + @ViewBuilder 133 + private var photoEditorSection: some View { 134 + if !photoItems.isEmpty { 135 + Section { 136 + PhotoEditor( 137 + items: $photoItems, 138 + selectedPhotoID: $selectedPhotoID, 139 + sendExif: sendExif 140 + ) 141 + } 142 + } 143 + } 144 + 145 + private var gallerySection: some View { 146 + Section("Gallery") { 147 + VStack(alignment: .leading, spacing: 4) { 148 + TextField("Add a title (required)...", text: $title) 149 + Text("\(title.count)/\(maxTitle)") 150 + .font(.caption2) 151 + .foregroundStyle(title.count > maxTitle ? .red : .secondary) 152 + .frame(maxWidth: .infinity, alignment: .trailing) 153 + } 154 + 155 + VStack(alignment: .leading, spacing: 4) { 156 + TextField("Add a description. Supports @mentions, #hashtags, and links.", text: $description, axis: .vertical) 157 + .lineLimit(3 ... 6) 158 + .onChange(of: description) { mentionState.update(text: description) } 159 + Text("\(description.count)/\(maxDescription)") 160 + .font(.caption2) 161 + .foregroundStyle(description.count > maxDescription ? .red : .secondary) 162 + .frame(maxWidth: .infinity, alignment: .trailing) 163 + } 164 + 165 + locationRow 166 + 167 + if !photoItems.isEmpty { 168 + let filled = photoItems.count(where: { !$0.alt.trimmingCharacters(in: .whitespaces).isEmpty }) 169 + HStack { 170 + Label("Alt Text", systemImage: "text.below.photo") 171 + Spacer() 172 + Text("\(filled)/\(photoItems.count)") 173 + .foregroundStyle(.secondary) 174 + } 175 + .font(.subheadline) 176 + } 177 + } 178 + } 179 + 180 + @ViewBuilder 181 + private var locationRow: some View { 182 + if let loc = resolvedLocation { 183 + HStack { 184 + Label(loc.name, systemImage: "mappin.and.ellipse") 185 + .font(.subheadline) 186 + .lineLimit(1) 187 + Spacer() 188 + Button { 189 + resolvedLocation = nil 190 + locationQuery = "" 191 + } label: { 192 + Image(systemName: "xmark.circle.fill") 193 + .foregroundStyle(.secondary) 194 + } 195 + } 196 + } else { 197 + if let photoLoc = photoLocationResult { 198 + Button { selectLocation(photoLoc) } label: { 199 + HStack(spacing: 10) { 200 + Image(systemName: "location.fill") 201 + .foregroundStyle(.secondary) 202 + .frame(width: 20) 203 + VStack(alignment: .leading, spacing: 1) { 204 + Text("Use photo location") 205 + .font(.subheadline) 206 + Text(photoLoc.name) 207 + .font(.caption) 208 + .foregroundStyle(.secondary) 209 + } 210 + } 211 + } 212 + .foregroundStyle(.primary) 213 + } 214 + locationSearchField 215 + ForEach(locationSuggestions, id: \.placeId) { result in 216 + Button { 217 + selectLocation(result) 218 + } label: { 219 + VStack(alignment: .leading, spacing: 2) { 220 + Text(result.name) 221 + .font(.subheadline) 222 + .foregroundStyle(.primary) 223 + if let context = result.context { 224 + Text(context) 225 + .font(.caption) 226 + .foregroundStyle(.secondary) 227 + } 228 + } 229 + } 230 + } 231 + } 232 + } 233 + 234 + private var locationSearchField: some View { 235 + HStack { 236 + Image(systemName: "magnifyingglass") 237 + .foregroundStyle(.secondary) 238 + TextField("Search for a location...", text: $locationQuery) 239 + .textInputAutocapitalization(.never) 240 + .onChange(of: locationQuery) { 241 + locationSearchTask?.cancel() 242 + let query = locationQuery 243 + locationSearchTask = Task { 244 + try? await Task.sleep(for: .milliseconds(300)) 245 + guard !Task.isCancelled else { return } 246 + await searchLocation(query: query) 247 + } 248 + } 249 + if isSearchingLocation { 250 + ProgressView() 251 + .controlSize(.small) 252 + } 253 + } 254 + } 255 + 256 + @ViewBuilder 257 + private var cameraDataSection: some View { 258 + if !photoItems.isEmpty { 259 + Section { 260 + Toggle("Include camera data", isOn: $sendExif) 261 + } footer: { 262 + Text("Camera make, model, lens, and exposure settings.") 263 + } 264 + } 265 + } 266 + 267 + @ViewBuilder 268 + private var errorSection: some View { 269 + if let errorMessage { 270 + Section { 271 + Text(errorMessage) 272 + .foregroundStyle(.red) 273 + .font(.caption) 274 + } 275 + } 276 + } 277 + 215 278 // MARK: - Photo Loading 216 279 217 280 private func loadPickerPhotos() async { ··· 220 283 221 284 // Remove picker items that are no longer in the selection 222 285 photoItems.removeAll { item in 223 - guard case .picker(let pickerItem) = item.source else { return false } 286 + guard case let .picker(pickerItem) = item.source else { return false } 224 287 guard let id = pickerItem.itemIdentifier else { return true } 225 288 return !selectedIDs.contains(id) 226 289 } 227 290 228 291 // Find which picker items are already represented 229 292 let existingIDs = Set(photoItems.compactMap { item -> String? in 230 - guard case .picker(let pickerItem) = item.source else { return nil } 293 + guard case let .picker(pickerItem) = item.source else { return nil } 231 294 return pickerItem.itemIdentifier 232 295 }) 233 296 234 297 // Only load and append truly new selections 235 298 for item in selectedPhotos where !(item.itemIdentifier.map { existingIDs.contains($0) } ?? false) { 236 299 if let data = try? await item.loadTransferable(type: Data.self), 237 - let image = UIImage(data: data) { 300 + let image = UIImage(data: data) 301 + { 238 302 let thumb = PhotoItem.makeThumbnail(from: image) 239 - photoItems.append(PhotoItem(thumbnail: thumb, source: .picker(item))) 303 + let exif = makeExifSummary(from: data) 304 + photoItems.append(PhotoItem(thumbnail: thumb, source: .picker(item), exifSummary: exif)) 240 305 } 241 306 } 242 307 } 243 308 244 309 private func detectLocation() async { 245 - // Don't overwrite a manually-selected location 246 - guard resolvedLocation == nil else { return } 247 - 310 + // Always extract GPS from the first photo so "Use photo location" can be offered 311 + photoLocationResult = nil 248 312 for item in photoItems { 249 - guard case .picker(let pickerItem) = item.source, 313 + guard case let .picker(pickerItem) = item.source, 250 314 let data = try? await pickerItem.loadTransferable(type: Data.self), 251 315 let gps = ImageProcessing.extractGPS(from: data) else { continue } 252 316 253 317 if let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 254 - selectLocation(result) 318 + photoLocationResult = result 319 + if includeLocation, resolvedLocation == nil { 320 + selectLocation(result) 321 + } 255 322 } 256 323 break 257 324 } ··· 278 345 errorMessage = nil 279 346 280 347 do { 281 - struct ProcessedPhoto { 282 - let blob: BlobRef 283 - let aspectRatio: AspectRatio 284 - let exif: [String: AnyCodable]? 285 - } 286 - 287 - var processed: [ProcessedPhoto] = [] 288 - let altTexts = photoItems.map { $0.alt } 289 - 290 - for item in photoItems { 291 - switch item.source { 292 - case .picker(let pickerItem): 293 - guard let data = try await pickerItem.loadTransferable(type: Data.self), 294 - let original = UIImage(data: data) else { continue } 295 - let exif = extractExif(from: data) 296 - let (resized, size) = ImageProcessing.resizeImage(original, maxDimension: 2000, maxBytes: 900_000) 297 - logger.info("Uploading \(resized.count) bytes, \(Int(size.width))x\(Int(size.height))") 298 - let response = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: authContext) 299 - processed.append(ProcessedPhoto( 300 - blob: response.blob, 301 - aspectRatio: AspectRatio(width: Int(size.width), height: Int(size.height)), 302 - exif: exif 303 - )) 304 - 305 - case .camera(let image): 306 - let (resized, size) = ImageProcessing.resizeImage(image, maxDimension: 2000, maxBytes: 900_000) 307 - logger.info("Uploading camera photo \(resized.count) bytes, \(Int(size.width))x\(Int(size.height))") 308 - let response = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: authContext) 309 - processed.append(ProcessedPhoto( 310 - blob: response.blob, 311 - aspectRatio: AspectRatio(width: Int(size.width), height: Int(size.height)), 312 - exif: nil 313 - )) 314 - } 315 - } 316 - 317 - // 2. Create photo records + EXIF records 348 + let altTexts = photoItems.map(\.alt) 349 + let processed = try await processGalleryPhotos(items: photoItems, client: client, authContext: authContext, skipExif: !sendExif) 318 350 let now = DateFormatting.nowISO() 319 - var photoUris: [String] = [] 320 - for (index, photo) in processed.enumerated() { 321 - let blobDict: [String: AnyCodable] = [ 322 - "$type": AnyCodable(photo.blob.type ?? "blob"), 323 - "ref": AnyCodable(["$link": AnyCodable(photo.blob.ref?.link ?? "")] as [String: AnyCodable]), 324 - "mimeType": AnyCodable(photo.blob.mimeType ?? "image/jpeg"), 325 - "size": AnyCodable(photo.blob.size ?? 0) 326 - ] 327 - var photoRecord: [String: AnyCodable] = [ 328 - "photo": AnyCodable(blobDict), 329 - "aspectRatio": AnyCodable(["width": AnyCodable(photo.aspectRatio.width), "height": AnyCodable(photo.aspectRatio.height)] as [String: AnyCodable]), 330 - "createdAt": AnyCodable(now) 331 - ] 332 - let alt = altTexts[index].trimmingCharacters(in: .whitespacesAndNewlines) 333 - if !alt.isEmpty { 334 - photoRecord["alt"] = AnyCodable(alt) 335 - } 336 - let result = try await client.createRecord( 337 - collection: "social.grain.photo", 338 - repo: repo, 339 - record: AnyCodable(photoRecord), 340 - auth: authContext 341 - ) 342 - guard let uri = result.uri else { continue } 343 - photoUris.append(uri) 344 - 345 - // Create EXIF record if we extracted metadata and user has it enabled 346 - if includeExif, var exif = photo.exif { 347 - exif["photo"] = AnyCodable(uri) 348 - exif["createdAt"] = AnyCodable(now) 349 - _ = try await client.createRecord( 350 - collection: "social.grain.photo.exif", 351 - repo: repo, 352 - record: AnyCodable(exif), 353 - auth: authContext 354 - ) 355 - } 356 - } 351 + let photoUris = try await createGalleryPhotoRecords( 352 + processed: processed, 353 + altTexts: altTexts, 354 + now: now, 355 + repo: repo, 356 + client: client, 357 + authContext: authContext, 358 + includeExif: includeCameraData 359 + ) 357 360 358 361 // 3. Create gallery record with pre-resolved location 359 362 var galleryRecord: [String: AnyCodable] = [ 360 363 "title": AnyCodable(title), 361 - "createdAt": AnyCodable(now) 364 + "createdAt": AnyCodable(now), 362 365 ] 363 366 if !description.isEmpty { galleryRecord["description"] = AnyCodable(description) } 364 367 if !selectedLabels.isEmpty { 365 368 let labelValues = selectedLabels.map { ["val": AnyCodable($0)] as [String: AnyCodable] } 366 369 galleryRecord["labels"] = AnyCodable([ 367 370 "$type": AnyCodable("com.atproto.label.defs#selfLabels"), 368 - "values": AnyCodable(labelValues as [[String: AnyCodable]]) 371 + "values": AnyCodable(labelValues as [[String: AnyCodable]]), 369 372 ] as [String: AnyCodable]) 370 373 } 371 374 if let loc = resolvedLocation { 372 375 galleryRecord["location"] = AnyCodable([ 373 376 "value": AnyCodable(loc.h3), 374 - "name": AnyCodable(loc.name) 377 + "name": AnyCodable(loc.name), 375 378 ] as [String: AnyCodable]) 376 379 if let addr = loc.address { 377 380 galleryRecord["address"] = AnyCodable(addr) ··· 391 394 "gallery": AnyCodable(galleryUri), 392 395 "item": AnyCodable(photoUri), 393 396 "position": AnyCodable(index), 394 - "createdAt": AnyCodable(now) 397 + "createdAt": AnyCodable(now), 395 398 ] 396 399 _ = try await client.createRecord( 397 400 collection: "social.grain.gallery.item", ··· 437 440 } 438 441 isUploading = false 439 442 } 440 - 441 - // MARK: - EXIF Extraction (gallery-specific, not shared) 442 - 443 - private func extractExif(from data: Data) -> [String: AnyCodable]? { 444 - let scale = 1_000_000 445 - 446 - guard let source = CGImageSourceCreateWithData(data as CFData, nil), 447 - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] else { 448 - logger.warning("No image properties found") 449 - return nil 450 - } 451 - 452 - let exifDict = properties[kCGImagePropertyExifDictionary as String] as? [String: Any] 453 - let tiffDict = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] 454 - let exifAux = properties[kCGImagePropertyExifAuxDictionary as String] as? [String: Any] 455 - var result: [String: AnyCodable] = [:] 456 - 457 - if let make = tiffDict?[kCGImagePropertyTIFFMake as String] as? String { 458 - result["make"] = AnyCodable(make.trimmingCharacters(in: .whitespaces)) 459 - } 460 - if let model = tiffDict?[kCGImagePropertyTIFFModel as String] as? String { 461 - result["model"] = AnyCodable(model.trimmingCharacters(in: .whitespaces)) 462 - } 463 - if let lensMake = exifAux?["LensMake"] as? String ?? exifDict?["LensMake"] as? String ?? tiffDict?[kCGImagePropertyTIFFMake as String] as? String { 464 - result["lensMake"] = AnyCodable(lensMake.trimmingCharacters(in: .whitespaces)) 465 - } 466 - if let lensModel = exifAux?["LensModel"] as? String ?? exifDict?[kCGImagePropertyExifLensModel as String] as? String { 467 - result["lensModel"] = AnyCodable(lensModel.trimmingCharacters(in: .whitespaces)) 468 - } 469 - if let exposureTime = exifDict?[kCGImagePropertyExifExposureTime as String] as? Double { 470 - result["exposureTime"] = AnyCodable(Int(exposureTime * Double(scale))) 471 - } 472 - if let fNumber = exifDict?[kCGImagePropertyExifFNumber as String] as? Double { 473 - result["fNumber"] = AnyCodable(Int(fNumber * Double(scale))) 474 - } 475 - if let isoRaw = exifDict?[kCGImagePropertyExifISOSpeedRatings as String] as? [Any], 476 - let iso = (isoRaw.first as? NSNumber)?.intValue { 477 - result["iSO"] = AnyCodable(iso * scale) 478 - } 479 - if let focal35 = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] as? Int { 480 - result["focalLengthIn35mmFormat"] = AnyCodable(focal35 * scale) 481 - } else if let focal35 = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] as? Double { 482 - result["focalLengthIn35mmFormat"] = AnyCodable(Int(focal35) * scale) 483 - } 484 - if let flash = exifDict?[kCGImagePropertyExifFlash as String] as? Int { 485 - let flashStr: String 486 - switch flash { 487 - case 0: flashStr = "Off, Did not fire" 488 - case 1: flashStr = "On, Fired" 489 - case 5: flashStr = "On, Return not detected" 490 - case 7: flashStr = "On, Return detected" 491 - case 16: flashStr = "Off, Did not fire" 492 - case 24: flashStr = "Off, Auto" 493 - case 25: flashStr = "On, Auto" 494 - default: flashStr = "Unknown (\(flash))" 495 - } 496 - result["flash"] = AnyCodable(flashStr) 497 - } 498 - if let dateStr = exifDict?[kCGImagePropertyExifDateTimeOriginal as String] as? String { 499 - let formatter = DateFormatter() 500 - formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" 501 - if let date = formatter.date(from: dateStr) { 502 - result["dateTimeOriginal"] = AnyCodable(ISO8601DateFormatter().string(from: date)) 503 - } 504 - } 505 - 506 - return result.isEmpty ? nil : result 507 - } 508 443 } 509 444 510 445 // MARK: - Photo Item Model 511 446 447 + struct ExifSummary { 448 + var camera: String? 449 + var lens: String? 450 + var exposure: String? 451 + var shutterSpeed: String? 452 + var iso: String? 453 + var focalLength: String? 454 + var aperture: String? 455 + } 456 + 512 457 struct PhotoItem: Identifiable { 513 458 let id = UUID() 514 459 let thumbnail: UIImage 515 460 let source: PhotoSource 516 461 var alt: String = "" 462 + var exifSummary: ExifSummary? 517 463 518 464 static func makeThumbnail(from image: UIImage, maxSize: CGFloat = 150) -> UIImage { 519 465 let scale = min(maxSize / image.size.width, maxSize / image.size.height, 1) ··· 529 475 case picker(PhotosPickerItem) 530 476 case camera(UIImage) 531 477 } 478 + 479 + // MARK: - Gallery Upload Helpers 480 + 481 + private struct ProcessedPhoto { 482 + let blob: BlobRef 483 + let aspectRatio: AspectRatio 484 + let exif: [String: AnyCodable]? 485 + } 486 + 487 + private func processGalleryPhotos( 488 + items: [PhotoItem], 489 + client: XRPCClient, 490 + authContext: AuthContext, 491 + skipExif: Bool = false 492 + ) async throws -> [ProcessedPhoto] { 493 + var processed: [ProcessedPhoto] = [] 494 + for item in items { 495 + switch item.source { 496 + case let .picker(pickerItem): 497 + guard let data = try await pickerItem.loadTransferable(type: Data.self), 498 + let original = UIImage(data: data) else { continue } 499 + let exif = skipExif ? nil : extractGalleryExif(from: data) 500 + let (resized, size) = ImageProcessing.resizeImage(original, maxDimension: 2000, maxBytes: 900_000) 501 + logger.info("Uploading \(resized.count) bytes, \(Int(size.width))x\(Int(size.height))") 502 + let response = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: authContext) 503 + processed.append(ProcessedPhoto( 504 + blob: response.blob, 505 + aspectRatio: AspectRatio(width: Int(size.width), height: Int(size.height)), 506 + exif: exif 507 + )) 508 + 509 + case let .camera(image): 510 + let (resized, size) = ImageProcessing.resizeImage(image, maxDimension: 2000, maxBytes: 900_000) 511 + logger.info("Uploading camera photo \(resized.count) bytes, \(Int(size.width))x\(Int(size.height))") 512 + let response = try await client.uploadBlob(data: resized, mimeType: "image/jpeg", auth: authContext) 513 + processed.append(ProcessedPhoto( 514 + blob: response.blob, 515 + aspectRatio: AspectRatio(width: Int(size.width), height: Int(size.height)), 516 + exif: nil 517 + )) 518 + } 519 + } 520 + return processed 521 + } 522 + 523 + private func createGalleryPhotoRecords( 524 + processed: [ProcessedPhoto], 525 + altTexts: [String], 526 + now: String, 527 + repo: String, 528 + client: XRPCClient, 529 + authContext: AuthContext, 530 + includeExif: Bool 531 + ) async throws -> [String] { 532 + var photoUris: [String] = [] 533 + for (index, photo) in processed.enumerated() { 534 + let blobDict: [String: AnyCodable] = [ 535 + "$type": AnyCodable(photo.blob.type ?? "blob"), 536 + "ref": AnyCodable(["$link": AnyCodable(photo.blob.ref?.link ?? "")] as [String: AnyCodable]), 537 + "mimeType": AnyCodable(photo.blob.mimeType ?? "image/jpeg"), 538 + "size": AnyCodable(photo.blob.size ?? 0), 539 + ] 540 + var photoRecord: [String: AnyCodable] = [ 541 + "photo": AnyCodable(blobDict), 542 + "aspectRatio": AnyCodable(["width": AnyCodable(photo.aspectRatio.width), "height": AnyCodable(photo.aspectRatio.height)] as [String: AnyCodable]), 543 + "createdAt": AnyCodable(now), 544 + ] 545 + let alt = altTexts[index].trimmingCharacters(in: .whitespacesAndNewlines) 546 + if !alt.isEmpty { 547 + photoRecord["alt"] = AnyCodable(alt) 548 + } 549 + let result = try await client.createRecord( 550 + collection: "social.grain.photo", 551 + repo: repo, 552 + record: AnyCodable(photoRecord), 553 + auth: authContext 554 + ) 555 + guard let uri = result.uri else { continue } 556 + photoUris.append(uri) 557 + 558 + if includeExif, var exif = photo.exif { 559 + exif["photo"] = AnyCodable(uri) 560 + exif["createdAt"] = AnyCodable(now) 561 + _ = try await client.createRecord( 562 + collection: "social.grain.photo.exif", 563 + repo: repo, 564 + record: AnyCodable(exif), 565 + auth: authContext 566 + ) 567 + } 568 + } 569 + return photoUris 570 + } 571 + 572 + // MARK: - EXIF Extraction (gallery-specific, not shared) 573 + 574 + private func flashDescription(for flash: Int) -> String { 575 + switch flash { 576 + case 0: "Off, Did not fire" 577 + case 1: "On, Fired" 578 + case 5: "On, Return not detected" 579 + case 7: "On, Return detected" 580 + case 16: "Off, Did not fire" 581 + case 24: "Off, Auto" 582 + case 25: "On, Auto" 583 + default: "Unknown (\(flash))" 584 + } 585 + } 586 + 587 + private func extractCameraInfo( 588 + exifDict: [String: Any]?, 589 + tiffDict: [String: Any]?, 590 + exifAux: [String: Any]?, 591 + into result: inout [String: AnyCodable] 592 + ) { 593 + if let make = tiffDict?[kCGImagePropertyTIFFMake as String] as? String { 594 + result["make"] = AnyCodable(make.trimmingCharacters(in: .whitespaces)) 595 + } 596 + if let model = tiffDict?[kCGImagePropertyTIFFModel as String] as? String { 597 + result["model"] = AnyCodable(model.trimmingCharacters(in: .whitespaces)) 598 + } 599 + let lensMake = exifAux?["LensMake"] as? String 600 + ?? exifDict?["LensMake"] as? String 601 + ?? tiffDict?[kCGImagePropertyTIFFMake as String] as? String 602 + if let lensMake { 603 + result["lensMake"] = AnyCodable(lensMake.trimmingCharacters(in: .whitespaces)) 604 + } 605 + let lensModel = exifAux?["LensModel"] as? String 606 + ?? exifDict?[kCGImagePropertyExifLensModel as String] as? String 607 + if let lensModel { 608 + result["lensModel"] = AnyCodable(lensModel.trimmingCharacters(in: .whitespaces)) 609 + } 610 + } 611 + 612 + private func extractExposureInfo( 613 + exifDict: [String: Any]?, 614 + scale: Int, 615 + into result: inout [String: AnyCodable] 616 + ) { 617 + if let exposureTime = exifDict?[kCGImagePropertyExifExposureTime as String] as? Double { 618 + result["exposureTime"] = AnyCodable(Int(exposureTime * Double(scale))) 619 + } 620 + if let fNumber = exifDict?[kCGImagePropertyExifFNumber as String] as? Double { 621 + result["fNumber"] = AnyCodable(Int(fNumber * Double(scale))) 622 + } 623 + if let isoRaw = exifDict?[kCGImagePropertyExifISOSpeedRatings as String] as? [Any], 624 + let iso = (isoRaw.first as? NSNumber)?.intValue 625 + { 626 + result["iSO"] = AnyCodable(iso * scale) 627 + } 628 + if let focal35 = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] as? Int { 629 + result["focalLengthIn35mmFormat"] = AnyCodable(focal35 * scale) 630 + } else if let focal35 = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] as? Double { 631 + result["focalLengthIn35mmFormat"] = AnyCodable(Int(focal35) * scale) 632 + } 633 + if let flash = exifDict?[kCGImagePropertyExifFlash as String] as? Int { 634 + result["flash"] = AnyCodable(flashDescription(for: flash)) 635 + } 636 + if let dateStr = exifDict?[kCGImagePropertyExifDateTimeOriginal as String] as? String { 637 + let formatter = DateFormatter() 638 + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" 639 + if let date = formatter.date(from: dateStr) { 640 + result["dateTimeOriginal"] = AnyCodable(ISO8601DateFormatter().string(from: date)) 641 + } 642 + } 643 + } 644 + 645 + private func makeExifSummary(from data: Data) -> ExifSummary? { 646 + guard let source = CGImageSourceCreateWithData(data as CFData, nil), 647 + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] 648 + else { return nil } 649 + 650 + let exifDict = properties[kCGImagePropertyExifDictionary as String] as? [String: Any] 651 + let tiffDict = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] 652 + let exifAux = properties[kCGImagePropertyExifAuxDictionary as String] as? [String: Any] 653 + 654 + var summary = ExifSummary() 655 + 656 + let make = (tiffDict?[kCGImagePropertyTIFFMake as String] as? String)?.trimmingCharacters(in: .whitespaces) 657 + let model = (tiffDict?[kCGImagePropertyTIFFModel as String] as? String)?.trimmingCharacters(in: .whitespaces) 658 + if let model { 659 + summary.camera = (make.map { model.lowercased().hasPrefix($0.lowercased()) } == true) ? model : [make, model].compactMap(\.self).joined(separator: " ") 660 + } 661 + 662 + let lens = (exifAux?["LensModel"] as? String ?? exifDict?[kCGImagePropertyExifLensModel as String] as? String)?.trimmingCharacters(in: .whitespaces) 663 + summary.lens = lens 664 + 665 + if let et = exifDict?[kCGImagePropertyExifExposureTime as String] as? Double { 666 + summary.shutterSpeed = et < 1 ? "1/\(Int((1 / et).rounded()))s" : "\(et)s" 667 + } 668 + if let fn = exifDict?[kCGImagePropertyExifFNumber as String] as? Double { 669 + summary.aperture = "f/\(fn)" 670 + } 671 + if let isoRaw = exifDict?[kCGImagePropertyExifISOSpeedRatings as String] as? [Any], 672 + let iso = (isoRaw.first as? NSNumber)?.intValue 673 + { 674 + summary.iso = "ISO \(iso)" 675 + } 676 + if let focal = exifDict?[kCGImagePropertyExifFocalLenIn35mmFilm as String] { 677 + let mm = (focal as? Int) ?? Int((focal as? Double) ?? 0) 678 + if mm > 0 { summary.focalLength = "\(mm)mm" } 679 + } 680 + let parts = [summary.shutterSpeed, summary.iso, summary.focalLength, summary.aperture].compactMap(\.self) 681 + if !parts.isEmpty { summary.exposure = parts.joined(separator: " ") } 682 + 683 + guard summary.camera != nil || summary.lens != nil || summary.exposure != nil else { return nil } 684 + return summary 685 + } 686 + 687 + private func extractGalleryExif(from data: Data) -> [String: AnyCodable]? { 688 + guard let source = CGImageSourceCreateWithData(data as CFData, nil), 689 + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] 690 + else { 691 + logger.warning("No image properties found") 692 + return nil 693 + } 694 + 695 + let exifDict = properties[kCGImagePropertyExifDictionary as String] as? [String: Any] 696 + let tiffDict = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any] 697 + let exifAux = properties[kCGImagePropertyExifAuxDictionary as String] as? [String: Any] 698 + var result: [String: AnyCodable] = [:] 699 + 700 + extractCameraInfo(exifDict: exifDict, tiffDict: tiffDict, exifAux: exifAux, into: &result) 701 + extractExposureInfo(exifDict: exifDict, scale: 1_000_000, into: &result) 702 + 703 + return result.isEmpty ? nil : result 704 + } 705 + 706 + #Preview { 707 + @Previewable @State var photos = PreviewData.photoItems 708 + @Previewable @State var selectedID: UUID? 709 + NavigationStack { 710 + CreateGalleryViewPreview(photoItems: $photos, selectedPhotoID: $selectedID) 711 + } 712 + .environment(AuthManager()) 713 + .environment(LabelDefinitionsCache()) 714 + .onAppear { selectedID = photos.first?.id } 715 + } 716 + 717 + /// Thin wrapper that exposes photoItems for preview injection 718 + private struct CreateGalleryViewPreview: View { 719 + @Binding var photoItems: [PhotoItem] 720 + @Binding var selectedPhotoID: UUID? 721 + 722 + var body: some View { 723 + Form { 724 + Section("Photos") { 725 + Label("5 photos selected", systemImage: "photo.on.rectangle.angled") 726 + .foregroundStyle(.secondary) 727 + } 728 + Section { 729 + TextField("Add a title (required)...", text: .constant("Golden Hour, Kyoto")) 730 + TextField("Add a description...", text: .constant("Shot on Leica M6 with Kodak Portra 400. #analog #japan #35mm"), axis: .vertical) 731 + .lineLimit(3 ... 6) 732 + } header: { 733 + Text("Gallery") 734 + } 735 + Section { 736 + PhotoEditor(items: $photoItems, selectedPhotoID: $selectedPhotoID, sendExif: true) 737 + } 738 + } 739 + .navigationTitle("New Gallery") 740 + .toolbar { 741 + ToolbarItem(placement: .cancellationAction) { Button("Cancel") {} } 742 + ToolbarItem(placement: .topBarTrailing) { Button("Post") {}.bold() } 743 + } 744 + } 745 + }
+49
Grain/Views/Create/DragToReorder.swift
··· 1 + import SwiftUI 2 + 3 + /// Visual treatment for a draggable, selectable thumbnail in a reorderable collection. 4 + struct ReorderableThumbnail: ViewModifier { 5 + let isDragging: Bool 6 + let isSelected: Bool 7 + let cornerRadius: CGFloat 8 + 9 + func body(content: Content) -> some View { 10 + content 11 + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 12 + .overlay( 13 + RoundedRectangle(cornerRadius: cornerRadius) 14 + .stroke(Color.accentColor, lineWidth: 2.5) 15 + .opacity(isSelected ? 1 : 0) 16 + ) 17 + .scaleEffect(isDragging ? 1.13 : 1) 18 + .shadow( 19 + color: isDragging ? .black.opacity(0.25) : .clear, 20 + radius: 8, y: 4 21 + ) 22 + .opacity(isDragging ? 0.92 : 1) 23 + .zIndex(isDragging ? 1 : 0) 24 + } 25 + } 26 + 27 + extension View { 28 + func reorderableThumbnail(isDragging: Bool, isSelected: Bool, cornerRadius: CGFloat = 8) -> some View { 29 + modifier(ReorderableThumbnail(isDragging: isDragging, isSelected: isSelected, cornerRadius: cornerRadius)) 30 + } 31 + } 32 + 33 + #Preview { 34 + HStack(spacing: 8) { 35 + RoundedRectangle(cornerRadius: 8) 36 + .fill(Color.gray.opacity(0.3)) 37 + .frame(width: 72, height: 72) 38 + .reorderableThumbnail(isDragging: false, isSelected: false) 39 + RoundedRectangle(cornerRadius: 8) 40 + .fill(Color.gray.opacity(0.3)) 41 + .frame(width: 72, height: 72) 42 + .reorderableThumbnail(isDragging: false, isSelected: true) 43 + RoundedRectangle(cornerRadius: 8) 44 + .fill(Color.gray.opacity(0.3)) 45 + .frame(width: 72, height: 72) 46 + .reorderableThumbnail(isDragging: true, isSelected: false) 47 + } 48 + .padding() 49 + }
+67
Grain/Views/Create/LocalZoomableViewer.swift
··· 1 + import SwiftUI 2 + 3 + struct LocalZoomableViewer: View { 4 + let image: UIImage 5 + 6 + @State private var scale: CGFloat = 1 7 + @State private var lastScale: CGFloat = 1 8 + @State private var offset: CGSize = .zero 9 + @State private var lastOffset: CGSize = .zero 10 + 11 + var body: some View { 12 + Image(uiImage: image) 13 + .resizable() 14 + .scaledToFit() 15 + .scaleEffect(scale, anchor: .center) 16 + .offset(offset) 17 + .simultaneousGesture(magnification) 18 + .simultaneousGesture( 19 + DragGesture(minimumDistance: scale > 1 ? 0 : .infinity) 20 + .onChanged { value in 21 + offset = CGSize( 22 + width: lastOffset.width + value.translation.width, 23 + height: lastOffset.height + value.translation.height 24 + ) 25 + } 26 + .onEnded { _ in 27 + lastOffset = offset 28 + if scale <= 1.05 { 29 + withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 30 + offset = .zero 31 + lastOffset = .zero 32 + } 33 + } 34 + } 35 + ) 36 + .onChange(of: image) { 37 + withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 38 + scale = 1 39 + lastScale = 1 40 + offset = .zero 41 + lastOffset = .zero 42 + } 43 + } 44 + } 45 + 46 + private var magnification: some Gesture { 47 + MagnificationGesture() 48 + .onChanged { value in 49 + scale = max(1, lastScale * value) 50 + } 51 + .onEnded { value in 52 + let final = max(1, lastScale * value) 53 + if final < 1.05 { 54 + withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 55 + scale = 1 56 + offset = .zero 57 + lastOffset = .zero 58 + } 59 + } 60 + lastScale = scale 61 + } 62 + } 63 + } 64 + 65 + #Preview { 66 + LocalZoomableViewer(image: UIImage(named: "login-bg") ?? UIImage(systemName: "photo")!) 67 + }
+79
Grain/Views/Create/PhotoEditor.swift
··· 1 + import SwiftUI 2 + 3 + struct PhotoEditor: View { 4 + @Binding var items: [PhotoItem] 5 + @Binding var selectedPhotoID: UUID? 6 + let sendExif: Bool 7 + 8 + private var selectedIndex: Int? { 9 + guard let id = selectedPhotoID else { return nil } 10 + return items.firstIndex(where: { $0.id == id }) 11 + } 12 + 13 + var body: some View { 14 + ReorderablePhotoStrip(items: $items, selectedPhotoID: $selectedPhotoID) 15 + .listRowInsets(EdgeInsets()) 16 + .listRowSeparator(.hidden) 17 + if let idx = selectedIndex { 18 + viewer(selectedIndex: idx) 19 + altTextField(for: idx) 20 + exifRow(for: items[idx]) 21 + } 22 + ReorderablePhotoGrid(items: $items, selectedPhotoID: $selectedPhotoID) 23 + .listRowInsets(EdgeInsets()) 24 + .listRowSeparator(.hidden) 25 + } 26 + 27 + // MARK: - Zoomable Viewer 28 + 29 + private func viewer(selectedIndex _: Int) -> some View { 30 + TabView(selection: $selectedPhotoID) { 31 + ForEach(items) { item in 32 + LocalZoomableViewer(image: item.thumbnail) 33 + .tag(Optional(item.id)) 34 + } 35 + } 36 + .tabViewStyle(.page(indexDisplayMode: .never)) 37 + .frame(height: 280) 38 + } 39 + 40 + // MARK: - EXIF Row 41 + 42 + @ViewBuilder 43 + private func exifRow(for item: PhotoItem) -> some View { 44 + if let exif = item.exifSummary { 45 + VStack(alignment: .leading, spacing: 2) { 46 + if let camera = exif.camera { 47 + Text(camera).font(.caption) 48 + } 49 + HStack { 50 + Text([exif.shutterSpeed, exif.iso].compactMap(\.self).joined(separator: " ")) 51 + .font(.caption) 52 + Spacer() 53 + Text([exif.focalLength, exif.aperture].compactMap(\.self).joined(separator: " ")) 54 + .font(.caption) 55 + } 56 + } 57 + .foregroundStyle(sendExif ? .primary : .tertiary) 58 + } 59 + } 60 + 61 + // MARK: - Alt Text Field 62 + 63 + private func altTextField(for index: Int) -> some View { 64 + TextField("Describe this photo...", text: $items[index].alt, axis: .vertical) 65 + .font(.subheadline) 66 + .lineLimit(2 ... 4) 67 + } 68 + } 69 + 70 + #Preview { 71 + @Previewable @State var state: [PhotoItem] = PreviewData.photoItems 72 + @Previewable @State var selected: UUID? 73 + ScrollView { 74 + PhotoEditor(items: $state, selectedPhotoID: $selected, sendExif: true) 75 + .padding() 76 + } 77 + .onAppear { selected = state.first?.id } 78 + .preferredColorScheme(.dark) 79 + }
+156
Grain/Views/Create/ReorderablePhotoGrid.swift
··· 1 + import SwiftUI 2 + 3 + struct ReorderablePhotoGrid: View { 4 + @Binding var items: [PhotoItem] 5 + @Binding var selectedPhotoID: UUID? 6 + 7 + @State private var draggingID: UUID? 8 + @State private var dragOffset: CGSize = .zero 9 + @Namespace private var gridNamespace 10 + 11 + private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 3) 12 + private let spacing: CGFloat = 4 13 + 14 + var body: some View { 15 + GeometryReader { geo in 16 + let cellSize = (geo.size.width - spacing * 2) / 3 17 + let step = cellSize + spacing 18 + LazyVGrid(columns: columns, spacing: spacing) { 19 + ForEach(items) { item in 20 + cellView(item: item, cellSize: cellSize, isDragging: false) 21 + // matchedGeometryEffect tracks each cell's frame so moves 22 + // animate as smooth positional transitions 23 + .matchedGeometryEffect(id: item.id, in: gridNamespace) 24 + .opacity(draggingID == item.id ? 0 : 1) 25 + .simultaneousGesture(TapGesture().onEnded { 26 + guard draggingID == nil else { return } 27 + selectedPhotoID = item.id 28 + }) 29 + .gesture( 30 + LongPressGesture(minimumDuration: 0.2) 31 + .sequenced(before: DragGesture()) 32 + .onChanged { value in 33 + switch value { 34 + case .second(true, let drag): 35 + if draggingID == nil { 36 + draggingID = item.id 37 + } 38 + if let drag { 39 + dragOffset = drag.translation 40 + reorderIfNeeded(cellSize: cellSize) 41 + } 42 + default: 43 + break 44 + } 45 + } 46 + .onEnded { _ in 47 + withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 48 + draggingID = nil 49 + dragOffset = .zero 50 + } 51 + } 52 + ) 53 + } 54 + } 55 + // Dragged item rendered above the grid — unconditionally on top 56 + .overlay(alignment: .topLeading) { 57 + if let draggingID, 58 + let item = items.first(where: { $0.id == draggingID }), 59 + let idx = items.firstIndex(where: { $0.id == draggingID }) 60 + { 61 + let col = CGFloat(idx % 3) 62 + let row = CGFloat(idx / 3) 63 + cellView(item: item, cellSize: cellSize, isDragging: true) 64 + .offset( 65 + x: col * step + dragOffset.width, 66 + y: row * step + dragOffset.height 67 + ) 68 + .allowsHitTesting(false) 69 + } 70 + } 71 + } 72 + .aspectRatio(aspectRatio, contentMode: .fit) 73 + } 74 + 75 + private func cellView(item: PhotoItem, cellSize: CGFloat, isDragging: Bool) -> some View { 76 + Image(uiImage: item.thumbnail) 77 + .resizable() 78 + .scaledToFill() 79 + .frame(width: cellSize, height: cellSize) 80 + .clipped() 81 + .overlay(alignment: .bottomTrailing) { 82 + let hasAlt = !item.alt.trimmingCharacters(in: .whitespaces).isEmpty 83 + Image(systemName: hasAlt ? "text.bubble.fill" : "text.bubble") 84 + .font(.system(size: 20)) 85 + .scaleEffect(x: -1) 86 + .foregroundStyle(hasAlt ? .white : .white.opacity(0.5)) 87 + .shadow(color: .black.opacity(0.5), radius: 2, y: 1) 88 + .padding(4) 89 + } 90 + .reorderableThumbnail( 91 + isDragging: isDragging, 92 + isSelected: item.id == selectedPhotoID, 93 + cornerRadius: 6 94 + ) 95 + } 96 + 97 + private var aspectRatio: CGFloat { 98 + let rows = max(1, ceil(CGFloat(items.count) / 3)) 99 + return 3 / rows 100 + } 101 + 102 + static func targetIndex( 103 + currentIndex: Int, 104 + dragOffset: CGSize, 105 + cellSize: CGFloat, 106 + spacing: CGFloat, 107 + itemCount: Int 108 + ) -> Int { 109 + let step = cellSize + spacing 110 + let colSteps = Int((dragOffset.width / step).rounded()) 111 + let rowSteps = Int((dragOffset.height / step).rounded()) 112 + 113 + let currentCol = currentIndex % 3 114 + let currentRow = currentIndex / 3 115 + 116 + let targetCol = max(0, min(2, currentCol + colSteps)) 117 + let targetRow = max(0, currentRow + rowSteps) 118 + return max(0, min(itemCount - 1, targetRow * 3 + targetCol)) 119 + } 120 + 121 + private func reorderIfNeeded(cellSize: CGFloat) { 122 + guard let draggingID, 123 + let currentIndex = items.firstIndex(where: { $0.id == draggingID }) 124 + else { return } 125 + 126 + let target = Self.targetIndex( 127 + currentIndex: currentIndex, 128 + dragOffset: dragOffset, 129 + cellSize: cellSize, 130 + spacing: spacing, 131 + itemCount: items.count 132 + ) 133 + 134 + if target != currentIndex { 135 + let colDelta = (target % 3) - (currentIndex % 3) 136 + let rowDelta = (target / 3) - (currentIndex / 3) 137 + let step = cellSize + spacing 138 + 139 + withAnimation(.spring(response: 0.35, dampingFraction: 0.88)) { 140 + items.move(fromOffsets: IndexSet(integer: currentIndex), toOffset: target > currentIndex ? target + 1 : target) 141 + } 142 + 143 + dragOffset.width -= CGFloat(colDelta) * step 144 + dragOffset.height -= CGFloat(rowDelta) * step 145 + } 146 + } 147 + } 148 + 149 + #Preview { 150 + @Previewable @State var state: [PhotoItem] = PreviewData.photoItems 151 + @Previewable @State var selected: UUID? 152 + ReorderablePhotoGrid(items: $state, selectedPhotoID: $selected) 153 + .padding() 154 + .onAppear { selected = state.first?.id } 155 + .preferredColorScheme(.dark) 156 + }
+74 -46
Grain/Views/Create/ReorderablePhotoStrip.swift
··· 2 2 3 3 struct ReorderablePhotoStrip: View { 4 4 @Binding var items: [PhotoItem] 5 + @Binding var selectedPhotoID: UUID? 5 6 @State private var draggingID: UUID? 6 7 @State private var dragOffset: CGFloat = 0 7 8 private let thumbSize: CGFloat = 72 8 9 private let spacing: CGFloat = 8 9 10 10 11 var body: some View { 11 - ScrollView(.horizontal, showsIndicators: false) { 12 - HStack(spacing: spacing) { 13 - ForEach(items) { item in 14 - ZStack(alignment: .topTrailing) { 15 - Image(uiImage: item.thumbnail) 16 - .resizable() 17 - .scaledToFill() 18 - .frame(width: thumbSize, height: thumbSize) 19 - .clipShape(RoundedRectangle(cornerRadius: 8)) 12 + ScrollViewReader { proxy in 13 + ScrollView(.horizontal, showsIndicators: false) { 14 + HStack(spacing: spacing) { 15 + ForEach(items) { item in 16 + ZStack(alignment: .topTrailing) { 17 + Image(uiImage: item.thumbnail) 18 + .resizable() 19 + .scaledToFill() 20 + .frame(width: thumbSize, height: thumbSize) 20 21 21 - Button { 22 - withAnimation { 23 - items.removeAll { $0.id == item.id } 22 + Button { 23 + withAnimation { 24 + items.removeAll { $0.id == item.id } 25 + if selectedPhotoID == item.id { 26 + selectedPhotoID = items.first?.id 27 + } 28 + } 29 + } label: { 30 + Image(systemName: "xmark.circle.fill") 31 + .font(.system(size: 18)) 32 + .foregroundStyle(.white, .black.opacity(0.6)) 24 33 } 25 - } label: { 26 - Image(systemName: "xmark.circle.fill") 27 - .font(.system(size: 18)) 28 - .foregroundStyle(.white, .black.opacity(0.6)) 34 + .offset(x: 4, y: -4) 29 35 } 30 - .offset(x: 4, y: -4) 31 - } 32 - .offset(x: draggingID == item.id ? dragOffset : 0) 33 - .opacity(draggingID == item.id ? 0.8 : 1) 34 - .scaleEffect(draggingID == item.id ? 1.08 : 1) 35 - .zIndex(draggingID == item.id ? 1 : 0) 36 - .gesture( 37 - LongPressGesture(minimumDuration: 0.2) 38 - .sequenced(before: DragGesture()) 39 - .onChanged { value in 40 - switch value { 41 - case .second(true, let drag): 42 - if draggingID == nil { 43 - draggingID = item.id 44 - } 45 - if let drag { 46 - dragOffset = drag.translation.width 47 - reorderIfNeeded() 36 + .reorderableThumbnail( 37 + isDragging: draggingID == item.id, 38 + isSelected: item.id == selectedPhotoID 39 + ) 40 + .zIndex(draggingID == item.id ? 1 : 0) 41 + .id(item.id) 42 + .offset(x: draggingID == item.id ? dragOffset : 0) 43 + .simultaneousGesture(TapGesture().onEnded { 44 + guard draggingID == nil else { return } 45 + selectedPhotoID = item.id 46 + }) 47 + .gesture( 48 + LongPressGesture(minimumDuration: 0.2) 49 + .sequenced(before: DragGesture()) 50 + .onChanged { value in 51 + switch value { 52 + case .second(true, let drag): 53 + if draggingID == nil { 54 + draggingID = item.id 55 + } 56 + if let drag { 57 + dragOffset = drag.translation.width 58 + reorderIfNeeded() 59 + } 60 + default: 61 + break 48 62 } 49 - default: 50 - break 51 63 } 52 - } 53 - .onEnded { _ in 54 - withAnimation(.easeInOut(duration: 0.2)) { 55 - draggingID = nil 56 - dragOffset = 0 64 + .onEnded { _ in 65 + withAnimation(.spring(response: 0.22, dampingFraction: 0.75)) { 66 + draggingID = nil 67 + dragOffset = 0 68 + } 57 69 } 58 - } 59 - ) 70 + ) 71 + } 60 72 } 73 + .padding(.vertical, 4) 74 + .padding(.horizontal, 16) 61 75 } 62 - .padding(.vertical, 4) 63 - } 76 + .onChange(of: selectedPhotoID) { _, id in 77 + guard let id else { return } 78 + withAnimation(.easeInOut(duration: 0.3)) { 79 + proxy.scrollTo(id, anchor: .center) 80 + } 81 + } 82 + } // ScrollViewReader 64 83 } 65 84 66 85 private func reorderIfNeeded() { ··· 73 92 74 93 let targetIndex = max(0, min(items.count - 1, currentIndex + steps)) 75 94 if targetIndex != currentIndex { 76 - withAnimation(.easeInOut(duration: 0.15)) { 95 + withAnimation(.spring(response: 0.45, dampingFraction: 0.7, blendDuration: 0)) { 77 96 items.move(fromOffsets: IndexSet(integer: currentIndex), toOffset: targetIndex > currentIndex ? targetIndex + 1 : targetIndex) 78 97 } 79 98 dragOffset -= CGFloat(targetIndex - currentIndex) * step 80 99 } 81 100 } 82 101 } 102 + 103 + #Preview { 104 + @Previewable @State var state: [PhotoItem] = PreviewData.photoItems 105 + @Previewable @State var selected: UUID? 106 + ReorderablePhotoStrip(items: $state, selectedPhotoID: $selected) 107 + .padding() 108 + .onAppear { selected = state.first?.id } 109 + .preferredColorScheme(.dark) 110 + }
+64 -23
Grain/Views/Feed/FeedView.swift
··· 1 + import Nuke 1 2 import SwiftUI 2 3 3 4 struct FeedView: View { ··· 25 26 } 26 27 27 28 var body: some View { 28 - // Read version so Observation tracks it and re-renders on invalidate() 29 - let _ = storyViewModel.version 29 + let storySortVersion = storyViewModel.version 30 30 NavigationStack { 31 31 ForEach(prefsViewModel.pinnedFeeds) { feed in 32 32 if feed.id == prefsViewModel.selectedFeedId { ··· 35 35 pinnedFeed: feed, 36 36 userDID: auth.userDID, 37 37 storyAuthors: storyViewModel.authors, 38 + storySortVersion: storySortVersion, 38 39 userAvatar: auth.userAvatar, 39 40 onStoryAuthorTap: { author, _ in 40 41 storyViewerDid = author.profile.did 41 42 }, 42 43 onStoryCreateTap: { showStoryCreate = true }, 43 44 onRefresh: { [storyStatusCache] in 44 - await storyViewModel.load(auth: await auth.authContext(), storyStatusCache: storyStatusCache) 45 + await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 45 46 }, 46 47 prefsViewModel: prefsViewModel 47 48 ) ··· 59 60 .sharedBackgroundVisibility(.hidden) 60 61 } 61 62 .task { 62 - await prefsViewModel.loadIfNeeded(auth: await auth.authContext()) 63 - await storyViewModel.load(auth: await auth.authContext(), storyStatusCache: storyStatusCache) 63 + guard !isPreview else { return } 64 + await prefsViewModel.loadIfNeeded(auth: auth.authContext()) 65 + await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) 64 66 } 65 67 .onAppear { 66 - Task { await prefsViewModel.refresh(auth: await auth.authContext()) } 68 + Task { await prefsViewModel.refresh(auth: auth.authContext()) } 67 69 } 68 70 .onChange(of: storyViewerDid) { 69 71 if storyViewerDid == nil { ··· 93 95 } 94 96 .sheet(isPresented: $showStoryCreate) { 95 97 StoryCreateView(client: client) { 96 - Task { await storyViewModel.load(auth: await auth.authContext(), storyStatusCache: storyStatusCache) } 98 + Task { await storyViewModel.load(auth: auth.authContext(), storyStatusCache: storyStatusCache) } 97 99 } 98 100 } 99 101 .navigationDestination(item: $deepLinkProfileDid) { did in ··· 110 112 deepLinkStoryAuthor = nil 111 113 deepLinkProfileDid = did 112 114 }, 113 - onDismiss: { deepLinkStoryAuthor = nil } 115 + onDismiss: { 116 + deepLinkStoryAuthor = nil 117 + storyViewModel.invalidate() 118 + } 114 119 ) 115 120 .environment(auth) 116 121 } ··· 123 128 } 124 129 } 125 130 126 - @ViewBuilder 127 131 private var leadingToolbarContent: some View { 128 132 Menu { 129 133 ForEach(prefsViewModel.pinnedFeeds) { feed in ··· 142 146 Divider() 143 147 Button(role: .destructive) { 144 148 Task { 145 - await prefsViewModel.unpinFeed(prefsViewModel.selectedFeedId, auth: await auth.authContext()) 149 + await prefsViewModel.unpinFeed(prefsViewModel.selectedFeedId, auth: auth.authContext()) 146 150 } 147 151 } label: { 148 152 Label("Unpin", systemImage: "pin.slash") ··· 166 170 .tint(.primary) 167 171 } 168 172 169 - @ViewBuilder 170 173 private var trailingToolbarContent: some View { 171 174 Button { 172 175 showCreate = true ··· 184 187 guard let link = pendingDeepLink else { return } 185 188 pendingDeepLink = nil 186 189 switch link { 187 - case .profile(let did): 190 + case let .profile(did): 188 191 deepLinkProfileDid = did 189 192 case .gallery: 190 193 deepLinkGalleryUri = link.galleryUri 191 - case .story(let did, _): 194 + case let .story(did, _): 192 195 Task { await openStoryDeepLink(did: did) } 193 196 } 194 197 } 195 198 196 199 private func openStoryDeepLink(did: String) async { 197 200 do { 198 - let response = try await client.getStories(actor: did, auth: await auth.authContext()) 201 + let response = try await client.getStories(actor: did, auth: auth.authContext()) 199 202 let count = response.stories.count 200 203 if count > 0, let creator = response.stories.first?.creator { 201 204 deepLinkStoryAuthor = GrainStoryAuthor( ··· 212 215 deepLinkProfileDid = did 213 216 } 214 217 } 215 - 216 218 } 217 219 218 220 private struct FeedTabContent: View { ··· 226 228 @State private var deletedGalleryUri: String? 227 229 @State private var zoomState = ImageZoomState() 228 230 @State private var cardStoryAuthor: GrainStoryAuthor? 231 + @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 229 232 @State private var suggestedFollows: [SuggestedItem] = [] 230 233 @State private var suggestedLoaded = false 231 234 @State private var lastLoadTime: Date = .now 235 + @State private var feedPrefetcher = ImagePrefetcher() 232 236 let client: XRPCClient 233 237 let storyAuthors: [GrainStoryAuthor] 238 + var storySortVersion: Int = 0 234 239 let userAvatar: String? 235 240 let onStoryAuthorTap: (GrainStoryAuthor, Int) -> Void 236 241 let onStoryCreateTap: () -> Void 237 242 let onRefresh: (@Sendable () async -> Void)? 238 243 let prefsViewModel: FeedPreferencesViewModel 239 244 240 - init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil, storyAuthors: [GrainStoryAuthor] = [], userAvatar: String? = nil, onStoryAuthorTap: @escaping (GrainStoryAuthor, Int) -> Void = { _, _ in }, onStoryCreateTap: @escaping () -> Void = {}, onRefresh: (@Sendable () async -> Void)? = nil, prefsViewModel: FeedPreferencesViewModel) { 245 + init( 246 + client: XRPCClient, 247 + pinnedFeed: PinnedFeed, 248 + userDID: String? = nil, 249 + storyAuthors: [GrainStoryAuthor] = [], 250 + storySortVersion: Int = 0, 251 + userAvatar: String? = nil, 252 + onStoryAuthorTap: @escaping (GrainStoryAuthor, Int) -> Void = { _, _ in }, 253 + onStoryCreateTap: @escaping () -> Void = {}, 254 + onRefresh: (@Sendable () async -> Void)? = nil, 255 + prefsViewModel: FeedPreferencesViewModel 256 + ) { 241 257 self.client = client 242 258 self.storyAuthors = storyAuthors 259 + self.storySortVersion = storySortVersion 243 260 self.userAvatar = userAvatar 244 261 self.onStoryAuthorTap = onStoryAuthorTap 245 262 self.onStoryCreateTap = onStoryCreateTap ··· 253 270 LazyVStack(spacing: 12) { 254 271 StoryStripView( 255 272 authors: storyAuthors, 273 + userDid: auth.userDID, 256 274 userAvatar: userAvatar, 275 + sortVersion: storySortVersion, 257 276 onAuthorTap: onStoryAuthorTap, 258 277 onAuthorLongPress: { did in selectedProfileDid = did }, 259 278 onCreateTap: onStoryCreateTap ··· 272 291 cardStoryAuthor = author 273 292 }) 274 293 .onAppear { 275 - if gallery.id == viewModel.galleries.last?.id { 276 - Task { await viewModel.loadMore(auth: await auth.authContext()) } 294 + // Trigger loadMore when 5 items from the end 295 + let remaining = viewModel.galleries.count - index 296 + if remaining <= 5 { 297 + Task { await viewModel.loadMore(auth: auth.authContext()) } 277 298 } 299 + // Prefetch first image of next 3 galleries 300 + let input = viewModel.galleries.map { g in 301 + (firstThumb: g.items?.first?.thumb, firstFullsize: g.items?.first?.fullsize) 302 + } 303 + let plan = ImagePrefetchPlanning.feedPrefetchRequests(galleries: input, currentIndex: index) 304 + feedPrefetcher.startPrefetching(with: plan.all) 278 305 } 279 306 280 - if index == 4 { 307 + if index == 4, showSuggestedUsers { 281 308 SuggestedFollowsView(client: client, suggestions: $suggestedFollows, onProfileTap: { did in 282 309 selectedProfileDid = did 283 310 }) ··· 325 352 .environment(auth) 326 353 } 327 354 .task { 355 + guard !isPreview else { 356 + #if DEBUG 357 + viewModel.galleries = PreviewData.galleries 358 + #endif 359 + return 360 + } 328 361 if viewModel.galleries.isEmpty { 329 - await viewModel.loadInitial(auth: await auth.authContext()) 362 + await viewModel.loadInitial(auth: auth.authContext()) 330 363 lastLoadTime = .now 331 364 } 332 - if !suggestedLoaded, let did = auth.userDID { 365 + if showSuggestedUsers, !suggestedLoaded, let did = auth.userDID { 333 366 do { 334 - let response = try await client.getSuggestedFollows(actor: did, auth: await auth.authContext()) 367 + let response = try await client.getSuggestedFollows(actor: did, auth: auth.authContext()) 335 368 suggestedFollows = response.items ?? [] 336 369 } catch {} 337 370 suggestedLoaded = true ··· 340 373 .onChange(of: scenePhase) { 341 374 if scenePhase == .active, Date.now.timeIntervalSince(lastLoadTime) > 300 { 342 375 Task { 343 - await viewModel.loadInitial(auth: await auth.authContext()) 376 + await viewModel.loadInitial(auth: auth.authContext()) 344 377 lastLoadTime = .now 345 378 } 346 379 } ··· 353 386 } 354 387 } 355 388 } 389 + 390 + #Preview { 391 + FeedView(client: XRPCClient(baseURL: AuthManager.serverURL)) 392 + .environment(AuthManager()) 393 + .environment(StoryStatusCache()) 394 + .environment(ViewedStoryStorage()) 395 + .environment(LabelDefinitionsCache()) 396 + }
+18 -1
Grain/Views/Feed/HashtagFeedView.swift
··· 16 16 let client: XRPCClient 17 17 let tag: String 18 18 19 - private var feedId: String { "hashtag:\(tag)" } 19 + private var feedId: String { 20 + "hashtag:\(tag)" 21 + } 20 22 21 23 var body: some View { 22 24 ScrollView { ··· 67 69 } 68 70 } 69 71 .task { 72 + guard !isPreview else { return } 70 73 await checkPinned() 71 74 } 72 75 .navigationDestination(item: $selectedUri) { uri in ··· 94 97 .environment(auth) 95 98 } 96 99 .task { 100 + guard !isPreview else { 101 + #if DEBUG 102 + galleries = PreviewData.galleries 103 + #endif 104 + return 105 + } 97 106 if galleries.isEmpty { 98 107 await loadInitial() 99 108 } ··· 142 151 } catch {} 143 152 } 144 153 } 154 + 155 + #Preview { 156 + HashtagFeedView(client: XRPCClient(baseURL: AuthManager.serverURL), tag: "35mm") 157 + .environment(AuthManager()) 158 + .environment(StoryStatusCache()) 159 + .environment(ViewedStoryStorage()) 160 + .environment(LabelDefinitionsCache()) 161 + }
+25 -2
Grain/Views/Feed/LocationFeedView.swift
··· 18 18 let h3Index: String 19 19 let locationName: String 20 20 21 - private var feedId: String { "location:\(h3Index)" } 21 + private var feedId: String { 22 + "location:\(h3Index)" 23 + } 22 24 23 25 private var coordinate: CLLocationCoordinate2D? { 24 26 LocationServices.h3ToCoordinate(h3Index) ··· 98 100 } 99 101 } 100 102 .task { 103 + guard !isPreview else { return } 101 104 await checkPinned() 102 105 } 103 106 .navigationDestination(item: $selectedUri) { uri in ··· 122 125 .environment(auth) 123 126 } 124 127 .task { 128 + guard !isPreview else { 129 + #if DEBUG 130 + galleries = PreviewData.galleries 131 + #endif 132 + return 133 + } 125 134 if galleries.isEmpty { 126 135 await loadInitial() 127 136 } ··· 174 183 struct LocationDestination: Hashable, Identifiable { 175 184 let h3Index: String 176 185 let name: String 177 - var id: String { h3Index } 186 + var id: String { 187 + h3Index 188 + } 189 + } 190 + 191 + #Preview { 192 + LocationFeedView( 193 + client: XRPCClient(baseURL: AuthManager.serverURL), 194 + h3Index: "8928308280fffff", 195 + locationName: "San Francisco" 196 + ) 197 + .environment(AuthManager()) 198 + .environment(StoryStatusCache()) 199 + .environment(ViewedStoryStorage()) 200 + .environment(LabelDefinitionsCache()) 178 201 }
+28 -6
Grain/Views/Gallery/GalleryDetailView.swift
··· 1 - import SwiftUI 2 1 import NukeUI 2 + import SwiftUI 3 3 4 4 struct GalleryDetailView: View { 5 5 @Environment(AuthManager.self) private var auth ··· 90 90 ForEach(threadedComments, id: \.root.id) { thread in 91 91 CommentRow( 92 92 comment: thread.root, 93 + userDID: auth.userDID, 93 94 isOwn: thread.root.author.did == auth.userDID, 94 95 isReply: false, 95 96 onProfileTap: { did in selectedProfileDid = did }, ··· 102 103 ForEach(thread.replies) { reply in 103 104 CommentRow( 104 105 comment: reply, 106 + userDID: auth.userDID, 105 107 isOwn: reply.author.did == auth.userDID, 106 108 isReply: true, 107 109 onProfileTap: { did in selectedProfileDid = did }, ··· 115 117 } 116 118 } 117 119 } 118 - } else if viewModel.isLoading { 120 + } else { 119 121 ProgressView() 120 122 .padding(.top, 100) 121 123 } ··· 207 209 .font(.body) 208 210 .focused($commentFocused) 209 211 .padding() 210 - .lineLimit(5...10) 212 + .lineLimit(5 ... 10) 211 213 .onChange(of: commentText) { mentionState.update(text: commentText) } 212 214 213 215 Spacer() ··· 244 246 } 245 247 } 246 248 .task { 247 - await viewModel.load(uri: galleryUri, auth: await auth.authContext()) 249 + guard !isPreview else { 250 + #if DEBUG 251 + viewModel.gallery = PreviewData.gallery1 252 + viewModel.comments = PreviewData.comments 253 + #endif 254 + return 255 + } 256 + await viewModel.load(uri: galleryUri, auth: auth.authContext()) 248 257 } 249 258 } 250 259 ··· 268 277 var recordDict: [String: String] = [ 269 278 "text": text, 270 279 "subject": galleryUri, 271 - "createdAt": DateFormatting.nowISO() 280 + "createdAt": DateFormatting.nowISO(), 272 281 ] 273 282 if let replyTarget = replyingTo { 274 283 recordDict["replyTo"] = replyTarget.uri ··· 315 324 @Environment(StoryStatusCache.self) private var storyStatusCache 316 325 @Environment(ViewedStoryStorage.self) private var viewedStories 317 326 let comment: GrainComment 327 + let userDID: String? 318 328 var isOwn: Bool = false 319 329 var isReply: Bool = false 320 330 var onProfileTap: ((String) -> Void)? ··· 326 336 var body: some View { 327 337 HStack(alignment: .top, spacing: 8) { 328 338 let avatarSize: CGFloat = isReply ? 24 : 28 329 - StoryRingView(hasStory: storyStatusCache.hasStory(for: comment.author.did), viewed: viewedStories.hasViewedAll(did: comment.author.did, storyStatusCache: storyStatusCache), size: avatarSize) { 339 + StoryRingView( 340 + hasStory: storyStatusCache.hasStory(for: comment.author.did), 341 + viewed: comment.author.did != userDID && viewedStories.hasViewedAll(did: comment.author.did, storyStatusCache: storyStatusCache), 342 + size: avatarSize 343 + ) { 330 344 AvatarView(url: comment.author.avatar, size: avatarSize) 331 345 } 332 346 .onTapGesture { ··· 385 399 .padding(.trailing, 12) 386 400 .padding(.vertical, 8) 387 401 } 402 + } 388 403 404 + #Preview { 405 + GalleryDetailView( 406 + client: XRPCClient(baseURL: AuthManager.serverURL), 407 + galleryUri: "at://did:plc:preview/social.grain.gallery/r1" 408 + ) 409 + .environment(AuthManager()) 410 + .environment(LabelDefinitionsCache()) 389 411 }
+195 -172
Grain/Views/LoginView.swift
··· 2 2 import SwiftUI 3 3 4 4 struct LoginView: View { 5 + static let legalMarkdown = 6 + "By signing in you agree to our [Terms](https://grain.social/support/terms), " + 7 + "[Privacy Policy](https://grain.social/support/privacy), " + 8 + "and [Community Guidelines](https://grain.social/support/community-guidelines)." 9 + 5 10 @Environment(AuthManager.self) private var auth 6 11 @State private var handle = "" 7 12 @State private var isLoading = false 13 + @State private var isSearching = false 8 14 @State private var errorMessage: String? 9 15 @State private var suggestions: [ActorSuggestion] = [] 10 16 @State private var searchTask: Task<Void, Never>? ··· 26 32 .allowsHitTesting(false) 27 33 28 34 ScrollViewReader { proxy in 29 - ScrollView { 30 - if suggestions.isEmpty { 31 - Spacer() 32 - .frame(minHeight: 100) 33 - .frame(maxHeight: .infinity) 34 - } else { 35 - Spacer() 36 - .frame(height: 60) 37 - } 38 - 39 - // Logo 40 - Text("grain") 41 - .font(.custom("Syne", size: 44).weight(.heavy)) 42 - .foregroundStyle(.white) 43 - .padding(.bottom, suggestions.isEmpty ? 60 : 20) 35 + ScrollView { 36 + if suggestions.isEmpty { 37 + Spacer() 38 + .frame(minHeight: 100) 39 + .frame(maxHeight: .infinity) 40 + } else { 41 + Spacer() 42 + .frame(height: 60) 43 + } 44 44 45 - if suggestions.isEmpty { 46 - // Heading 47 - Text("Log in with your internet handle") 48 - .font(.title3.weight(.semibold)) 45 + // Logo 46 + Text("grain") 47 + .font(.custom("Syne", size: 44).weight(.heavy)) 49 48 .foregroundStyle(.white) 50 - .multilineTextAlignment(.center) 51 - .padding(.bottom, 4) 52 - 53 - Text("Enter the domain you use as your identity across the open social web.") 54 - .font(.subheadline) 55 - .foregroundStyle(.white.opacity(0.7)) 56 - .multilineTextAlignment(.center) 57 - .padding(.horizontal, 8) 49 + .padding(.bottom, suggestions.isEmpty ? 60 : 20) 58 50 59 - Link("Learn more", destination: URL(string: "https://internethandle.org")!) 60 - .font(.subheadline.weight(.medium)) 61 - .underline() 62 - .foregroundStyle(.white) 63 - .padding(.bottom, 16) 64 - } 51 + if suggestions.isEmpty { 52 + // Heading 53 + Text("Log in with your internet handle") 54 + .font(.title3.weight(.semibold)) 55 + .foregroundStyle(.white) 56 + .multilineTextAlignment(.center) 57 + .padding(.bottom, 4) 65 58 66 - // Login card 67 - VStack(spacing: 16) { 68 - // Handle input 69 - HStack(spacing: 10) { 70 - Image(systemName: "at") 71 - .font(.body.weight(.medium)) 72 - .foregroundStyle(.white.opacity(0.5)) 59 + Text("Enter the domain you use as your identity across the open social web.") 60 + .font(.subheadline) 61 + .foregroundStyle(.white.opacity(0.7)) 62 + .multilineTextAlignment(.center) 63 + .padding(.horizontal, 8) 73 64 74 - TextField("e.g. jasmine.garden", text: $handle, prompt: Text("e.g. jasmine.garden").foregroundStyle(.white.opacity(0.5))) 65 + Link("Learn more", destination: URL(string: "https://internethandle.org")!) 66 + .font(.subheadline.weight(.medium)) 67 + .underline() 75 68 .foregroundStyle(.white) 76 - .textContentType(.username) 77 - .autocorrectionDisabled() 78 - .textInputAutocapitalization(.never) 79 - .submitLabel(.go) 80 - .onSubmit { 81 - if !handle.isEmpty { Task { await login() } } 82 - } 83 - .onChange(of: handle) { 84 - searchTask?.cancel() 85 - let query = handle 86 - searchTask = Task { 87 - try? await Task.sleep(for: .milliseconds(200)) 88 - guard !Task.isCancelled else { return } 89 - await searchActors(query: query) 69 + .padding(.bottom, 16) 70 + } 71 + 72 + // Login card 73 + VStack(spacing: 16) { 74 + // Handle input 75 + HStack(spacing: 10) { 76 + Image(systemName: "at") 77 + .font(.body.weight(.medium)) 78 + .foregroundStyle(.white.opacity(0.5)) 79 + 80 + TextField("e.g. jasmine.garden", text: $handle, prompt: Text("e.g. jasmine.garden").foregroundStyle(.white.opacity(0.5))) 81 + .foregroundStyle(.white) 82 + .textContentType(.username) 83 + .autocorrectionDisabled() 84 + .textInputAutocapitalization(.never) 85 + .submitLabel(.go) 86 + .onSubmit { 87 + if !handle.isEmpty { Task { await login() } } 90 88 } 89 + .onChange(of: handle) { 90 + searchTask?.cancel() 91 + let query = handle 92 + let trimmed = query.trimmingCharacters(in: .whitespaces) 93 + isSearching = trimmed.count >= 2 94 + searchTask = Task { 95 + try? await Task.sleep(for: .milliseconds(200)) 96 + guard !Task.isCancelled else { return } 97 + await searchActors(query: query) 98 + } 99 + } 100 + 101 + if isSearching { 102 + ProgressView() 103 + .tint(.white.opacity(0.7)) 104 + .scaleEffect(0.8) 91 105 } 92 - } 93 - .padding(.horizontal, 16) 94 - .padding(.vertical, 14) 95 - .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) 96 - .overlay( 97 - RoundedRectangle(cornerRadius: 12) 98 - .stroke(.white.opacity(0.25), lineWidth: 1) 99 - ) 106 + } 107 + .padding(.horizontal, 16) 108 + .padding(.vertical, 14) 109 + .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) 110 + .overlay( 111 + RoundedRectangle(cornerRadius: 12) 112 + .stroke(.white.opacity(0.25), lineWidth: 1) 113 + ) 100 114 101 - // Suggestions 102 - if !suggestions.isEmpty { 103 - VStack(spacing: 0) { 104 - ForEach(suggestions) { actor in 105 - Button { 106 - handle = actor.handle 107 - suggestions = [] 108 - Task { await login() } 109 - } label: { 110 - HStack(spacing: 10) { 111 - if let avatar = actor.avatar, let url = URL(string: avatar) { 112 - LazyImage(url: url) { state in 113 - if let image = state.image { 114 - image.resizable().scaledToFill() 115 - } else { 116 - Circle().fill(.white.opacity(0.2)) 115 + // Suggestions 116 + if !suggestions.isEmpty { 117 + VStack(spacing: 0) { 118 + ForEach(suggestions) { actor in 119 + Button { 120 + handle = actor.handle 121 + suggestions = [] 122 + Task { await login() } 123 + } label: { 124 + HStack(spacing: 10) { 125 + if let avatar = actor.avatar, let url = URL(string: avatar) { 126 + LazyImage(url: url) { state in 127 + if let image = state.image { 128 + image.resizable().scaledToFill() 129 + } else { 130 + Circle().fill(.white.opacity(0.2)) 131 + } 117 132 } 133 + .frame(width: 32, height: 32) 134 + .clipShape(Circle()) 135 + } else { 136 + Circle() 137 + .fill(.white.opacity(0.2)) 138 + .frame(width: 32, height: 32) 118 139 } 119 - .frame(width: 32, height: 32) 120 - .clipShape(Circle()) 121 - } else { 122 - Circle() 123 - .fill(.white.opacity(0.2)) 124 - .frame(width: 32, height: 32) 125 - } 126 140 127 - VStack(alignment: .leading, spacing: 1) { 128 - if let displayName = actor.displayName, !displayName.isEmpty { 129 - Text(displayName) 130 - .font(.subheadline.weight(.medium)) 131 - .foregroundStyle(.white) 141 + VStack(alignment: .leading, spacing: 1) { 142 + if let displayName = actor.displayName, !displayName.isEmpty { 143 + Text(displayName) 144 + .font(.subheadline.weight(.medium)) 145 + .foregroundStyle(.white) 146 + .lineLimit(1) 147 + } 148 + Text("@\(actor.handle)") 149 + .font(.caption) 150 + .foregroundStyle(.white.opacity(0.6)) 132 151 .lineLimit(1) 133 152 } 134 - Text("@\(actor.handle)") 135 - .font(.caption) 136 - .foregroundStyle(.white.opacity(0.6)) 137 - .lineLimit(1) 153 + 154 + Spacer() 138 155 } 156 + .padding(.horizontal, 16) 157 + .padding(.vertical, 8) 158 + .contentShape(Rectangle()) 159 + } 160 + .buttonStyle(.plain) 139 161 140 - Spacer() 162 + if actor.id != suggestions.last?.id { 163 + Divider() 164 + .background(.white.opacity(0.15)) 165 + .padding(.leading, 58) 141 166 } 142 - .padding(.horizontal, 16) 143 - .padding(.vertical, 8) 144 - .contentShape(Rectangle()) 145 167 } 146 - .buttonStyle(.plain) 168 + } 169 + .padding(.vertical, 4) 170 + .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) 171 + .overlay( 172 + RoundedRectangle(cornerRadius: 12) 173 + .stroke(.white.opacity(0.2), lineWidth: 1) 174 + ) 175 + .id("suggestions") 176 + } 147 177 148 - if actor.id != suggestions.last?.id { 149 - Divider() 150 - .background(.white.opacity(0.15)) 151 - .padding(.leading, 58) 178 + // Sign in button 179 + Button { 180 + Task { await login() } 181 + } label: { 182 + Group { 183 + if isLoading { 184 + ProgressView() 185 + .tint(.black) 186 + } else { 187 + Text("Sign In") 188 + .font(.body.weight(.semibold)) 152 189 } 153 190 } 191 + .frame(maxWidth: .infinity) 192 + .padding(.vertical, 14) 154 193 } 155 - .padding(.vertical, 4) 156 - .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) 194 + .background(.white, in: RoundedRectangle(cornerRadius: 12)) 195 + .foregroundStyle(.black) 196 + .disabled(handle.isEmpty || isLoading) 197 + .opacity(handle.isEmpty ? 0.4 : 1) 198 + 199 + // Create account button 200 + Button { 201 + Task { await createAccount() } 202 + } label: { 203 + Text("Create Account") 204 + .font(.body.weight(.semibold)) 205 + .frame(maxWidth: .infinity) 206 + .padding(.vertical, 14) 207 + } 208 + .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) 157 209 .overlay( 158 210 RoundedRectangle(cornerRadius: 12) 159 - .stroke(.white.opacity(0.2), lineWidth: 1) 211 + .stroke(.white.opacity(0.25), lineWidth: 1) 160 212 ) 161 - .id("suggestions") 213 + .foregroundStyle(.white) 214 + .disabled(isLoading) 215 + // Legal links 216 + Text(LocalizedStringKey(Self.legalMarkdown)) 217 + .font(.caption2) 218 + .foregroundStyle(.white.opacity(0.5)) 219 + .tint(.white.opacity(0.7)) 220 + .multilineTextAlignment(.center) 162 221 } 222 + .padding(24) 163 223 164 - // Sign in button 165 - Button { 166 - Task { await login() } 167 - } label: { 168 - Group { 169 - if isLoading { 170 - ProgressView() 171 - .tint(.black) 172 - } else { 173 - Text("Sign In") 174 - .font(.body.weight(.semibold)) 175 - } 176 - } 177 - .frame(maxWidth: .infinity) 178 - .padding(.vertical, 14) 224 + if let errorMessage { 225 + Text(errorMessage) 226 + .font(.caption) 227 + .foregroundStyle(.red) 228 + .multilineTextAlignment(.center) 229 + .padding(.horizontal, 24) 179 230 } 180 - .background(.white, in: RoundedRectangle(cornerRadius: 12)) 181 - .foregroundStyle(.black) 182 - .disabled(handle.isEmpty || isLoading) 183 - .opacity(handle.isEmpty ? 0.4 : 1) 184 231 185 - // Create account button 186 - Button { 187 - Task { await createAccount() } 188 - } label: { 189 - Text("Create Account") 190 - .font(.body.weight(.semibold)) 191 - .frame(maxWidth: .infinity) 192 - .padding(.vertical, 14) 193 - } 194 - .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) 195 - .overlay( 196 - RoundedRectangle(cornerRadius: 12) 197 - .stroke(.white.opacity(0.25), lineWidth: 1) 198 - ) 199 - .foregroundStyle(.white) 200 - .disabled(isLoading) 201 - // Legal links 202 - Text("By signing in you agree to our [Terms](https://grain.social/support/terms), [Privacy Policy](https://grain.social/support/privacy), and [Community Guidelines](https://grain.social/support/community-guidelines).") 203 - .font(.caption2) 204 - .foregroundStyle(.white.opacity(0.5)) 205 - .tint(.white.opacity(0.7)) 206 - .multilineTextAlignment(.center) 232 + Spacer() 233 + .frame(height: 60) 207 234 } 208 - .padding(24) 209 - 210 - if let errorMessage { 211 - Text(errorMessage) 212 - .font(.caption) 213 - .foregroundStyle(.red) 214 - .multilineTextAlignment(.center) 215 - .padding(.horizontal, 24) 216 - } 217 - 218 - Spacer() 219 - .frame(height: 60) 220 - } 221 - .frame(minHeight: geo.size.height) 222 - .onChange(of: suggestions) { 223 - if !suggestions.isEmpty { 224 - withAnimation { 225 - proxy.scrollTo("suggestions", anchor: .bottom) 235 + .frame(minHeight: geo.size.height) 236 + .onChange(of: suggestions) { 237 + if !suggestions.isEmpty { 238 + withAnimation { 239 + proxy.scrollTo("suggestions", anchor: .bottom) 240 + } 226 241 } 227 242 } 228 243 } 229 - } 230 - .scrollDismissesKeyboard(.interactively) 231 - .scrollIndicators(.hidden) 244 + .scrollDismissesKeyboard(.interactively) 245 + .scrollIndicators(.hidden) 232 246 } // ScrollViewReader 233 247 } 234 248 } ··· 260 274 } 261 275 262 276 private func searchActors(query: String) async { 277 + defer { isSearching = false } 263 278 let trimmed = query.trimmingCharacters(in: .whitespaces) 264 279 guard trimmed.count >= 2 else { 265 280 suggestions = [] ··· 275 290 276 291 guard let (data, _) = try? await URLSession.shared.data(from: url), 277 292 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 278 - let actors = json["actors"] as? [[String: Any]] else { 293 + let actors = json["actors"] as? [[String: Any]] 294 + else { 279 295 return 280 296 } 281 297 ··· 294 310 let handle: String 295 311 let displayName: String? 296 312 let avatar: String? 297 - var id: String { handle } 313 + var id: String { 314 + handle 315 + } 316 + } 317 + 318 + #Preview { 319 + LoginView() 320 + .environment(AuthManager()) 298 321 }
+29 -6
Grain/Views/MainTabView.swift
··· 16 16 @State private var notificationsVM = NotificationsViewModel(client: XRPCClient(baseURL: AuthManager.serverURL)) 17 17 @Binding var pendingDeepLink: DeepLink? 18 18 19 - static let badgeAppearanceConfigured: Bool = { 19 + @MainActor static let badgeAppearanceConfigured: Bool = MainActor.assumeIsolated { 20 20 let color = UIColor(named: "AccentColor") 21 21 let textAttrs: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.white] 22 22 let appearance = UITabBarAppearance() ··· 32 32 UITabBar.appearance().standardAppearance = appearance 33 33 UITabBar.appearance().scrollEdgeAppearance = appearance 34 34 return true 35 - }() 35 + } 36 36 37 37 var body: some View { 38 38 let _ = Self.badgeAppearanceConfigured ··· 78 78 if let uiImage = auth.avatarImage { 79 79 avatarTabImage = circularAvatar(uiImage, size: 26) 80 80 } 81 - await notificationsVM.fetchUnseenCount(auth: await auth.authContext()) 82 - await labelDefsCache.loadIfNeeded(client: c, auth: await auth.authContext()) 81 + await notificationsVM.fetchUnseenCount(auth: auth.authContext()) 82 + await labelDefsCache.loadIfNeeded(client: c, auth: auth.authContext()) 83 83 } 84 84 .onChange(of: auth.avatarImage) { 85 85 if let uiImage = auth.avatarImage { ··· 95 95 if scenePhase == .active { 96 96 Task { 97 97 try? await auth.refreshIfNeeded() 98 - await notificationsVM.fetchUnseenCount(auth: await auth.authContext()) 99 - await labelDefsCache.loadIfNeeded(client: client, auth: await auth.authContext()) 98 + await notificationsVM.fetchUnseenCount(auth: auth.authContext()) 99 + await labelDefsCache.loadIfNeeded(client: client, auth: auth.authContext()) 100 100 } 101 101 } 102 102 } ··· 106 106 feedRefreshID = UUID() 107 107 } 108 108 } 109 + .onReceive(NotificationCenter.default.publisher(for: .grainShortcutAction)) { notification in 110 + guard let rawValue = notification.object as? String, 111 + let action = GrainShortcutAction(rawValue: rawValue) 112 + else { return } 113 + switch action { 114 + case .feed: selectedTab = .feed 115 + case .search: selectedTab = .search 116 + case .notifications: selectedTab = .notifications 117 + case .profile: selectedTab = .profile 118 + case .createGallery: 119 + selectedTab = .feed 120 + showCreate = true 121 + } 122 + } 109 123 } 110 124 111 125 private func circularAvatar(_ image: UIImage, size: CGFloat) -> UIImage { ··· 118 132 return circled.withRenderingMode(.alwaysOriginal) 119 133 } 120 134 } 135 + 136 + #Preview { 137 + MainTabView(pendingDeepLink: .constant(nil)) 138 + .environment(AuthManager()) 139 + .environment(PushManager()) 140 + .environment(StoryStatusCache()) 141 + .environment(ViewedStoryStorage()) 142 + .environment(LabelDefinitionsCache()) 143 + }
+37 -27
Grain/Views/Notifications/NotificationsView.swift
··· 1 - import SwiftUI 2 1 import NukeUI 2 + import SwiftUI 3 3 4 4 struct NotificationsView: View { 5 5 @Environment(AuthManager.self) private var auth ··· 18 18 NavigationStack { 19 19 List { 20 20 ForEach(viewModel.notifications) { notification in 21 - NotificationRow(notification: notification, onProfileTap: { did in 22 - selectedProfileDid = did 23 - }, onStoryTap: { author in 24 - cardStoryAuthor = author 25 - }) 26 - .contentShape(Rectangle()) 27 - .onTapGesture { 28 - if notification.reasonType == .follow { 29 - selectedProfileDid = notification.author.did 30 - } else if let galleryUri = notification.galleryUri { 31 - selectedGalleryUri = galleryUri 32 - } 21 + NotificationRow(notification: notification, userDID: auth.userDID, onProfileTap: { did in 22 + selectedProfileDid = did 23 + }, onStoryTap: { author in 24 + cardStoryAuthor = author 25 + }) 26 + .contentShape(Rectangle()) 27 + .onTapGesture { 28 + if notification.reasonType == .follow { 29 + selectedProfileDid = notification.author.did 30 + } else if let galleryUri = notification.galleryUri { 31 + selectedGalleryUri = galleryUri 33 32 } 34 - .swipeActions(edge: .leading) { 35 - Button { 36 - selectedProfileDid = notification.author.did 37 - } label: { 38 - Label("Profile", systemImage: "person") 39 - } 33 + } 34 + .swipeActions(edge: .leading) { 35 + Button { 36 + selectedProfileDid = notification.author.did 37 + } label: { 38 + Label("Profile", systemImage: "person") 40 39 } 41 - .onAppear { 42 - if notification.id == viewModel.notifications.last?.id { 43 - Task { await viewModel.loadMore(auth: await auth.authContext()) } 44 - } 40 + } 41 + .onAppear { 42 + if notification.id == viewModel.notifications.last?.id { 43 + Task { await viewModel.loadMore(auth: auth.authContext()) } 45 44 } 45 + } 46 46 } 47 47 48 48 if viewModel.isLoading { ··· 55 55 } 56 56 .listStyle(.plain) 57 57 .refreshable { 58 - await viewModel.loadInitial(auth: await auth.authContext()) 58 + await viewModel.loadInitial(auth: auth.authContext()) 59 59 } 60 60 .navigationTitle("Notifications") 61 61 .navigationDestination(item: $selectedGalleryUri) { uri in ··· 78 78 } 79 79 .task(id: viewModel.unseenCount) { 80 80 if viewModel.notifications.isEmpty || viewModel.unseenCount > 0 { 81 - await viewModel.loadInitial(auth: await auth.authContext()) 81 + await viewModel.loadInitial(auth: auth.authContext()) 82 82 } 83 - await viewModel.markAsSeen(auth: await auth.authContext()) 83 + await viewModel.markAsSeen(auth: auth.authContext()) 84 84 } 85 85 } 86 86 } ··· 90 90 @Environment(StoryStatusCache.self) private var storyStatusCache 91 91 @Environment(ViewedStoryStorage.self) private var viewedStories 92 92 let notification: GrainNotification 93 + let userDID: String? 93 94 var onProfileTap: ((String) -> Void)? 94 95 var onStoryTap: ((GrainStoryAuthor) -> Void)? 95 96 96 97 var body: some View { 97 98 HStack(alignment: .top, spacing: 12) { 98 - StoryRingView(hasStory: storyStatusCache.hasStory(for: notification.author.did), viewed: viewedStories.hasViewedAll(did: notification.author.did, storyStatusCache: storyStatusCache), size: 36) { 99 + StoryRingView(hasStory: storyStatusCache.hasStory(for: notification.author.did), viewed: notification.author.did != userDID && viewedStories.hasViewedAll(did: notification.author.did, storyStatusCache: storyStatusCache), size: 36) { 99 100 AvatarView(url: notification.author.avatar, size: 36) 100 101 } 101 102 .onTapGesture { ··· 154 155 } 155 156 } 156 157 } 158 + 159 + #Preview { 160 + let client = XRPCClient(baseURL: AuthManager.serverURL) 161 + let vm = NotificationsViewModel(client: client) 162 + vm.notifications = PreviewData.notifications 163 + vm.unseenCount = 3 164 + return NotificationsView(client: client, viewModel: vm) 165 + .environment(AuthManager()) 166 + }
+38 -22
Grain/Views/Profile/FollowListView.swift
··· 1 - import SwiftUI 2 1 import NukeUI 2 + import SwiftUI 3 3 4 4 enum FollowListMode: Hashable { 5 5 case followers ··· 36 36 37 37 var body: some View { 38 38 Group { 39 - if !hasLoaded && isLoading { 39 + if !hasLoaded, isLoading { 40 40 ProgressView() 41 41 .frame(maxWidth: .infinity, maxHeight: .infinity) 42 - } else if hasLoaded && items.isEmpty { 42 + } else if hasLoaded, items.isEmpty { 43 43 ContentUnavailableView( 44 44 "No \(title)", 45 45 systemImage: mode == .knownFollowers ? "person.2" : mode == .followers ? "person.2" : "person.badge.plus", 46 - description: Text(mode == .knownFollowers ? "None of the people you follow are following this account." : mode == .followers ? "No one is following this account yet." : "This account isn't following anyone yet.") 46 + description: Text( 47 + mode == .knownFollowers 48 + ? "None of the people you follow are following this account." 49 + : mode == .followers 50 + ? "No one is following this account yet." 51 + : "This account isn't following anyone yet." 52 + ) 47 53 ) 48 54 } else { 49 55 List { ··· 102 108 } 103 109 } 104 110 105 - @ViewBuilder 106 111 private func rowContent(item: FollowListItem) -> some View { 107 112 HStack(alignment: .center, spacing: 14) { 108 - StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), viewed: viewedStories.hasViewedAll(did: item.did, storyStatusCache: storyStatusCache), size: 50) { 113 + StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), viewed: item.did != auth.userDID && viewedStories.hasViewedAll(did: item.did, storyStatusCache: storyStatusCache), size: 50) { 109 114 AvatarView(url: item.avatar, size: 50) 110 115 } 111 116 .onTapGesture { ··· 171 176 172 177 private func loadMore() async { 173 178 guard !isLoading else { return } 174 - if !items.isEmpty && cursor == nil { return } 179 + if !items.isEmpty, cursor == nil { return } 175 180 isLoading = true 176 181 defer { isLoading = false } 177 182 ··· 210 215 items[index].followingUri = nil 211 216 do { 212 217 try await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: authContext) 213 - if mode == .following && did == repo { 218 + if mode == .following, did == repo { 214 219 if let idx = items.firstIndex(where: { $0.did == targetDid }) { 215 220 items.remove(at: idx) 216 221 } ··· 226 231 do { 227 232 let record = AnyCodable([ 228 233 "subject": item.did, 229 - "createdAt": DateFormatting.nowISO() 234 + "createdAt": DateFormatting.nowISO(), 230 235 ]) 231 236 let result = try await client.createRecord( 232 237 collection: "social.grain.graph.follow", ··· 253 258 var description: String? 254 259 var avatar: String? 255 260 var followingUri: String? 256 - var id: String { did } 261 + var id: String { 262 + did 263 + } 257 264 258 265 init(from follower: FollowerItem) { 259 - self.did = follower.did 260 - self.handle = follower.handle 261 - self.displayName = follower.displayName 262 - self.description = follower.description 263 - self.avatar = follower.avatar 264 - self.followingUri = follower.viewer?.following 266 + did = follower.did 267 + handle = follower.handle 268 + displayName = follower.displayName 269 + description = follower.description 270 + avatar = follower.avatar 271 + followingUri = follower.viewer?.following 265 272 } 266 273 267 274 init(from following: FollowingItem) { 268 - self.did = following.did 269 - self.handle = following.handle 270 - self.displayName = following.displayName 271 - self.description = following.description 272 - self.avatar = following.avatar 273 - self.followingUri = following.viewer?.following 275 + did = following.did 276 + handle = following.handle 277 + displayName = following.displayName 278 + description = following.description 279 + avatar = following.avatar 280 + followingUri = following.viewer?.following 274 281 } 275 282 } 283 + 284 + #Preview { 285 + FollowListView( 286 + client: XRPCClient(baseURL: AuthManager.serverURL), 287 + did: "did:plc:preview", 288 + mode: .followers 289 + ) 290 + .environment(AuthManager()) 291 + }
+295 -234
Grain/Views/Profile/ProfileView.swift
··· 1 - import SwiftUI 2 1 import NukeUI 2 + import SwiftUI 3 3 4 4 enum ProfileViewMode: String, CaseIterable { 5 5 case grid, list ··· 11 11 @Environment(ViewedStoryStorage.self) private var viewedStories 12 12 @Environment(LabelDefinitionsCache.self) private var labelDefsCache 13 13 @State private var showStoryViewer = false 14 + @State private var showStoryCreate = false 14 15 @State private var showAvatarOverlay = false 15 16 @State private var viewModel: ProfileDetailViewModel 16 17 @State private var selectedGalleryUri: String? ··· 26 27 var isRoot = false 27 28 28 29 /// Resolved DID from the loaded profile, or the original actor identifier 29 - private var did: String { viewModel.profile?.did ?? actor } 30 + private var did: String { 31 + viewModel.profile?.did ?? actor 32 + } 30 33 31 34 init(client: XRPCClient, did: String, isRoot: Bool = false) { 32 35 self.client = client 33 36 _viewModel = State(initialValue: ProfileDetailViewModel(client: client)) 34 - self.actor = did 37 + actor = did 35 38 self.isRoot = isRoot 36 39 } 37 40 ··· 47 50 48 51 private var profileContent: some View { 49 52 ScrollView { 50 - if let profile = viewModel.profile { 51 - VStack(spacing: 12) { 52 - // Avatar + stats row 53 - HStack(alignment: .center, spacing: 16) { 54 - StoryRingView(hasStory: !viewModel.stories.isEmpty, viewed: viewedStories.hasViewedAll(authorDid: did, latestAt: viewModel.stories.last?.createdAt ?? ""), size: 80) { 55 - AvatarView(url: profile.avatar, size: 80) 56 - .liquidGlassCircle() 53 + if let profile = viewModel.profile { 54 + VStack(spacing: 12) { 55 + // Avatar + stats row 56 + HStack(alignment: .center, spacing: 16) { 57 + StoryRingView(hasStory: !viewModel.stories.isEmpty, viewed: did != auth.userDID && viewedStories.hasViewedAll(authorDid: did, latestAt: viewModel.stories.last?.createdAt ?? ""), size: 80) { 58 + AvatarView(url: profile.avatar, size: 80) 59 + .liquidGlassCircle() 60 + } 61 + .overlay(alignment: .bottomTrailing) { 62 + if did == auth.userDID { 63 + ZStack { 64 + Circle() 65 + .fill(.white) 66 + .frame(width: 22, height: 22) 67 + Image(systemName: "plus") 68 + .font(.system(size: 11, weight: .bold)) 69 + .foregroundStyle(.black) 70 + } 71 + .offset(x: 4, y: 4) 57 72 } 58 - .scaleEffect(avatarPressed ? 1.08 : 1.0) 59 - .animation(.spring(response: 0.2, dampingFraction: 0.6), value: avatarPressed) 60 - .contentShape(Circle()) 61 - .onTapGesture { 73 + } 74 + .scaleEffect(avatarPressed ? 1.08 : 1.0) 75 + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: avatarPressed) 76 + .contentShape(Circle()) 77 + .onTapGesture { 78 + if did == auth.userDID { 79 + if !viewModel.stories.isEmpty { 80 + showStoryViewer = true 81 + } else { 82 + showStoryCreate = true 83 + } 84 + } else { 62 85 if !viewModel.stories.isEmpty { 63 86 showStoryViewer = true 64 87 } else if profile.avatar != nil { 65 88 showAvatarOverlay = true 66 89 } 67 90 } 68 - .simultaneousGesture( 69 - DragGesture(minimumDistance: 0) 70 - .onChanged { _ in if !avatarPressed { avatarPressed = true } } 71 - .onEnded { _ in avatarPressed = false } 72 - ) 91 + } 92 + .onLongPressGesture(minimumDuration: 0.5) { 93 + if did == auth.userDID { 94 + showStoryCreate = true 95 + } 96 + } 97 + .simultaneousGesture( 98 + DragGesture(minimumDistance: 0) 99 + .onChanged { _ in if !avatarPressed { avatarPressed = true } } 100 + .onEnded { _ in avatarPressed = false } 101 + ) 73 102 74 - HStack(spacing: 0) { 75 - StatView(count: profile.galleryCount ?? 0, label: "Galleries") 76 - .frame(maxWidth: .infinity) 77 - NavigationLink { 78 - FollowListView(client: client, did: did, mode: .followers) 79 - } label: { 80 - StatView(count: profile.followersCount ?? 0, label: "Followers") 81 - } 82 - .buttonStyle(.plain) 103 + HStack(spacing: 0) { 104 + StatView(count: profile.galleryCount ?? 0, label: "Galleries") 83 105 .frame(maxWidth: .infinity) 84 - NavigationLink { 85 - FollowListView(client: client, did: did, mode: .following) 86 - } label: { 87 - StatView(count: profile.followsCount ?? 0, label: "Following") 88 - } 89 - .buttonStyle(.plain) 90 - .frame(maxWidth: .infinity) 106 + NavigationLink { 107 + FollowListView(client: client, did: did, mode: .followers) 108 + } label: { 109 + StatView(count: profile.followersCount ?? 0, label: "Followers") 110 + } 111 + .buttonStyle(.plain) 112 + .frame(maxWidth: .infinity) 113 + NavigationLink { 114 + FollowListView(client: client, did: did, mode: .following) 115 + } label: { 116 + StatView(count: profile.followsCount ?? 0, label: "Following") 91 117 } 118 + .buttonStyle(.plain) 119 + .frame(maxWidth: .infinity) 92 120 } 93 - .padding(.horizontal) 94 - .padding(.top, 8) 121 + } 122 + .padding(.horizontal) 123 + .padding(.top, 8) 124 + 125 + // Name + handle + bio 126 + VStack(alignment: .leading, spacing: 4) { 127 + Text(profile.displayName ?? profile.handle) 128 + .font(.subheadline.bold()) 129 + Text("@\(profile.handle)") 130 + .font(.subheadline) 131 + .foregroundStyle(.secondary) 132 + 133 + if let description = profile.description, !description.isEmpty { 134 + RichTextView( 135 + text: description, 136 + font: .subheadline, 137 + onMentionTap: { did in selectedProfileDid = did }, 138 + onHashtagTap: { tag in selectedHashtag = tag } 139 + ) 140 + .padding(.top, 2) 141 + } 142 + } 143 + .frame(maxWidth: .infinity, alignment: .leading) 144 + .padding(.horizontal) 145 + 146 + // Known followers 147 + if !viewModel.knownFollowers.isEmpty, did != auth.userDID { 148 + NavigationLink { 149 + FollowListView(client: client, did: did, mode: .knownFollowers) 150 + } label: { 151 + knownFollowersRow 152 + } 153 + .buttonStyle(.plain) 154 + } 95 155 96 - // Name + handle + bio 97 - VStack(alignment: .leading, spacing: 4) { 98 - Text(profile.displayName ?? profile.handle) 99 - .font(.subheadline.bold()) 100 - Text("@\(profile.handle)") 101 - .font(.subheadline) 102 - .foregroundStyle(.secondary) 156 + // Follow + Germ DM buttons 157 + if did != auth.userDID { 158 + HStack(spacing: 8) { 159 + followButton(profile: profile) 103 160 104 - if let description = profile.description, !description.isEmpty { 105 - RichTextView( 106 - text: description, 107 - font: .subheadline, 108 - onMentionTap: { did in selectedProfileDid = did }, 109 - onHashtagTap: { tag in selectedHashtag = tag } 110 - ) 111 - .padding(.top, 2) 161 + if let germUrl = germDMUrl(profile: profile) { 162 + Link(destination: germUrl) { 163 + HStack(spacing: 4) { 164 + Image("germ-logo") 165 + .resizable() 166 + .frame(width: 14, height: 14) 167 + Text("Germ DM") 168 + .font(.subheadline.weight(.semibold)) 169 + } 170 + .frame(maxWidth: .infinity) 171 + } 172 + .buttonStyle(.bordered) 173 + .tint(Color(red: 0.52, green: 0.63, blue: 1.0)) 112 174 } 113 175 } 114 - .frame(maxWidth: .infinity, alignment: .leading) 115 176 .padding(.horizontal) 116 - 117 - // Known followers 118 - if !viewModel.knownFollowers.isEmpty && did != auth.userDID { 177 + } else { 178 + HStack(spacing: 8) { 119 179 NavigationLink { 120 - FollowListView(client: client, did: did, mode: .knownFollowers) 180 + EditProfileView(client: client, onSaved: { 181 + Task { await viewModel.load(did: did) } 182 + }) 121 183 } label: { 122 - knownFollowersRow 184 + Text("Edit Profile") 185 + .font(.subheadline.weight(.semibold)) 186 + .frame(maxWidth: .infinity) 123 187 } 124 - .buttonStyle(.plain) 125 - } 188 + .buttonStyle(.bordered) 126 189 127 - // Follow + Germ DM buttons 128 - if did != auth.userDID { 129 - HStack(spacing: 8) { 130 - followButton(profile: profile) 131 - 132 - if let germUrl = germDMUrl(profile: profile) { 133 - Link(destination: germUrl) { 134 - HStack(spacing: 4) { 135 - Image("germ-logo") 136 - .resizable() 137 - .frame(width: 14, height: 14) 138 - Text("Germ DM") 139 - .font(.subheadline.weight(.semibold)) 140 - } 141 - .frame(maxWidth: .infinity) 190 + if let germUrl = germDMUrl(profile: profile) { 191 + Link(destination: germUrl) { 192 + HStack(spacing: 4) { 193 + Image("germ-logo") 194 + .resizable() 195 + .frame(width: 14, height: 14) 196 + Text("Germ DM") 197 + .font(.subheadline.weight(.semibold)) 142 198 } 143 - .buttonStyle(.bordered) 144 - .tint(Color(red: 0.52, green: 0.63, blue: 1.0)) 199 + .frame(maxWidth: .infinity) 145 200 } 201 + .buttonStyle(.bordered) 202 + .tint(Color(red: 0.52, green: 0.63, blue: 1.0)) 146 203 } 147 - .padding(.horizontal) 148 - } else if let germUrl = germDMUrl(profile: profile) { 149 - Link(destination: germUrl) { 150 - HStack(spacing: 4) { 151 - Image("germ-logo") 152 - .resizable() 153 - .frame(width: 14, height: 14) 154 - Text("Germ DM") 155 - .font(.subheadline.weight(.semibold)) 156 - } 157 - .frame(maxWidth: .infinity) 158 - } 159 - .buttonStyle(.bordered) 160 - .tint(Color(red: 0.52, green: 0.63, blue: 1.0)) 161 - .padding(.horizontal) 162 204 } 205 + .padding(.horizontal) 206 + } 163 207 164 - // Galleries 165 - if viewModel.galleries.isEmpty && !viewModel.isLoading { 166 - Text("No galleries yet") 167 - .font(.subheadline) 168 - .foregroundStyle(.tertiary) 169 - .frame(maxWidth: .infinity) 170 - .padding(.top, 60) 171 - } else { 172 - LazyVGrid(columns: [ 173 - GridItem(.flexible(), spacing: 2), 174 - GridItem(.flexible(), spacing: 2), 175 - GridItem(.flexible(), spacing: 2) 176 - ], spacing: 2) { 177 - ForEach(viewModel.galleries) { gallery in 178 - Button { 179 - selectedGalleryUri = gallery.uri 180 - } label: { 181 - Color.clear 182 - .aspectRatio(3.0/4.0, contentMode: .fit) 183 - .overlay { 184 - if let photo = gallery.items?.first { 185 - LazyImage(url: URL(string: photo.thumb)) { state in 186 - if let image = state.image { 187 - image 188 - .resizable() 189 - .scaledToFill() 190 - } else { 191 - Rectangle().fill(.quaternary) 192 - } 208 + // Galleries 209 + if viewModel.galleries.isEmpty, !viewModel.isLoading { 210 + Text("No galleries yet") 211 + .font(.subheadline) 212 + .foregroundStyle(.tertiary) 213 + .frame(maxWidth: .infinity) 214 + .padding(.top, 60) 215 + } else { 216 + LazyVGrid(columns: [ 217 + GridItem(.flexible(), spacing: 2), 218 + GridItem(.flexible(), spacing: 2), 219 + GridItem(.flexible(), spacing: 2), 220 + ], spacing: 2) { 221 + ForEach(viewModel.galleries) { gallery in 222 + Button { 223 + selectedGalleryUri = gallery.uri 224 + } label: { 225 + Color.clear 226 + .aspectRatio(3.0 / 4.0, contentMode: .fit) 227 + .overlay { 228 + if let photo = gallery.items?.first { 229 + LazyImage(url: URL(string: photo.thumb)) { state in 230 + if let image = state.image { 231 + image 232 + .resizable() 233 + .scaledToFill() 234 + } else { 235 + Rectangle().fill(.quaternary) 193 236 } 194 237 } 195 238 } 196 - .clipped() 197 - .overlay { 198 - let lr = resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) 199 - if lr.action >= .warnMedia { 200 - Rectangle().fill(Color(.secondarySystemBackground)) 201 - HStack(spacing: 4) { 202 - Image(systemName: "info.circle.fill") 203 - .font(.caption2) 204 - Text(lr.name) 205 - .font(.system(size: 9)) 206 - } 207 - .foregroundStyle(.secondary) 239 + } 240 + .clipped() 241 + .overlay { 242 + let lr = resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) 243 + if lr.action >= .warnMedia { 244 + Rectangle().fill(Color(.secondarySystemBackground)) 245 + HStack(spacing: 4) { 246 + Image(systemName: "info.circle.fill") 247 + .font(.caption2) 248 + Text(lr.name) 249 + .font(.system(size: 9)) 208 250 } 251 + .foregroundStyle(.secondary) 209 252 } 210 - .overlay(alignment: .topTrailing) { 211 - if (gallery.items?.count ?? 0) > 1 { 212 - Image(systemName: "square.on.square.fill") 213 - .font(.system(size: 14)) 214 - .rotationEffect(.degrees(180)) 215 - .foregroundStyle(.white) 216 - .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 217 - .padding(6) 218 - } 253 + } 254 + .overlay(alignment: .topTrailing) { 255 + if (gallery.items?.count ?? 0) > 1 { 256 + Image(systemName: "square.on.square.fill") 257 + .font(.system(size: 14)) 258 + .rotationEffect(.degrees(180)) 259 + .foregroundStyle(.white) 260 + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 261 + .padding(6) 219 262 } 220 - } 221 - .buttonStyle(.plain) 222 - .onAppear { 223 - if gallery.id == viewModel.galleries.last?.id { 224 - Task { await viewModel.loadMoreGalleries(did: did, auth: await auth.authContext()) } 225 263 } 264 + } 265 + .buttonStyle(.plain) 266 + .onAppear { 267 + if gallery.id == viewModel.galleries.last?.id { 268 + Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 226 269 } 227 270 } 228 271 } 229 272 } 230 273 } 231 - } else if viewModel.isLoading { 232 - ProgressView() 233 - .padding(.top, 100) 234 - } else if viewModel.error != nil { 235 - VStack(spacing: 16) { 236 - ContentUnavailableView( 237 - "Profile Not Found", 238 - systemImage: "person.slash", 239 - description: Text("This user doesn't have a Grain profile yet.") 240 - ) 241 - if let url = URL(string: "https://bsky.app/profile/\(actor)") { 242 - Link(destination: url) { 243 - HStack(spacing: 6) { 244 - Image(systemName: "arrow.up.right") 245 - Text("View on Bluesky") 246 - } 247 - .font(.subheadline.weight(.medium)) 274 + } 275 + } else if viewModel.error != nil { 276 + VStack(spacing: 16) { 277 + ContentUnavailableView( 278 + "Profile Not Found", 279 + systemImage: "person.slash", 280 + description: Text("This user doesn't have a Grain profile yet.") 281 + ) 282 + if let url = URL(string: "https://bsky.app/profile/\(actor)") { 283 + Link(destination: url) { 284 + HStack(spacing: 6) { 285 + Image(systemName: "arrow.up.right") 286 + Text("View on Bluesky") 248 287 } 288 + .font(.subheadline.weight(.medium)) 249 289 } 250 290 } 251 - .padding(.top, 40) 252 291 } 292 + .padding(.top, 40) 293 + } else { 294 + ProgressView() 295 + .padding(.top, 100) 253 296 } 254 - .environment(zoomState) 255 - .modifier(ImageZoomOverlay(zoomState: zoomState)) 256 - .navigationTitle("") 257 - .navigationBarTitleDisplayMode(.inline) 258 - .toolbar { 259 - if did == auth.userDID { 260 - ToolbarItem(placement: .topBarTrailing) { 261 - NavigationLink { 262 - SettingsView(client: client, onProfileEdited: { 263 - Task { await viewModel.load(did: actor, viewer: auth.userDID, auth: await auth.authContext()) } 264 - }) 265 - } label: { 266 - Image(systemName: "gearshape") 267 - } 268 - .tint(.primary) 297 + } 298 + .environment(zoomState) 299 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 300 + .navigationTitle("") 301 + .navigationBarTitleDisplayMode(.inline) 302 + .toolbar { 303 + if did == auth.userDID { 304 + ToolbarItem(placement: .topBarTrailing) { 305 + NavigationLink { 306 + SettingsView(client: client) 307 + } label: { 308 + Image(systemName: "gearshape") 269 309 } 310 + .tint(.primary) 270 311 } 271 312 } 272 - .navigationDestination(item: $selectedGalleryUri) { uri in 273 - GalleryDetailView(client: client, galleryUri: uri, deletedGalleryUri: $deletedGalleryUri) 274 - } 275 - .navigationDestination(item: $selectedProfileDid) { did in 276 - ProfileView(client: client, did: did) 277 - } 278 - .navigationDestination(item: $selectedHashtag) { tag in 279 - HashtagFeedView(client: client, tag: tag) 280 - } 281 - .fullScreenCover(isPresented: $showStoryViewer) { 282 - if let profile = viewModel.profile { 283 - StoryViewer( 284 - authors: [GrainStoryAuthor( 285 - profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 286 - storyCount: viewModel.stories.count, 287 - latestAt: viewModel.stories.last?.createdAt ?? "" 288 - )], 289 - startIndex: 0, 290 - client: client, 291 - onProfileTap: { did in 292 - showStoryViewer = false 293 - selectedProfileDid = did 294 - }, 295 - onDismiss: { showStoryViewer = false } 296 - ) 297 - .environment(auth) 298 - } 299 - } 300 - .fullScreenCover(item: $cardStoryAuthor) { author in 313 + } 314 + .navigationDestination(item: $selectedGalleryUri) { uri in 315 + GalleryDetailView(client: client, galleryUri: uri, deletedGalleryUri: $deletedGalleryUri) 316 + } 317 + .navigationDestination(item: $selectedProfileDid) { did in 318 + ProfileView(client: client, did: did) 319 + } 320 + .navigationDestination(item: $selectedHashtag) { tag in 321 + HashtagFeedView(client: client, tag: tag) 322 + } 323 + .fullScreenCover(isPresented: $showStoryViewer) { 324 + if let profile = viewModel.profile { 301 325 StoryViewer( 302 - authors: [author], 326 + authors: [GrainStoryAuthor( 327 + profile: GrainProfile(cid: "", did: did, handle: profile.handle, displayName: profile.displayName, avatar: profile.avatar), 328 + storyCount: viewModel.stories.count, 329 + latestAt: viewModel.stories.last?.createdAt ?? "" 330 + )], 303 331 client: client, 304 332 onProfileTap: { did in 305 - cardStoryAuthor = nil 333 + showStoryViewer = false 306 334 selectedProfileDid = did 307 335 }, 308 - onDismiss: { cardStoryAuthor = nil } 336 + onDismiss: { showStoryViewer = false } 309 337 ) 310 338 .environment(auth) 311 339 } 312 - .fullScreenCover(isPresented: $showAvatarOverlay) { 313 - if let avatar = viewModel.profile?.avatar { 314 - AvatarOverlay(url: avatar) { 315 - showAvatarOverlay = false 316 - } 340 + } 341 + .fullScreenCover(item: $cardStoryAuthor) { author in 342 + StoryViewer( 343 + authors: [author], 344 + client: client, 345 + onProfileTap: { did in 346 + cardStoryAuthor = nil 347 + selectedProfileDid = did 348 + }, 349 + onDismiss: { cardStoryAuthor = nil } 350 + ) 351 + .environment(auth) 352 + } 353 + .fullScreenCover(isPresented: $showStoryCreate) { 354 + StoryCreateView(client: client, onCreated: { 355 + Task { await viewModel.load(did: did) } 356 + }) 357 + .environment(auth) 358 + } 359 + .fullScreenCover(isPresented: $showAvatarOverlay) { 360 + if let avatar = viewModel.profile?.avatar { 361 + AvatarOverlay(url: avatar) { 362 + showAvatarOverlay = false 317 363 } 318 364 } 319 - .background(Color(.systemBackground)) 320 - .refreshable { 321 - await viewModel.load(did: actor, viewer: auth.userDID, auth: await auth.authContext()) 322 - } 323 - .task { 324 - await viewModel.load(did: actor, viewer: auth.userDID, auth: await auth.authContext()) 365 + } 366 + .background(Color(.systemBackground)) 367 + .refreshable { 368 + await viewModel.load(did: actor, viewer: auth.userDID, auth: auth.authContext()) 369 + } 370 + .task { 371 + guard !isPreview else { 372 + #if DEBUG 373 + viewModel.profile = PreviewData.profile 374 + viewModel.galleries = PreviewData.galleries 375 + #endif 376 + return 325 377 } 326 - .onChange(of: deletedGalleryUri) { _, uri in 327 - if let uri { 328 - viewModel.galleries.removeAll { $0.uri == uri } 329 - deletedGalleryUri = nil 330 - } 378 + await viewModel.load(did: actor, viewer: auth.userDID, auth: auth.authContext()) 379 + } 380 + .onChange(of: deletedGalleryUri) { _, uri in 381 + if let uri { 382 + viewModel.galleries.removeAll { $0.uri == uri } 383 + deletedGalleryUri = nil 331 384 } 385 + } 332 386 } 333 387 334 388 @ViewBuilder ··· 355 409 356 410 // "Followed by X, Y and Z others" text 357 411 Group { 358 - if names.count == 1 && othersCount == 0 { 412 + if names.count == 1, othersCount == 0 { 359 413 Text("Followed by **\(names[0])**") 360 - } else if names.count == 2 && othersCount == 0 { 414 + } else if names.count == 2, othersCount == 0 { 361 415 Text("Followed by **\(names[0])** and **\(names[1])**") 362 - } else if names.count == 1 && othersCount > 0 { 416 + } else if names.count == 1, othersCount > 0 { 363 417 Text("Followed by **\(names[0])** and \(othersCount) \(othersCount == 1 ? "other" : "others") you follow") 364 - } else if names.count >= 2 && othersCount > 0 { 418 + } else if names.count >= 2, othersCount > 0 { 365 419 Text("Followed by **\(names[0])**, **\(names[1])** and \(othersCount) \(othersCount == 1 ? "other" : "others") you follow") 366 420 } else { 367 421 Text("Followed by \(displayCount) you follow") ··· 379 433 private func followButton(profile: GrainProfileDetailed) -> some View { 380 434 if profile.viewer?.following != nil { 381 435 Button { 382 - Task { await viewModel.toggleFollow(auth: await auth.authContext()) } 436 + Task { await viewModel.toggleFollow(auth: auth.authContext()) } 383 437 } label: { 384 438 Text("Following") 385 439 .font(.subheadline.weight(.semibold)) ··· 389 443 .tint(.primary) 390 444 } else { 391 445 Button { 392 - Task { await viewModel.toggleFollow(auth: await auth.authContext()) } 446 + Task { await viewModel.toggleFollow(auth: auth.authContext()) } 393 447 } label: { 394 448 Text("Follow") 395 449 .font(.subheadline.weight(.semibold)) ··· 457 511 } 458 512 } 459 513 } 514 + 515 + #Preview { 516 + ProfileView(client: XRPCClient(baseURL: AuthManager.serverURL), did: "did:plc:preview") 517 + .environment(AuthManager()) 518 + .environment(ViewedStoryStorage()) 519 + .environment(LabelDefinitionsCache()) 520 + }
+13 -3
Grain/Views/Search/SearchView.swift
··· 25 25 NavigationStack { 26 26 Group { 27 27 if viewModel.searchText.isEmpty { 28 - if recentSearches.profiles.isEmpty && recentSearches.textSearches.isEmpty { 28 + if recentSearches.profiles.isEmpty, recentSearches.textSearches.isEmpty { 29 29 ContentUnavailableView("Search", systemImage: "magnifyingglass", description: Text("Search for galleries and profiles")) 30 30 } else { 31 31 recentSearchesView ··· 55 55 selectedProfileDid = profile.did 56 56 } label: { 57 57 HStack { 58 - StoryRingView(hasStory: storyStatusCache.hasStory(for: profile.did), viewed: viewedStories.hasViewedAll(did: profile.did, storyStatusCache: storyStatusCache), size: 40) { 58 + StoryRingView( 59 + hasStory: storyStatusCache.hasStory(for: profile.did), 60 + viewed: profile.did != auth.userDID && viewedStories.hasViewedAll(did: profile.did, storyStatusCache: storyStatusCache), 61 + size: 40 62 + ) { 59 63 AvatarView(url: profile.avatar, size: 40) 60 64 } 61 65 VStack(alignment: .leading) { ··· 127 131 } 128 132 } 129 133 130 - @ViewBuilder 131 134 private var recentSearchesView: some View { 132 135 ScrollView { 133 136 VStack(alignment: .leading, spacing: 16) { ··· 198 201 } 199 202 } 200 203 } 204 + 205 + #Preview { 206 + SearchView(client: XRPCClient(baseURL: AuthManager.serverURL)) 207 + .environment(AuthManager()) 208 + .environment(StoryStatusCache()) 209 + .environment(ViewedStoryStorage()) 210 + }
+14 -6
Grain/Views/Settings/EditProfileView.swift
··· 1 + import NukeUI 1 2 import PhotosUI 2 3 import SwiftUI 3 - import NukeUI 4 4 5 5 struct EditProfileView: View { 6 6 @Environment(AuthManager.self) private var auth ··· 136 136 Task { await loadSelectedPhoto() } 137 137 } 138 138 .task { 139 + guard !isPreview else { return } 139 140 await loadProfile() 140 141 } 141 142 .overlay { ··· 156 157 // Fetch raw record to get avatar blob ref for preservation on save 157 158 let record = try await client.getRecord(uri: "at://\(did)/social.grain.actor.profile/self", auth: authContext) 158 159 if let value = record.record?.dictValue?["value"], 159 - let avatar = value.dictValue?["avatar"] { 160 + let avatar = value.dictValue?["avatar"] 161 + { 160 162 existingAvatarBlob = avatar.dictValue 161 163 } 162 164 } catch { ··· 169 171 guard let selectedPhoto else { return } 170 172 do { 171 173 if let data = try await selectedPhoto.loadTransferable(type: Data.self), 172 - let image = UIImage(data: data) { 174 + let image = UIImage(data: data) 175 + { 173 176 let resized = resizeImage(image, maxSize: 1000, maxBytes: 900_000) 174 177 newAvatarImage = UIImage(data: resized) 175 178 newAvatarData = resized ··· 200 203 } 201 204 202 205 var record: [String: AnyCodable] = [ 203 - "createdAt": AnyCodable(DateFormatting.nowISO()) 206 + "createdAt": AnyCodable(DateFormatting.nowISO()), 204 207 ] 205 208 206 209 let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) ··· 235 238 236 239 private func blobRefToAnyCodable(_ blob: BlobRef) -> AnyCodable { 237 240 var dict: [String: AnyCodable] = [ 238 - "$type": AnyCodable("blob") 241 + "$type": AnyCodable("blob"), 239 242 ] 240 243 if let mimeType = blob.mimeType { 241 244 dict["mimeType"] = AnyCodable(mimeType) ··· 266 269 267 270 if result.count <= maxBytes { return result } 268 271 269 - for _ in 0..<8 { 272 + for _ in 0 ..< 8 { 270 273 let mid = (low + high) / 2 271 274 let data = rendered.jpegData(compressionQuality: mid) ?? Data() 272 275 if data.count <= maxBytes { ··· 279 282 return result 280 283 } 281 284 } 285 + 286 + #Preview { 287 + EditProfileView(client: XRPCClient(baseURL: AuthManager.serverURL)) 288 + .environment(AuthManager()) 289 + }
+50 -8
Grain/Views/Settings/SettingsView.swift
··· 1 + import Nuke 1 2 import SwiftUI 2 3 3 4 struct SettingsView: View { 4 5 @Environment(AuthManager.self) private var auth 5 6 @Environment(\.dismiss) private var dismiss 6 7 let client: XRPCClient 7 - var onProfileEdited: (() -> Void)? 8 + @State private var cacheSizeText = "Calculating..." 8 9 @State private var includeExif = true 9 10 @State private var hasLoadedExifPref = false 11 + @AppStorage("privacy.includeLocation") private var includeLocation = true 12 + @AppStorage("privacy.includeCameraData") private var includeCameraData = true 13 + @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 10 14 11 15 var body: some View { 12 16 List { ··· 20 24 } 21 25 } 22 26 23 - Section { 24 - NavigationLink("Edit Profile") { 25 - EditProfileView(client: client, onSaved: onProfileEdited) 26 - } 27 - } 28 - 29 27 Section("Photos") { 30 28 Toggle("Include camera data (EXIF) when uploading", isOn: $includeExif) 31 29 .onChange(of: includeExif) { ··· 37 35 } 38 36 } 39 37 38 + Section { 39 + Toggle("Include location", isOn: $includeLocation) 40 + Toggle("Include camera data", isOn: $includeCameraData) 41 + Toggle("Show suggested users", isOn: $showSuggestedUsers) 42 + } header: { 43 + Text("Privacy") 44 + } footer: { 45 + Text("Camera data includes make, model, and exposure info. Location is auto-detected from photo metadata when available.") 46 + } 47 + 48 + Section("Storage") { 49 + LabeledContent("Image Cache", value: cacheSizeText) 50 + Button("Clear Image Cache", role: .destructive) { 51 + clearImageCache() 52 + } 53 + } 54 + .task { 55 + guard !isPreview else { return } 56 + updateCacheSize() 57 + } 58 + 40 59 Section("Legal") { 41 60 Link("Privacy Policy", destination: URL(string: "https://grain.social/support/privacy")!) 42 61 Link("Terms of Service", destination: URL(string: "https://grain.social/support/terms")!) ··· 58 77 .task { 59 78 if let authContext = await auth.authContext(), 60 79 let prefs = try? await client.getPreferences(auth: authContext).preferences, 61 - let exif = prefs.includeExif { 80 + let exif = prefs.includeExif 81 + { 62 82 includeExif = exif 63 83 } 64 84 hasLoadedExifPref = true 65 85 } 66 86 } 87 + 88 + private func updateCacheSize() { 89 + guard let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache else { 90 + cacheSizeText = "Unknown" 91 + return 92 + } 93 + let size = dataCache.totalSize 94 + cacheSizeText = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) 95 + } 96 + 97 + private func clearImageCache() { 98 + ImagePipeline.shared.cache.removeAll() 99 + if let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache { 100 + dataCache.removeAll() 101 + } 102 + cacheSizeText = "Zero KB" 103 + } 104 + } 105 + 106 + #Preview { 107 + SettingsView(client: XRPCClient(baseURL: AuthManager.serverURL)) 108 + .environment(AuthManager()) 67 109 }
+49 -8
Grain/Views/Stories/DragToDismiss.swift
··· 17 17 view.transform = CGAffineTransform(scaleX: 0.92, y: 0.92) 18 18 }) { _ in 19 19 if let vc = view.findViewController(), 20 - let presented = vc.presentedViewController ?? (vc.isBeingPresented ? vc : nil) { 20 + let presented = vc.presentedViewController ?? (vc.isBeingPresented ? vc : nil) 21 + { 21 22 presented.dismiss(animated: false) { 22 23 view.alpha = 1 23 24 view.transform = .identity ··· 53 54 return view 54 55 } 55 56 56 - func updateUIView(_ uiView: UIView, context: Context) { 57 + func updateUIView(_: UIView, context: Context) { 57 58 context.coordinator.onDismiss = onDismiss 58 59 context.coordinator.onDragStart = onDragStart 59 60 context.coordinator.onDragCancel = onDragCancel ··· 90 91 private var panGesture: UIPanGestureRecognizer? 91 92 private var direction: DragDirection = .none 92 93 93 - init(handle: FadeDismissHandle, onDismiss: @escaping () -> Void, onDragStart: @escaping () -> Void, onDragCancel: @escaping () -> Void, onSwipeLeft: @escaping () -> Void, onSwipeRight: @escaping () -> Void) { 94 + init( 95 + handle: FadeDismissHandle, 96 + onDismiss: @escaping () -> Void, 97 + onDragStart: @escaping () -> Void, 98 + onDragCancel: @escaping () -> Void, 99 + onSwipeLeft: @escaping () -> Void, 100 + onSwipeRight: @escaping () -> Void 101 + ) { 94 102 self.handle = handle 95 103 self.onDismiss = onDismiss 96 104 self.onDragStart = onDragStart ··· 112 120 panGesture = pan 113 121 } 114 122 115 - // Allow SwiftUI tap gestures to work simultaneously 116 - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { 117 - return !(other is UIPanGestureRecognizer) 123 + /// Allow SwiftUI tap gestures to work simultaneously 124 + func gestureRecognizer(_: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { 125 + !(other is UIPanGestureRecognizer) 118 126 } 119 127 120 128 @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { ··· 132 140 let absX = abs(translation.x) 133 141 let absY = abs(translation.y) 134 142 if absX > 15 || absY > 15 { 135 - if absY > absX && translation.y > 0 { 143 + if absY > absX, translation.y > 0 { 136 144 direction = .vertical 137 145 onDragStart() 138 146 } else if absX > absY { ··· 157 165 158 166 let dismissThreshold = view.bounds.height * 0.35 159 167 if ty > dismissThreshold || velocity.y > 1200 { 160 - handle.fadeDismiss() 168 + // Throw downward off screen 169 + let screenHeight = view.bounds.height 170 + let speed = max(velocity.y, 800) 171 + let remaining = screenHeight - ty + 100 172 + let duration = min(max(Double(remaining / speed), 0.15), 0.3) 173 + UIView.animate(withDuration: duration, delay: 0, options: .curveEaseIn) { 174 + view.transform = CGAffineTransform(translationX: 0, y: screenHeight + 100) 175 + .scaledBy(x: 0.9, y: 0.9) 176 + } completion: { _ in 177 + if let vc = view.findViewController(), 178 + let presented = vc.presentedViewController ?? (vc.isBeingPresented ? vc : nil) 179 + { 180 + presented.dismiss(animated: false) { 181 + view.alpha = 1 182 + view.transform = .identity 183 + view.layer.cornerRadius = 0 184 + view.clipsToBounds = false 185 + self.onDismiss() 186 + } 187 + } else { 188 + view.isHidden = true 189 + self.onDismiss() 190 + } 191 + } 161 192 } else { 162 193 // Spring back 163 194 UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: [.allowUserInteraction]) { ··· 196 227 return nil 197 228 } 198 229 } 230 + 231 + #Preview { 232 + // DragToDismiss is a UIKit-backed gesture modifier — preview shows it applied. 233 + @Previewable @State var dismissed = false 234 + ZStack { 235 + Color.black.ignoresSafeArea() 236 + Text(dismissed ? "Dismissed" : "Drag down to dismiss") 237 + .foregroundStyle(.white) 238 + } 239 + }
+39 -8
Grain/Views/Stories/StoryCreateView.swift
··· 17 17 @State private var locationSuggestions: [NominatimResult] = [] 18 18 @State private var isSearchingLocation = false 19 19 @State private var locationSearchTask: Task<Void, Never>? 20 + @State private var photoLocationResult: NominatimResult? 21 + @AppStorage("privacy.includeLocation") private var includeLocation = true 20 22 @State private var isUploading = false 21 23 @State private var errorMessage: String? 22 24 @State private var postToBluesky = false ··· 61 63 } 62 64 } 63 65 } else { 66 + if let photoLoc = photoLocationResult { 67 + Button { selectLocation(photoLoc) } label: { 68 + HStack(spacing: 10) { 69 + Image(systemName: "location.fill") 70 + .foregroundStyle(.secondary) 71 + .frame(width: 20) 72 + VStack(alignment: .leading, spacing: 1) { 73 + Text("Use photo location") 74 + .font(.subheadline) 75 + Text(photoLoc.name) 76 + .font(.caption) 77 + .foregroundStyle(.secondary) 78 + } 79 + } 80 + } 81 + .foregroundStyle(.primary) 82 + } 83 + 64 84 HStack { 65 85 Image(systemName: "magnifyingglass") 66 86 .foregroundStyle(.secondary) ··· 156 176 resolvedLocation = nil 157 177 locationQuery = "" 158 178 locationSuggestions = [] 179 + photoLocationResult = nil 159 180 } 160 181 161 182 // MARK: - Photo Loading ··· 163 184 private func loadPhoto() async { 164 185 guard let item = selectedPhoto, 165 186 let data = try? await item.loadTransferable(type: Data.self), 166 - let image = UIImage(data: data) else { 187 + let image = UIImage(data: data) 188 + else { 167 189 photoData = nil 168 190 previewImage = nil 169 191 return ··· 174 196 resolvedLocation = nil 175 197 locationQuery = "" 176 198 locationSuggestions = [] 177 - if let gps = ImageProcessing.extractGPS(from: data) { 178 - if let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) { 199 + photoLocationResult = nil 200 + if let gps = ImageProcessing.extractGPS(from: data), 201 + let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude) 202 + { 203 + photoLocationResult = result 204 + if includeLocation { 179 205 selectLocation(result) 180 206 } 181 207 } ··· 199 225 "$type": AnyCodable(response.blob.type ?? "blob"), 200 226 "ref": AnyCodable(["$link": AnyCodable(response.blob.ref?.link ?? "")] as [String: AnyCodable]), 201 227 "mimeType": AnyCodable(response.blob.mimeType ?? "image/jpeg"), 202 - "size": AnyCodable(response.blob.size ?? 0) 228 + "size": AnyCodable(response.blob.size ?? 0), 203 229 ] 204 230 205 231 var record: [String: AnyCodable] = [ 206 232 "media": AnyCodable(blobDict), 207 233 "aspectRatio": AnyCodable([ 208 234 "width": AnyCodable(Int(size.width)), 209 - "height": AnyCodable(Int(size.height)) 235 + "height": AnyCodable(Int(size.height)), 210 236 ] as [String: AnyCodable]), 211 - "createdAt": AnyCodable(DateFormatting.nowISO()) 237 + "createdAt": AnyCodable(DateFormatting.nowISO()), 212 238 ] 213 239 214 240 if let loc = resolvedLocation { 215 241 record["location"] = AnyCodable([ 216 242 "value": AnyCodable(loc.h3), 217 - "name": AnyCodable(loc.name) 243 + "name": AnyCodable(loc.name), 218 244 ] as [String: AnyCodable]) 219 245 if let addr = loc.address { 220 246 record["address"] = AnyCodable(addr) ··· 224 250 let labelValues = selectedLabels.map { ["val": AnyCodable($0)] as [String: AnyCodable] } 225 251 record["labels"] = AnyCodable([ 226 252 "$type": AnyCodable("com.atproto.label.defs#selfLabels"), 227 - "values": AnyCodable(labelValues as [[String: AnyCodable]]) 253 + "values": AnyCodable(labelValues as [[String: AnyCodable]]), 228 254 ] as [String: AnyCodable]) 229 255 } 230 256 ··· 283 309 locationSuggestions = [] 284 310 } 285 311 } 312 + 313 + #Preview { 314 + StoryCreateView(client: XRPCClient(baseURL: AuthManager.serverURL)) 315 + .environment(AuthManager()) 316 + }
+25 -5
Grain/Views/Stories/StoryRingView.swift
··· 6 6 let size: CGFloat 7 7 @ViewBuilder let content: () -> Content 8 8 9 - private var lineWidth: CGFloat { size <= 28 ? 1.5 : size <= 40 ? 2.5 : 3.5 } 10 - private var ringSize: CGFloat { size + (size <= 28 ? 4 : size <= 40 ? 6 : 8) } 9 + private var lineWidth: CGFloat { 10 + size <= 28 ? 1.5 : size <= 40 ? 2.5 : 3.5 11 + } 12 + 13 + private var ringSize: CGFloat { 14 + size + (size <= 28 ? 4 : size <= 40 ? 6 : 8) 15 + } 11 16 12 17 var body: some View { 13 18 content() ··· 22 27 .strokeBorder( 23 28 LinearGradient( 24 29 colors: [ 25 - Color(red: 0xc9/255, green: 0x7c/255, blue: 0xf8/255), 26 - Color(red: 0x85/255, green: 0xa1/255, blue: 0xff/255), 27 - Color(red: 0x5b/255, green: 0xf0/255, blue: 0xd6/255) 30 + Color(red: 0xC9 / 255, green: 0x7C / 255, blue: 0xF8 / 255), 31 + Color(red: 0x85 / 255, green: 0xA1 / 255, blue: 0xFF / 255), 32 + Color(red: 0x5B / 255, green: 0xF0 / 255, blue: 0xD6 / 255), 28 33 ], 29 34 startPoint: .topLeading, 30 35 endPoint: .bottomTrailing ··· 37 42 } 38 43 } 39 44 } 45 + 46 + #Preview { 47 + HStack(spacing: 24) { 48 + StoryRingView(hasStory: false, size: 48) { 49 + AvatarView(url: nil, size: 48) 50 + } 51 + StoryRingView(hasStory: true, viewed: false, size: 48) { 52 + AvatarView(url: nil, size: 48) 53 + } 54 + StoryRingView(hasStory: true, viewed: true, size: 48) { 55 + AvatarView(url: nil, size: 48) 56 + } 57 + } 58 + .padding() 59 + }
+83 -9
Grain/Views/Stories/StoryStripView.swift
··· 3 3 struct StoryStripView: View { 4 4 @Environment(ViewedStoryStorage.self) private var viewedStories 5 5 let authors: [GrainStoryAuthor] 6 + let userDid: String? 6 7 let userAvatar: String? 8 + var sortVersion: Int = 0 7 9 let onAuthorTap: (GrainStoryAuthor, Int) -> Void 8 10 var onAuthorLongPress: ((String) -> Void)? 9 11 let onCreateTap: () -> Void 10 12 11 13 private let avatarSize: CGFloat = 68 12 14 15 + @State private var sorted: [GrainStoryAuthor] = [] 16 + @State private var liftedDids: Set<String> = [] 17 + 18 + private var ownAuthor: GrainStoryAuthor? { 19 + authors.first(where: { $0.profile.did == userDid }) 20 + } 21 + 22 + private func computeSorted() -> [GrainStoryAuthor] { 23 + let others = authors.filter { $0.profile.did != userDid } 24 + let unviewed = others.filter { !viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) } 25 + let viewed = others.filter { viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) } 26 + return unviewed + viewed 27 + } 28 + 13 29 var body: some View { 14 - let unviewed = authors.filter { !viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) } 15 - let viewed = authors.filter { viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) } 16 - let sorted = unviewed + viewed 17 - let orderKey = sorted.map(\.id).joined(separator: ",") 18 - 19 30 ScrollView(.horizontal, showsIndicators: false) { 20 31 HStack(spacing: 16) { 21 - // Create button 32 + // Your story 22 33 VStack(spacing: 4) { 23 34 ZStack(alignment: .bottomTrailing) { 24 - AvatarView(url: userAvatar, size: avatarSize) 35 + StoryRingView(hasStory: ownAuthor != nil, viewed: false, size: avatarSize) { 36 + AvatarView(url: userAvatar, size: avatarSize) 37 + } 25 38 Image(systemName: "plus.circle.fill") 26 39 .font(.system(size: 18)) 27 40 .foregroundStyle(.white, Color("AccentColor")) ··· 32 45 .foregroundStyle(.secondary) 33 46 .lineLimit(1) 34 47 } 35 - .onTapGesture { onCreateTap() } 48 + .onTapGesture { 49 + if let own = ownAuthor { 50 + onAuthorTap(own, 0) 51 + } else { 52 + onCreateTap() 53 + } 54 + } 55 + .onLongPressGesture { onCreateTap() } 36 56 37 57 ForEach(sorted, id: \.id) { author in 38 58 let isViewed = viewedStories.hasViewedAll(authorDid: author.profile.did, latestAt: author.latestAt) 59 + let isLifted = liftedDids.contains(author.profile.did) 39 60 VStack(spacing: 4) { 40 61 StoryRingView(hasStory: true, viewed: isViewed, size: avatarSize) { 41 62 AvatarView(url: author.profile.avatar, size: avatarSize) ··· 46 67 .lineLimit(1) 47 68 .frame(width: avatarSize + 8) 48 69 } 70 + .scaleEffect(isLifted ? 1.03 : 1.0) 71 + .offset(y: isLifted ? -3 : 0) 72 + .zIndex(isViewed ? 0 : 1) 49 73 .onTapGesture { onAuthorTap(author, 0) } 50 74 .onLongPressGesture { onAuthorLongPress?(author.profile.did) } 51 75 } 52 76 } 53 - .id(orderKey) 54 77 .padding(.horizontal) 55 78 .padding(.vertical, 8) 56 79 } 80 + .scrollClipDisabled() 81 + .onAppear { sorted = computeSorted() } 82 + .onChange(of: authors.map(\.id)) { sorted = computeSorted() } 83 + .onChange(of: sortVersion) { 84 + let newSorted = computeSorted() 85 + 86 + // Find where old and new order diverge — that's the gap 87 + let oldIds = sorted.map(\.profile.did) 88 + let newIds = newSorted.map(\.profile.did) 89 + 90 + withAnimation(.smooth(duration: 0.5)) { 91 + sorted = newSorted 92 + } 93 + 94 + // Skip wave if order didn't change 95 + guard oldIds != newIds, 96 + let gapIndex = zip(oldIds, newIds).enumerated().first(where: { $1.0 != $1.1 })?.offset 97 + else { return } 98 + 99 + // Wave follows the read card as it slides right past unreads 100 + let unreadCount = newSorted.count(where: { !viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) }) 101 + let travelDistance = max(unreadCount - gapIndex, 1) 102 + for (index, author) in newSorted.enumerated() { 103 + guard !viewedStories.hasViewedAll(authorDid: author.profile.did, latestAt: author.latestAt) else { continue } 104 + guard index >= gapIndex else { continue } 105 + let did = author.profile.did 106 + let progress = Double(index - gapIndex) / Double(travelDistance) 107 + let delay = progress * 0.4 108 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 109 + withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) { 110 + _ = liftedDids.insert(did) 111 + } 112 + } 113 + DispatchQueue.main.asyncAfter(deadline: .now() + delay + 0.18) { 114 + withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { 115 + _ = liftedDids.remove(did) 116 + } 117 + } 118 + } 119 + } 57 120 } 58 121 } 122 + 123 + #Preview { 124 + StoryStripView( 125 + authors: PreviewData.storyAuthors, 126 + userDid: "did:plc:prevuser1", 127 + userAvatar: nil, 128 + onAuthorTap: { _, _ in }, 129 + onCreateTap: {} 130 + ) 131 + .environment(ViewedStoryStorage()) 132 + }
+305 -141
Grain/Views/Stories/StoryViewer.swift
··· 1 + import Nuke 2 + import NukeUI 1 3 import SwiftUI 2 - import NukeUI 3 4 4 5 @Observable 5 6 @MainActor ··· 13 14 stop() 14 15 progress = 0 15 16 isRunning = true 16 - halfwayFired = false 17 + quarterFired = false 17 18 task = Task { 18 19 let tickInterval: TimeInterval = 0.05 19 20 let totalTicks = Int(duration / tickInterval) 20 - for tick in 0...totalTicks { 21 + for tick in 0 ... totalTicks { 21 22 do { 22 23 try await Task.sleep(for: .milliseconds(Int(tickInterval * 1000))) 23 24 } catch { return } 24 25 guard !Task.isCancelled else { return } 25 26 progress = CGFloat(tick) / CGFloat(totalTicks) 26 - if !halfwayFired && progress >= 0.5 { 27 - halfwayFired = true 28 - onHalfway?() 27 + if !quarterFired, progress >= 0.25 { 28 + quarterFired = true 29 + onQuarter?() 29 30 } 30 31 } 31 32 guard !Task.isCancelled else { return } ··· 41 42 } 42 43 43 44 var onComplete: (() -> Void)? 44 - var onHalfway: (() -> Void)? 45 - private var halfwayFired = false 45 + var onQuarter: (() -> Void)? 46 + private var quarterFired = false 46 47 } 47 48 48 49 struct StoryViewer: View { ··· 62 63 @State private var showReportSheet = false 63 64 @State private var reportStoryUri = "" 64 65 @State private var reportStoryCid = "" 66 + @State private var showLocationCopied = false 65 67 @State private var lastNavTime: Date = .distantPast 66 68 @State private var labelRevealed = false 69 + @State private var imageLoaded = false 67 70 @State private var fadeDismissHandle = FadeDismissHandle() 68 71 @State private var prefetchedStories: [String: [GrainStory]] = [:] 72 + @State private var unreadOnly = false 73 + @State private var authorTransition: CGFloat = 1.0 74 + @State private var slideOffset: CGFloat = 0 75 + @State private var authorHistory: [(authorIndex: Int, storyIndex: Int)] = [] 76 + @State private var imagePrefetcher = ImagePrefetcher() 77 + @State private var nextStoryFromTrailing = true 69 78 70 - init(authors: [GrainStoryAuthor], startIndex: Int = 0, startAuthorDid: String? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 79 + init(authors: [GrainStoryAuthor], startAuthorDid: String? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 71 80 self.authors = authors 72 81 self.client = client 73 82 self.onProfileTap = onProfileTap 74 83 self.onDismiss = onDismiss 75 - let resolvedIndex: Int 76 - if let did = startAuthorDid { 77 - resolvedIndex = authors.firstIndex(where: { $0.profile.did == did }) ?? 0 78 - } else { 79 - resolvedIndex = startIndex 80 - } 84 + let resolvedIndex = startAuthorDid.flatMap { did in authors.firstIndex { $0.profile.did == did } } ?? 0 81 85 _currentAuthorIndex = State(initialValue: resolvedIndex) 82 86 } 83 87 ··· 92 96 93 97 var body: some View { 94 98 storyContent 95 - .background( 96 - DragToDismissInstaller( 97 - handle: fadeDismissHandle, 98 - onDismiss: { onDismiss?() }, 99 - onDragStart: { timer.stop() }, 100 - onDragCancel: { timer.start() }, 101 - onSwipeLeft: { goToNextAuthor() }, 102 - onSwipeRight: { goToPreviousAuthor() } 99 + .offset(x: slideOffset) 100 + .scaleEffect(0.85 + 0.15 * authorTransition) 101 + .opacity(0.3 + 0.7 * Double(authorTransition)) 102 + .background( 103 + DragToDismissInstaller( 104 + handle: fadeDismissHandle, 105 + onDismiss: { onDismiss?() }, 106 + onDragStart: { timer.stop() }, 107 + onDragCancel: { startTimerIfSafe() }, 108 + onSwipeLeft: { goToNextAuthor() }, 109 + onSwipeRight: { goToPreviousAuthor() } 110 + ) 103 111 ) 104 - ) 105 - .statusBarHidden() 106 - .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 107 - Button("Delete", role: .destructive) { 108 - if let story = currentStory { 109 - Task { await deleteStory(story) } 112 + .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 113 + Button("Delete", role: .destructive) { 114 + if let story = currentStory { 115 + Task { await deleteStory(story) } 116 + } 117 + } 118 + Button("Cancel", role: .cancel) { 119 + timer.start() 110 120 } 111 121 } 112 - Button("Cancel", role: .cancel) { 113 - timer.start() 122 + .fullScreenCover(isPresented: $showReportSheet) { 123 + ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 124 + .environment(auth) 114 125 } 115 - } 116 - .fullScreenCover(isPresented: $showReportSheet) { 117 - ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 118 - .environment(auth) 119 - } 120 - .onChange(of: showReportSheet) { 121 - if !showReportSheet { 122 - timer.start() 126 + .onChange(of: showReportSheet) { 127 + if !showReportSheet { 128 + timer.start() 129 + } 123 130 } 124 - } 125 - .task { 126 - timer.onComplete = { [self] in goToNext() } 127 - timer.onHalfway = { [self] in markCurrentStoryViewed() } 128 - await loadStoriesForCurrentAuthor() 129 - } 131 + .task { 132 + guard !isPreview else { return } 133 + let startAuthor = authors[currentAuthorIndex] 134 + let isOwn = startAuthor.profile.did == auth.userDID 135 + let hasUnreads = !viewedStories.hasViewedAll(authorDid: startAuthor.profile.did, latestAt: startAuthor.latestAt) 136 + unreadOnly = isOwn || hasUnreads 137 + timer.onComplete = { [self] in goToNext() } 138 + timer.onQuarter = { [self] in markCurrentStoryViewed() } 139 + await loadStoriesForCurrentAuthor() 140 + } 130 141 } 131 142 132 - @ViewBuilder 133 143 private var storyContent: some View { 134 144 ZStack { 135 145 Color.black.ignoresSafeArea() ··· 139 149 140 150 // Story image 141 151 ZStack { 142 - LazyImage(url: URL(string: story.fullsize)) { state in 152 + LazyImage(request: { 153 + guard lr.action != .hide || labelRevealed, 154 + let url = URL(string: story.fullsize) else { return ImageRequest(url: nil) } 155 + return ImageRequest(url: url, priority: .veryHigh) 156 + }()) { state in 143 157 if let image = state.image { 144 158 image 145 159 .resizable() 146 160 .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 147 161 .frame(maxWidth: .infinity) 148 - } else if state.isLoading { 149 - ProgressView() 150 - .tint(.white) 151 - } 152 - } 153 - .overlay { 154 - if (lr.action == .warnMedia || lr.action == .warnContent || lr.action == .hide) && !labelRevealed { 155 - Rectangle().fill(Color(.secondarySystemBackground)) 162 + .onAppear { 163 + if !imageLoaded { 164 + imageLoaded = true 165 + startTimerIfSafe() 166 + } 167 + } 168 + } else { 169 + ZStack { 170 + LazyImage(url: URL(string: story.thumb)) { thumbState in 171 + if let thumb = thumbState.image { 172 + thumb 173 + .resizable() 174 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 175 + .blur(radius: 20) 176 + .clipped() 177 + } 178 + } 179 + ProgressView() 180 + .tint(.white) 181 + } 156 182 } 157 183 } 184 + .blur(radius: (lr.action == .warnMedia || lr.action == .warnContent) && !labelRevealed ? 24 : 0) 158 185 159 - if (lr.action == .warnContent || lr.action == .warnMedia || lr.action == .hide) && !labelRevealed { 186 + if lr.action == .warnContent || lr.action == .warnMedia || lr.action == .hide, !labelRevealed { 160 187 MediaWarningOverlay(name: lr.name) { 161 188 withAnimation { labelRevealed = true } 162 - timer.start() 189 + startTimerIfSafe() 163 190 } 164 191 } 165 192 } 193 + .id(story.uri) 194 + .transition(.asymmetric( 195 + insertion: .move(edge: nextStoryFromTrailing ? .trailing : .leading).combined(with: .opacity), 196 + removal: .move(edge: nextStoryFromTrailing ? .leading : .trailing).combined(with: .opacity) 197 + )) 166 198 167 199 // Tap zones 168 200 VStack(spacing: 0) { ··· 182 214 } 183 215 } 184 216 } 185 - .allowsHitTesting(!showReportSheet && !showDeleteConfirm) 217 + .allowsHitTesting(!showReportSheet && !showDeleteConfirm && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 218 + } else { 219 + ProgressView() 220 + .tint(.white) 221 + } 186 222 187 - // Header overlay 188 - VStack(spacing: 0) { 189 - StoryProgressBars(timer: timer, stories: stories, currentStoryIndex: currentStoryIndex) 190 - .padding(.horizontal) 191 - .padding(.top, 8) 223 + // Header overlay — always visible regardless of loading state 224 + let author = authors[currentAuthorIndex].profile 225 + let story = currentStory 226 + VStack(spacing: 0) { 227 + StoryProgressBars(timer: timer, stories: stories, currentStoryIndex: currentStoryIndex, placeholderCount: authors[currentAuthorIndex].storyCount) 228 + .padding(.horizontal) 229 + .padding(.top, 8) 192 230 193 - HStack(alignment: .center) { 194 - Button { 231 + HStack(alignment: .center) { 232 + Button { 233 + if let story { 195 234 close() 196 235 onProfileTap?(story.creator.did) 197 - } label: { 198 - HStack(alignment: .center, spacing: 8) { 199 - AvatarView(url: story.creator.avatar, size: 32) 200 - VStack(alignment: .leading, spacing: 0) { 201 - Text(story.creator.displayName ?? story.creator.handle) 202 - .font(.subheadline.bold()) 203 - .foregroundStyle(.white) 236 + } 237 + } label: { 238 + HStack(alignment: .center, spacing: 8) { 239 + AvatarView(url: story?.creator.avatar ?? author.avatar, size: 32) 240 + VStack(alignment: .leading, spacing: 0) { 241 + Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 242 + .font(.subheadline.bold()) 243 + .foregroundStyle(.white) 244 + if let story { 204 245 Text(relativeTime(story.createdAt)) 205 246 .font(.caption2) 206 247 .foregroundStyle(.white.opacity(0.7)) 207 248 } 208 249 } 209 250 } 210 - Spacer() 251 + } 252 + Spacer() 211 253 254 + if let story { 212 255 if story.creator.did == auth.userDID { 213 256 Button { 214 257 timer.stop() ··· 230 273 .frame(width: 36, height: 36) 231 274 } 232 275 } 276 + } 233 277 234 - Button { close() } label: { 235 - Image(systemName: "xmark") 236 - .foregroundStyle(.white) 237 - .font(.body.weight(.semibold)) 238 - .frame(width: 36, height: 36) 239 - } 278 + Button { close() } label: { 279 + Image(systemName: "xmark") 280 + .foregroundStyle(.white) 281 + .font(.body.weight(.semibold)) 282 + .frame(width: 36, height: 36) 240 283 } 241 - .padding(.horizontal, 16) 242 - .padding(.vertical, 8) 284 + } 285 + .padding(.horizontal, 16) 286 + .padding(.vertical, 8) 243 287 244 - Spacer() 245 - .allowsHitTesting(false) 288 + Spacer() 289 + .allowsHitTesting(false) 246 290 247 - if let locationText = storyLocationText(story) { 248 - HStack { 249 - HStack(spacing: 4) { 250 - Image(systemName: "location.fill") 251 - Text(locationText) 291 + if let story, let locationText = storyLocationText(story) { 292 + HStack { 293 + HStack(spacing: 4) { 294 + Image(systemName: showLocationCopied ? "checkmark" : "location.fill") 295 + Text(showLocationCopied ? "Copied" : locationText) 296 + } 297 + .font(.caption) 298 + .foregroundStyle(.white) 299 + .padding(.horizontal, 12) 300 + .padding(.vertical, 6) 301 + .background(.ultraThinMaterial, in: Capsule()) 302 + .contentTransition(.symbolEffect(.replace)) 303 + .id(story.uri) 304 + .onTapGesture { 305 + UIPasteboard.general.string = locationText 306 + withAnimation(.easeInOut(duration: 0.15)) { showLocationCopied = true } 307 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { 308 + withAnimation(.easeInOut(duration: 0.15)) { showLocationCopied = false } 252 309 } 253 - .font(.caption) 254 - .foregroundStyle(.white) 255 - .padding(.horizontal, 12) 256 - .padding(.vertical, 6) 257 - .background(.ultraThinMaterial, in: Capsule()) 258 - Spacer() 259 310 } 260 - .padding(.horizontal) 261 - .padding(.bottom, 32) 311 + Spacer() 262 312 } 313 + .padding(.horizontal) 314 + .padding(.bottom, 32) 263 315 } 264 - } else if isLoadingStories { 265 - ProgressView() 266 - .tint(.white) 267 316 } 268 317 } 269 318 } ··· 275 324 fadeDismissHandle.fadeDismiss() 276 325 } 277 326 327 + private func canNavigate() -> Bool { 328 + !isLoadingStories && !stories.isEmpty 329 + && !showReportSheet && !showDeleteConfirm 330 + && Date().timeIntervalSince(lastNavTime) > 0.3 331 + } 332 + 333 + private func startTimerIfSafe() { 334 + guard imageLoaded else { return } 335 + let action = storyLabelResult.action 336 + if action == .none || action == .badge { timer.start() } 337 + } 338 + 278 339 private func goToNext() { 279 - guard !isLoadingStories, !stories.isEmpty else { return } 280 - guard !showReportSheet, !showDeleteConfirm else { return } 281 - guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 340 + guard canNavigate() else { return } 282 341 markCurrentStoryViewed() 283 342 timer.stop() 284 343 lastNavTime = Date() 285 344 if currentStoryIndex < stories.count - 1 { 286 - currentStoryIndex += 1 287 - labelRevealed = false 288 - timer.start() 345 + animateToStory(forward: true) { 346 + currentStoryIndex += 1 347 + imageLoaded = false 348 + labelRevealed = false 349 + showLocationCopied = false 350 + } 351 + prefetchStoryImages() 289 352 } else { 290 353 goToNextAuthor() 291 354 } 292 355 } 293 356 294 357 private func goToPrevious() { 295 - guard !isLoadingStories, !stories.isEmpty else { return } 296 - guard !showReportSheet, !showDeleteConfirm else { return } 297 - guard Date().timeIntervalSince(lastNavTime) > 0.3 else { return } 358 + guard canNavigate() else { return } 298 359 timer.stop() 299 360 lastNavTime = Date() 300 361 if currentStoryIndex > 0 { 301 - currentStoryIndex -= 1 302 - labelRevealed = false 303 - timer.start() 362 + animateToStory(forward: false) { 363 + currentStoryIndex -= 1 364 + imageLoaded = false 365 + labelRevealed = false 366 + showLocationCopied = false 367 + } 368 + prefetchStoryImages() 304 369 } else { 305 370 goToPreviousAuthor() 306 371 } 307 372 } 308 373 374 + private func animateToStory(forward: Bool, _ action: () -> Void) { 375 + nextStoryFromTrailing = forward 376 + withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 377 + action() 378 + } 379 + } 380 + 309 381 private func goToNextAuthor() { 310 - if currentAuthorIndex < authors.count - 1 { 311 - currentAuthorIndex += 1 312 - switchToCurrentAuthor() 382 + if let next = findAuthorIndex(from: currentAuthorIndex, forward: true) { 383 + authorHistory.append((authorIndex: currentAuthorIndex, storyIndex: currentStoryIndex)) 384 + transitionToAuthor(next, direction: 1) 313 385 } else { 314 386 close() 315 387 } 316 388 } 317 389 318 390 private func goToPreviousAuthor() { 319 - if currentAuthorIndex > 0 { 320 - currentAuthorIndex -= 1 321 - switchToCurrentAuthor() 391 + if let prev = authorHistory.popLast() { 392 + transitionToAuthor(prev.authorIndex, direction: -1, resumeIndex: prev.storyIndex) 393 + return 394 + } 395 + // No history (entered mid-strip in reads mode) — walk backward ignoring the reads filter 396 + var i = currentAuthorIndex - 1 397 + while i >= 0 { 398 + if authors[i].profile.did != auth.userDID { 399 + transitionToAuthor(i, direction: -1) 400 + return 401 + } 402 + i -= 1 403 + } 404 + } 405 + 406 + private func transitionToAuthor(_ index: Int, direction: CGFloat, resumeIndex: Int? = nil) { 407 + timer.stop() 408 + withAnimation(.easeIn(duration: 0.15)) { 409 + authorTransition = 0 410 + slideOffset = -80 * direction 411 + } 412 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { 413 + currentAuthorIndex = index 414 + switchToCurrentAuthor(resumeIndex: resumeIndex) 415 + slideOffset = 80 * direction 416 + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 417 + authorTransition = 1 418 + slideOffset = 0 419 + } 420 + } 421 + } 422 + 423 + private func findAuthorIndex(from index: Int, forward: Bool) -> Int? { 424 + let step = forward ? 1 : -1 425 + var i = index + step 426 + while i >= 0, i < authors.count { 427 + let author = authors[i] 428 + if author.profile.did == auth.userDID { i += step; continue } 429 + if !unreadOnly || authorHasUnreads(author) { return i } 430 + i += step 322 431 } 432 + return nil 323 433 } 324 434 325 - private func switchToCurrentAuthor() { 435 + private func authorHasUnreads(_ author: GrainStoryAuthor) -> Bool { 436 + !viewedStories.hasViewedAll(authorDid: author.profile.did, latestAt: author.latestAt) 437 + } 438 + 439 + // MARK: - Data 440 + 441 + private func switchToCurrentAuthor(resumeIndex: Int? = nil) { 326 442 timer.stop() 327 443 let did = authors[currentAuthorIndex].profile.did 328 444 if let cached = prefetchedStories.removeValue(forKey: did) { 329 - stories = cached 330 - currentStoryIndex = viewedStories.firstUnviewedIndex(in: cached) 331 - labelRevealed = false 332 - isLoadingStories = false 333 - timer.start() 334 - prefetchAdjacentAuthors() 445 + presentStories(cached, resumeIndex: resumeIndex) 335 446 } else { 336 447 currentStoryIndex = 0 337 448 stories = [] ··· 340 451 } 341 452 } 342 453 343 - // MARK: - Data 344 - 345 454 private func loadStoriesForCurrentAuthor() async { 346 455 guard currentAuthorIndex < authors.count else { return } 347 456 let did = authors[currentAuthorIndex].profile.did ··· 349 458 timer.stop() 350 459 351 460 do { 352 - let fetched: [GrainStory] 353 - if let cached = prefetchedStories.removeValue(forKey: did) { 354 - fetched = cached 461 + let fetched: [GrainStory] = if let cached = prefetchedStories.removeValue(forKey: did) { 462 + cached 355 463 } else { 356 - fetched = try await client.getStories(actor: did, auth: await auth.authContext()).stories 464 + try await client.getStories(actor: did, auth: auth.authContext()).stories 357 465 } 358 - stories = fetched 359 - currentStoryIndex = viewedStories.firstUnviewedIndex(in: fetched) 360 - labelRevealed = false 361 - timer.start() 466 + presentStories(fetched) 362 467 } catch { 363 468 stories = [] 469 + isLoadingStories = false 364 470 } 365 - isLoadingStories = false 471 + } 366 472 367 - // Prefetch next author's stories 473 + private func presentStories(_ fetched: [GrainStory], resumeIndex: Int? = nil) { 474 + imageLoaded = false 475 + showLocationCopied = false 476 + stories = fetched 477 + if let resume = resumeIndex { 478 + currentStoryIndex = min(resume, max(fetched.count - 1, 0)) 479 + } else { 480 + let isOwn = fetched.first?.creator.did == auth.userDID 481 + currentStoryIndex = (unreadOnly && isOwn) ? 0 : viewedStories.firstUnviewedIndex(in: fetched) 482 + } 483 + labelRevealed = false 484 + isLoadingStories = false 485 + startTimerIfSafe() 368 486 prefetchAdjacentAuthors() 487 + prefetchStoryImages() 369 488 } 370 489 371 490 private func prefetchAdjacentAuthors() { 372 - let nextIndex = currentAuthorIndex + 1 373 - guard nextIndex < authors.count else { return } 491 + guard let nextIndex = findAuthorIndex(from: currentAuthorIndex, forward: true) else { return } 374 492 let did = authors[nextIndex].profile.did 375 493 guard prefetchedStories[did] == nil else { return } 376 494 Task { 377 - if let response = try? await client.getStories(actor: did, auth: await auth.authContext()) { 495 + if let response = try? await client.getStories(actor: did, auth: auth.authContext()) { 378 496 prefetchedStories[did] = response.stories 379 497 } 380 498 } 381 499 } 382 500 501 + private func prefetchStoryImages() { 502 + let current = stories.map { (thumb: $0.thumb, fullsize: $0.fullsize) } 503 + 504 + // Resolve next authors' stories from prefetched data 505 + let nextDid = findAuthorIndex(from: currentAuthorIndex, forward: true).map { authors[$0].profile.did } 506 + let nextStories = nextDid.flatMap { prefetchedStories[$0] }?.map { (thumb: $0.thumb, fullsize: $0.fullsize) } 507 + 508 + let secondNextIdx = findAuthorIndex(from: currentAuthorIndex, forward: true) 509 + .flatMap { findAuthorIndex(from: $0, forward: true) } 510 + let secondNextDid = secondNextIdx.map { authors[$0].profile.did } 511 + let secondNextStories = secondNextDid.flatMap { prefetchedStories[$0] }?.map { (thumb: $0.thumb, fullsize: $0.fullsize) } 512 + 513 + let thirdNextIdx = secondNextIdx.flatMap { findAuthorIndex(from: $0, forward: true) } 514 + let thirdFirst = thirdNextIdx 515 + .flatMap { prefetchedStories[authors[$0].profile.did]?.first } 516 + .map { (thumb: $0.thumb, fullsize: $0.fullsize) } 517 + 518 + let fourthNextIdx = thirdNextIdx.flatMap { findAuthorIndex(from: $0, forward: true) } 519 + let fourthFirst = fourthNextIdx 520 + .flatMap { prefetchedStories[authors[$0].profile.did]?.first } 521 + .map { (thumb: $0.thumb, fullsize: $0.fullsize) } 522 + 523 + let plan = ImagePrefetchPlanning.storyPrefetchRequests( 524 + currentStories: current, 525 + currentStoryIndex: currentStoryIndex, 526 + nextAuthorStories: nextStories, 527 + secondNextAuthorStories: secondNextStories, 528 + thirdNextFirstStory: thirdFirst, 529 + fourthNextFirstStory: fourthFirst 530 + ) 531 + imagePrefetcher.startPrefetching(with: plan.all) 532 + } 533 + 383 534 private func markCurrentStoryViewed() { 384 535 guard let story = currentStory else { return } 385 536 viewedStories.markViewed(uri: story.uri, authorDid: story.creator.did, createdAt: story.createdAt) ··· 431 582 } 432 583 } 433 584 434 - // Extracted so progress ticks only redraw this view, not the entire StoryViewer 585 + /// Extracted so progress ticks only redraw this view, not the entire StoryViewer 435 586 private struct StoryProgressBars: View { 436 587 let timer: StoryTimer 437 588 let stories: [GrainStory] 438 589 let currentStoryIndex: Int 590 + var placeholderCount: Int = 0 591 + 592 + private var barCount: Int { 593 + stories.isEmpty ? placeholderCount : stories.count 594 + } 439 595 440 596 var body: some View { 441 597 HStack(spacing: 4) { 442 - ForEach(0..<stories.count, id: \.self) { index in 598 + ForEach(0 ..< barCount, id: \.self) { index in 443 599 GeometryReader { geo in 444 600 Capsule() 445 601 .fill(Color.white.opacity(0.3)) ··· 453 609 } 454 610 455 611 private func barWidth(for index: Int, totalWidth: CGFloat) -> CGFloat { 612 + guard !stories.isEmpty else { return 0 } 456 613 if index < currentStoryIndex { 457 614 return totalWidth 458 615 } else if index == currentStoryIndex { ··· 462 619 } 463 620 } 464 621 } 622 + 623 + #Preview { 624 + StoryViewer(authors: PreviewData.storyAuthors, client: XRPCClient(baseURL: AuthManager.serverURL)) 625 + .environment(AuthManager()) 626 + .environment(LabelDefinitionsCache()) 627 + .environment(ViewedStoryStorage()) 628 + }
+100
GrainTests/AnyCodableTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class AnyCodableTests: XCTestCase { 5 + private let encoder = JSONEncoder() 6 + private let decoder = JSONDecoder() 7 + 8 + // MARK: - Init variants 9 + 10 + func testInitWithString() throws { 11 + let value = AnyCodable("hello") 12 + let data = try encoder.encode(value) 13 + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) 14 + XCTAssertEqual(json, "\"hello\"") 15 + } 16 + 17 + func testInitWithInt() throws { 18 + let value = AnyCodable(42) 19 + let data = try encoder.encode(value) 20 + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) 21 + XCTAssertEqual(json, "42") 22 + } 23 + 24 + func testInitWithBool() throws { 25 + let value = AnyCodable(true) 26 + let data = try encoder.encode(value) 27 + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) 28 + XCTAssertEqual(json, "true") 29 + } 30 + 31 + func testInitWithDouble() throws { 32 + let value = AnyCodable(3.14) 33 + let data = try encoder.encode(value) 34 + let decoded = try decoder.decode(AnyCodable.self, from: data) 35 + // Round-trip through JSON, re-encode to verify 36 + let reEncoded = try XCTUnwrap(try String(data: encoder.encode(decoded), encoding: .utf8)) 37 + XCTAssertTrue(reEncoded.contains("3.14")) 38 + } 39 + 40 + func testInitWithStringDict() throws { 41 + let value = AnyCodable(["key": "value"] as [String: String]) 42 + let data = try encoder.encode(value) 43 + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) 44 + XCTAssertTrue(json.contains("\"key\"")) 45 + XCTAssertTrue(json.contains("\"value\"")) 46 + } 47 + 48 + func testInitWithUnknownTypeFallsToNull() throws { 49 + let value = AnyCodable(Date()) 50 + let data = try encoder.encode(value) 51 + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) 52 + XCTAssertEqual(json, "null") 53 + } 54 + 55 + // MARK: - Decoding from JSON 56 + 57 + func testDecodeNull() throws { 58 + let json = Data("null".utf8) 59 + let value = try decoder.decode(AnyCodable.self, from: json) 60 + // Re-encode should produce null 61 + let reEncoded = try XCTUnwrap(try String(data: encoder.encode(value), encoding: .utf8)) 62 + XCTAssertEqual(reEncoded, "null") 63 + } 64 + 65 + func testDecodeString() throws { 66 + let json = Data("\"test\"".utf8) 67 + let value = try decoder.decode(AnyCodable.self, from: json) 68 + let reEncoded = try XCTUnwrap(try String(data: encoder.encode(value), encoding: .utf8)) 69 + XCTAssertEqual(reEncoded, "\"test\"") 70 + } 71 + 72 + func testDecodeArray() throws { 73 + let json = Data("[1,2,3]".utf8) 74 + let value = try decoder.decode(AnyCodable.self, from: json) 75 + let reEncoded = try XCTUnwrap(try String(data: encoder.encode(value), encoding: .utf8)) 76 + XCTAssertEqual(reEncoded, "[1,2,3]") 77 + } 78 + 79 + func testDecodeNestedDict() throws { 80 + let json = Data(""" 81 + {"outer": {"inner": "value"}} 82 + """.utf8) 83 + let value = try decoder.decode(AnyCodable.self, from: json) 84 + XCTAssertNotNil(value.dictValue) 85 + XCTAssertNotNil(value.dictValue?["outer"]?.dictValue) 86 + } 87 + 88 + // MARK: - dictValue 89 + 90 + func testDictValueReturnsNilForNonDict() { 91 + let value = AnyCodable("not a dict") 92 + XCTAssertNil(value.dictValue) 93 + } 94 + 95 + func testDictValueReturnsDictForDict() { 96 + let value = AnyCodable(["key": AnyCodable("val")]) 97 + XCTAssertNotNil(value.dictValue) 98 + XCTAssertEqual(value.dictValue?.count, 1) 99 + } 100 + }
+80
GrainTests/DateFormattingTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class DateFormattingTests: XCTestCase { 5 + // MARK: - parse() 6 + 7 + func testParseFractionalSeconds() { 8 + let date = DateFormatting.parse("2024-06-15T12:30:45.123Z") 9 + XCTAssertNotNil(date) 10 + } 11 + 12 + func testParseWithoutFractionalSeconds() { 13 + let date = DateFormatting.parse("2024-06-15T12:30:45Z") 14 + XCTAssertNotNil(date) 15 + } 16 + 17 + func testParseInvalidStringReturnsNil() { 18 + XCTAssertNil(DateFormatting.parse("not-a-date")) 19 + XCTAssertNil(DateFormatting.parse("")) 20 + XCTAssertNil(DateFormatting.parse("2024-13-40")) 21 + } 22 + 23 + func testParseRoundTrip() throws { 24 + // Generate an ISO string, parse it back, verify it's close to now 25 + let iso = DateFormatting.nowISO() 26 + let parsed = DateFormatting.parse(iso) 27 + XCTAssertNotNil(parsed) 28 + // Should be within 1 second of now 29 + let interval = try abs(XCTUnwrap(parsed?.timeIntervalSinceNow)) 30 + XCTAssertLessThan(interval, 1.0) 31 + } 32 + 33 + // MARK: - relativeTime() 34 + 35 + func testRelativeTimeNow() { 36 + let iso = DateFormatting.nowISO() 37 + let result = DateFormatting.relativeTime(iso) 38 + XCTAssertEqual(result, "now") 39 + } 40 + 41 + func testRelativeTimeInvalidReturnsEmpty() { 42 + XCTAssertEqual(DateFormatting.relativeTime("garbage"), "") 43 + } 44 + 45 + func testRelativeTimeMinutesAgo() { 46 + let fiveMinutesAgo = Date().addingTimeInterval(-300) 47 + let iso = isoString(from: fiveMinutesAgo) 48 + let result = DateFormatting.relativeTime(iso) 49 + XCTAssertEqual(result, "5m") 50 + } 51 + 52 + func testRelativeTimeHoursAgo() { 53 + let threeHoursAgo = Date().addingTimeInterval(-10800) 54 + let iso = isoString(from: threeHoursAgo) 55 + let result = DateFormatting.relativeTime(iso) 56 + XCTAssertEqual(result, "3h") 57 + } 58 + 59 + func testRelativeTimeDaysAgo() { 60 + let twoDaysAgo = Date().addingTimeInterval(-180_000) // 50 hours, well within the 2d bucket 61 + let iso = isoString(from: twoDaysAgo) 62 + let result = DateFormatting.relativeTime(iso) 63 + XCTAssertEqual(result, "2d") 64 + } 65 + 66 + func testRelativeTimeWeeksAgo() { 67 + let twoWeeksAgo = Date().addingTimeInterval(-1_300_000) // ~2.17 weeks, well within the 2w bucket 68 + let iso = isoString(from: twoWeeksAgo) 69 + let result = DateFormatting.relativeTime(iso) 70 + XCTAssertEqual(result, "2w") 71 + } 72 + 73 + // MARK: - Helpers 74 + 75 + private func isoString(from date: Date) -> String { 76 + let formatter = ISO8601DateFormatter() 77 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 78 + return formatter.string(from: date) 79 + } 80 + }
+80
GrainTests/DeepLinkTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class DeepLinkTests: XCTestCase { 5 + // MARK: - grain:// scheme 6 + 7 + func testGrainSchemeProfile() throws { 8 + let url = try XCTUnwrap(URL(string: "grain://profile/did:plc:abc123")) 9 + let link = DeepLink.from(url: url) 10 + XCTAssertEqual(link, .profile(did: "did:plc:abc123")) 11 + } 12 + 13 + func testGrainSchemeGallery() throws { 14 + let url = try XCTUnwrap(URL(string: "grain://profile/did:plc:abc123/gallery/rkey456")) 15 + let link = DeepLink.from(url: url) 16 + XCTAssertEqual(link, .gallery(did: "did:plc:abc123", rkey: "rkey456")) 17 + } 18 + 19 + func testGrainSchemeStory() throws { 20 + let url = try XCTUnwrap(URL(string: "grain://profile/did:plc:abc123/story/rkey789")) 21 + let link = DeepLink.from(url: url) 22 + XCTAssertEqual(link, .story(did: "did:plc:abc123", rkey: "rkey789")) 23 + } 24 + 25 + // MARK: - https:// scheme 26 + 27 + func testHTTPSProfile() throws { 28 + let url = try XCTUnwrap(URL(string: "https://grain.social/profile/did:plc:xyz")) 29 + let link = DeepLink.from(url: url) 30 + XCTAssertEqual(link, .profile(did: "did:plc:xyz")) 31 + } 32 + 33 + func testHTTPSGallery() throws { 34 + let url = try XCTUnwrap(URL(string: "https://grain.social/profile/did:plc:xyz/gallery/abc")) 35 + let link = DeepLink.from(url: url) 36 + XCTAssertEqual(link, .gallery(did: "did:plc:xyz", rkey: "abc")) 37 + } 38 + 39 + func testHTTPSStory() throws { 40 + let url = try XCTUnwrap(URL(string: "https://grain.social/profile/did:plc:xyz/story/abc")) 41 + let link = DeepLink.from(url: url) 42 + XCTAssertEqual(link, .story(did: "did:plc:xyz", rkey: "abc")) 43 + } 44 + 45 + // MARK: - Invalid URLs 46 + 47 + func testMissingProfileSegment() throws { 48 + let url = try XCTUnwrap(URL(string: "grain://gallery/rkey456")) 49 + let link = DeepLink.from(url: url) 50 + XCTAssertNil(link) 51 + } 52 + 53 + func testEmptyPath() throws { 54 + let url = try XCTUnwrap(URL(string: "https://grain.social/")) 55 + let link = DeepLink.from(url: url) 56 + XCTAssertNil(link) 57 + } 58 + 59 + func testUnknownSubpath() throws { 60 + // profile/did/unknownsegment/rkey — should fall through to just profile 61 + let url = try XCTUnwrap(URL(string: "grain://profile/did:plc:abc/settings/foo")) 62 + let link = DeepLink.from(url: url) 63 + XCTAssertEqual(link, .profile(did: "did:plc:abc")) 64 + } 65 + 66 + // MARK: - galleryUri computed property 67 + 68 + func testGalleryUriForGalleryLink() { 69 + let link = DeepLink.gallery(did: "did:plc:test", rkey: "abc123") 70 + XCTAssertEqual(link.galleryUri, "at://did:plc:test/social.grain.gallery/abc123") 71 + } 72 + 73 + func testGalleryUriForNonGalleryLink() { 74 + let profile = DeepLink.profile(did: "did:plc:test") 75 + XCTAssertNil(profile.galleryUri) 76 + 77 + let story = DeepLink.story(did: "did:plc:test", rkey: "abc") 78 + XCTAssertNil(story.galleryUri) 79 + } 80 + }
+104
GrainTests/FacetCodingTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class FacetCodingTests: XCTestCase { 5 + private let decoder = JSONDecoder() 6 + private let encoder = JSONEncoder() 7 + 8 + // MARK: - Decoding 9 + 10 + func testDecodeMention() throws { 11 + let json = Data(""" 12 + {"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:abc123"} 13 + """.utf8) 14 + let feature = try decoder.decode(FacetFeature.self, from: json) 15 + if case let .mention(did) = feature { 16 + XCTAssertEqual(did, "did:plc:abc123") 17 + } else { 18 + XCTFail("Expected mention, got \(feature)") 19 + } 20 + } 21 + 22 + func testDecodeLink() throws { 23 + let json = Data(""" 24 + {"$type": "app.bsky.richtext.facet#link", "uri": "https://example.com"} 25 + """.utf8) 26 + let feature = try decoder.decode(FacetFeature.self, from: json) 27 + if case let .link(uri) = feature { 28 + XCTAssertEqual(uri, "https://example.com") 29 + } else { 30 + XCTFail("Expected link, got \(feature)") 31 + } 32 + } 33 + 34 + func testDecodeTag() throws { 35 + let json = Data(""" 36 + {"$type": "app.bsky.richtext.facet#tag", "tag": "photography"} 37 + """.utf8) 38 + let feature = try decoder.decode(FacetFeature.self, from: json) 39 + if case let .tag(tag) = feature { 40 + XCTAssertEqual(tag, "photography") 41 + } else { 42 + XCTFail("Expected tag, got \(feature)") 43 + } 44 + } 45 + 46 + func testDecodeUnknownTypeThrows() { 47 + let json = Data(""" 48 + {"$type": "app.bsky.richtext.facet#unknown", "data": "stuff"} 49 + """.utf8) 50 + XCTAssertThrowsError(try decoder.decode(FacetFeature.self, from: json)) 51 + } 52 + 53 + // MARK: - Encoding round-trips 54 + 55 + func testMentionRoundTrip() throws { 56 + let original = FacetFeature.mention(did: "did:plc:xyz") 57 + let data = try encoder.encode(original) 58 + let decoded = try decoder.decode(FacetFeature.self, from: data) 59 + if case let .mention(did) = decoded { 60 + XCTAssertEqual(did, "did:plc:xyz") 61 + } else { 62 + XCTFail("Round-trip failed") 63 + } 64 + } 65 + 66 + func testLinkRoundTrip() throws { 67 + let original = FacetFeature.link(uri: "https://grain.social") 68 + let data = try encoder.encode(original) 69 + let decoded = try decoder.decode(FacetFeature.self, from: data) 70 + if case let .link(uri) = decoded { 71 + XCTAssertEqual(uri, "https://grain.social") 72 + } else { 73 + XCTFail("Round-trip failed") 74 + } 75 + } 76 + 77 + func testTagRoundTrip() throws { 78 + let original = FacetFeature.tag(tag: "streetphoto") 79 + let data = try encoder.encode(original) 80 + let decoded = try decoder.decode(FacetFeature.self, from: data) 81 + if case let .tag(tag) = decoded { 82 + XCTAssertEqual(tag, "streetphoto") 83 + } else { 84 + XCTFail("Round-trip failed") 85 + } 86 + } 87 + 88 + // MARK: - Full Facet 89 + 90 + func testDecodeFacetWithFeatures() throws { 91 + let json = Data(""" 92 + { 93 + "index": {"byteStart": 0, "byteEnd": 10}, 94 + "features": [ 95 + {"$type": "app.bsky.richtext.facet#mention", "did": "did:plc:test"} 96 + ] 97 + } 98 + """.utf8) 99 + let facet = try decoder.decode(Facet.self, from: json) 100 + XCTAssertEqual(facet.index.byteStart, 0) 101 + XCTAssertEqual(facet.index.byteEnd, 10) 102 + XCTAssertEqual(facet.features.count, 1) 103 + } 104 + }
+51
GrainTests/FeedEndpointModelTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class FeedEndpointModelTests: XCTestCase { 5 + // MARK: - PinnedFeed.feedName 6 + 7 + func testFeedNameForCameraType() { 8 + let feed = PinnedFeed(id: "camera:Sony A7III", label: "Sony A7III", type: "camera", path: "/feeds/camera") 9 + XCTAssertEqual(feed.feedName, "camera") 10 + } 11 + 12 + func testFeedNameForLocationType() { 13 + let feed = PinnedFeed(id: "location:tokyo", label: "Tokyo", type: "location", path: "/feeds/location") 14 + XCTAssertEqual(feed.feedName, "location") 15 + } 16 + 17 + func testFeedNameForHashtagType() { 18 + let feed = PinnedFeed(id: "hashtag:streetphoto", label: "#streetphoto", type: "hashtag", path: "/feeds/hashtag") 19 + XCTAssertEqual(feed.feedName, "hashtag") 20 + } 21 + 22 + func testFeedNameDefaultsToId() { 23 + let feed = PinnedFeed(id: "following", label: "Following", type: "feed", path: "/feeds/following") 24 + XCTAssertEqual(feed.feedName, "following") 25 + } 26 + 27 + // MARK: - PinnedFeed.feedValue 28 + 29 + func testFeedValueExtractsAfterColon() { 30 + let feed = PinnedFeed(id: "camera:Sony A7III", label: "Sony A7III", type: "camera", path: "/") 31 + XCTAssertEqual(feed.feedValue, "Sony A7III") 32 + } 33 + 34 + func testFeedValueNilWithoutColon() { 35 + let feed = PinnedFeed(id: "recent", label: "Recent", type: "feed", path: "/") 36 + XCTAssertNil(feed.feedValue) 37 + } 38 + 39 + func testFeedValueHandlesMultipleColons() { 40 + let feed = PinnedFeed(id: "tag:key:value", label: "Test", type: "tag", path: "/") 41 + XCTAssertEqual(feed.feedValue, "key:value") 42 + } 43 + 44 + // MARK: - PinnedFeed.defaults 45 + 46 + func testDefaultsContainsTwoFeeds() { 47 + XCTAssertEqual(PinnedFeed.defaults.count, 2) 48 + XCTAssertEqual(PinnedFeed.defaults[0].id, "recent") 49 + XCTAssertEqual(PinnedFeed.defaults[1].id, "following") 50 + } 51 + }
+1 -1
GrainTests/GrainTests.swift
··· 1 - import XCTest 2 1 @testable import Grain 2 + import XCTest 3 3 4 4 final class GrainTests: XCTestCase { 5 5 func testAspectRatio() {
+68
GrainTests/Helpers/MockURLProtocol.swift
··· 1 + import Foundation 2 + 3 + /// A URLProtocol subclass that intercepts all requests and returns preconfigured responses. 4 + /// Use `MockURLProtocol.handler` to set up a response before each test. 5 + final class MockURLProtocol: URLProtocol { 6 + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (Data, HTTPURLResponse))? 7 + 8 + override static func canInit(with _: URLRequest) -> Bool { 9 + true 10 + } 11 + 12 + override static func canonicalRequest(for request: URLRequest) -> URLRequest { 13 + request 14 + } 15 + 16 + override func startLoading() { 17 + guard let handler = Self.handler else { 18 + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) 19 + return 20 + } 21 + do { 22 + let (data, response) = try handler(request) 23 + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 24 + client?.urlProtocol(self, didLoad: data) 25 + client?.urlProtocolDidFinishLoading(self) 26 + } catch { 27 + client?.urlProtocol(self, didFailWithError: error) 28 + } 29 + } 30 + 31 + override func stopLoading() {} 32 + 33 + // MARK: - Convenience 34 + 35 + /// Create a URLSession configured to use mock responses. 36 + static func mockSession() -> URLSession { 37 + let config = URLSessionConfiguration.ephemeral 38 + config.protocolClasses = [MockURLProtocol.self] 39 + return URLSession(configuration: config) 40 + } 41 + 42 + /// Set handler to return JSON data with 200 status for any request. 43 + static func respondWithJSON(_ json: String) { 44 + handler = { request in 45 + let data = json.data(using: .utf8)! 46 + let response = HTTPURLResponse( 47 + url: request.url!, 48 + statusCode: 200, 49 + httpVersion: nil, 50 + headerFields: ["Content-Type": "application/json"] 51 + )! 52 + return (data, response) 53 + } 54 + } 55 + 56 + /// Set handler to return an error status code. 57 + static func respondWithError(statusCode: Int) { 58 + handler = { request in 59 + let response = HTTPURLResponse( 60 + url: request.url!, 61 + statusCode: statusCode, 62 + httpVersion: nil, 63 + headerFields: nil 64 + )! 65 + return (Data(), response) 66 + } 67 + } 68 + }
+301
GrainTests/ImagePrefetchPlanningTests.swift
··· 1 + @testable import Grain 2 + import Nuke 3 + import XCTest 4 + 5 + final class ImagePrefetchPlanningTests: XCTestCase { 6 + // MARK: - Helpers 7 + 8 + private func photo(index: Int) -> (thumb: String, fullsize: String) { 9 + (thumb: "https://cdn.example.com/thumb/\(index).jpg", fullsize: "https://cdn.example.com/full/\(index).jpg") 10 + } 11 + 12 + private func photos(_ count: Int) -> [(thumb: String, fullsize: String)] { 13 + (0 ..< count).map { photo(index: $0) } 14 + } 15 + 16 + private func urls(from requests: [ImageRequest]) -> [String] { 17 + requests.compactMap { $0.url?.absoluteString } 18 + } 19 + 20 + // MARK: - Carousel: Empty / Edge Cases 21 + 22 + func testCarousel_emptyPhotos_returnsEmpty() { 23 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: [], currentPage: 0) 24 + XCTAssertTrue(result.all.isEmpty) 25 + } 26 + 27 + func testCarousel_outOfBoundsPage_returnsEmpty() { 28 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: 10) 29 + XCTAssertTrue(result.all.isEmpty) 30 + } 31 + 32 + func testCarousel_negativePage_returnsEmpty() { 33 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: -1) 34 + XCTAssertTrue(result.all.isEmpty) 35 + } 36 + 37 + // MARK: - Carousel: Page 0 38 + 39 + func testCarousel_singlePhoto_page0_prefetchesOnlyThatFullsize() { 40 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(1), currentPage: 0) 41 + XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/0.jpg"]) 42 + XCTAssertTrue(result.normal.isEmpty) 43 + XCTAssertTrue(result.low.isEmpty) 44 + } 45 + 46 + func testCarousel_twoPhotos_page0_prefetchesBothFullsize() { 47 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(2), currentPage: 0) 48 + XCTAssertEqual(urls(from: result.high), [ 49 + "https://cdn.example.com/full/0.jpg", 50 + "https://cdn.example.com/full/1.jpg", 51 + ]) 52 + } 53 + 54 + func testCarousel_fivePhotos_page0_prefetchesOnlyFirstTwo() { 55 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 0) 56 + XCTAssertEqual(urls(from: result.high).count, 2) 57 + XCTAssertTrue(result.normal.isEmpty) 58 + } 59 + 60 + // MARK: - Carousel: Page 1 61 + 62 + func testCarousel_threePhotos_page1_prefetchesThirdFullsizeAndAllThumbs() { 63 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: 1) 64 + XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/2.jpg"]) 65 + XCTAssertEqual(urls(from: result.normal).count, 3) // all 3 thumbs 66 + XCTAssertTrue(urls(from: result.normal).allSatisfy { $0.contains("/thumb/") }) 67 + } 68 + 69 + func testCarousel_twoPhotos_page1_noThirdFullsize_stillPrefetchesThumbs() { 70 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(2), currentPage: 1) 71 + XCTAssertTrue(result.high.isEmpty) 72 + XCTAssertEqual(urls(from: result.normal).count, 2) // both thumbs 73 + } 74 + 75 + // MARK: - Carousel: Page 2+ 76 + 77 + func testCarousel_tenPhotos_page5_prefetchesNextAtHighRestAtNormal() { 78 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(10), currentPage: 5) 79 + XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/6.jpg"]) 80 + XCTAssertEqual(urls(from: result.normal).count, 3) // 7, 8, 9 81 + } 82 + 83 + func testCarousel_lastPage_returnsEmpty() { 84 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(3), currentPage: 2) 85 + XCTAssertTrue(result.high.isEmpty) 86 + XCTAssertTrue(result.normal.isEmpty) 87 + } 88 + 89 + // MARK: - Feed: Edge Cases 90 + 91 + func testFeed_emptyGalleries_returnsEmpty() { 92 + let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: [], currentIndex: 0) 93 + XCTAssertTrue(result.all.isEmpty) 94 + } 95 + 96 + func testFeed_atLastGallery_returnsEmpty() { 97 + let galleries: [(firstThumb: String?, firstFullsize: String?)] = [ 98 + (firstThumb: "t0", firstFullsize: "f0"), 99 + ] 100 + let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 0) 101 + XCTAssertTrue(result.all.isEmpty) 102 + } 103 + 104 + // MARK: - Feed: Look-ahead 105 + 106 + func testFeed_tenGalleries_atIndex3_prefetchesNext3() { 107 + let galleries: [(firstThumb: String?, firstFullsize: String?)] = (0 ..< 10).map { i in 108 + (firstThumb: "https://cdn.example.com/thumb/\(i).jpg", 109 + firstFullsize: "https://cdn.example.com/full/\(i).jpg") 110 + } 111 + let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 3) 112 + 113 + // High: thumb 4, fullsize 4, thumb 5 114 + // Normal: fullsize 5 115 + // Low: fullsize 6, thumb 6 116 + let allUrls = urls(from: result.all) 117 + XCTAssertTrue(allUrls.contains("https://cdn.example.com/full/4.jpg")) 118 + XCTAssertTrue(allUrls.contains("https://cdn.example.com/full/5.jpg")) 119 + XCTAssertTrue(allUrls.contains("https://cdn.example.com/full/6.jpg")) 120 + XCTAssertTrue(allUrls.contains("https://cdn.example.com/thumb/4.jpg")) 121 + } 122 + 123 + func testFeed_galleryWithNoPhotos_skipsGracefully() { 124 + let galleries: [(firstThumb: String?, firstFullsize: String?)] = [ 125 + (firstThumb: "t0", firstFullsize: "f0"), 126 + (firstThumb: nil, firstFullsize: nil), // gallery with no photos 127 + (firstThumb: "t2", firstFullsize: "f2"), 128 + ] 129 + let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 0) 130 + // Should still get gallery at index 2 131 + let allUrls = urls(from: result.all) 132 + XCTAssertTrue(allUrls.contains("t2")) 133 + XCTAssertFalse(allUrls.contains("nil")) 134 + } 135 + 136 + // MARK: - Stories: Edge Cases 137 + 138 + func testStory_emptyCurrentStories_returnsEmpty() { 139 + let result = ImagePrefetchPlanning.storyPrefetchRequests( 140 + currentStories: [], 141 + currentStoryIndex: 0, 142 + nextAuthorStories: nil, 143 + secondNextAuthorStories: nil, 144 + thirdNextFirstStory: nil, 145 + fourthNextFirstStory: nil 146 + ) 147 + XCTAssertTrue(result.all.isEmpty) 148 + } 149 + 150 + // MARK: - Stories: Priority Order 151 + 152 + func testStory_fiveStories_atIndex0_prefetchesCorrectPriorities() { 153 + let stories = photos(5) 154 + let nextAuthor = photos(3) 155 + 156 + let result = ImagePrefetchPlanning.storyPrefetchRequests( 157 + currentStories: stories, 158 + currentStoryIndex: 0, 159 + nextAuthorStories: nextAuthor, 160 + secondNextAuthorStories: nil, 161 + thirdNextFirstStory: nil, 162 + fourthNextFirstStory: nil 163 + ) 164 + 165 + // High: stories 1, 2 of current + first of next author 166 + XCTAssertEqual(urls(from: result.high), [ 167 + "https://cdn.example.com/full/1.jpg", 168 + "https://cdn.example.com/full/2.jpg", 169 + "https://cdn.example.com/full/0.jpg", // next author's first 170 + ]) 171 + 172 + // Normal: stories 3, 4 of current (rest of stack) 173 + XCTAssertEqual(urls(from: result.normal), [ 174 + "https://cdn.example.com/full/3.jpg", 175 + "https://cdn.example.com/full/4.jpg", 176 + ]) 177 + } 178 + 179 + func testStory_lastStoryOfAuthor_onlyPrefetchesNextAuthor() { 180 + let stories = photos(1) 181 + let nextAuthor = photos(2) 182 + 183 + let result = ImagePrefetchPlanning.storyPrefetchRequests( 184 + currentStories: stories, 185 + currentStoryIndex: 0, 186 + nextAuthorStories: nextAuthor, 187 + secondNextAuthorStories: nil, 188 + thirdNextFirstStory: nil, 189 + fourthNextFirstStory: nil 190 + ) 191 + 192 + // High: only next author's first (no more current stories to prefetch) 193 + XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/0.jpg"]) 194 + } 195 + 196 + func testStory_noNextAuthorData_skipsThatTier() { 197 + let stories = photos(3) 198 + 199 + let result = ImagePrefetchPlanning.storyPrefetchRequests( 200 + currentStories: stories, 201 + currentStoryIndex: 0, 202 + nextAuthorStories: nil, 203 + secondNextAuthorStories: nil, 204 + thirdNextFirstStory: nil, 205 + fourthNextFirstStory: nil 206 + ) 207 + 208 + // High: only current author stories 1, 2 209 + XCTAssertEqual(urls(from: result.high).count, 2) 210 + XCTAssertTrue(result.low.isEmpty) 211 + } 212 + 213 + func testStory_fullPriorityChain() { 214 + let current = photos(2) 215 + let nextAuthor = [(thumb: "https://next/t0", fullsize: "https://next/f0")] 216 + let secondNext = [(thumb: "https://second/t0", fullsize: "https://second/f0"), 217 + (thumb: "https://second/t1", fullsize: "https://second/f1")] 218 + let third = (thumb: "https://third/t0", fullsize: "https://third/f0") 219 + let fourth = (thumb: "https://fourth/t0", fullsize: "https://fourth/f0") 220 + 221 + let result = ImagePrefetchPlanning.storyPrefetchRequests( 222 + currentStories: current, 223 + currentStoryIndex: 0, 224 + nextAuthorStories: nextAuthor, 225 + secondNextAuthorStories: secondNext, 226 + thirdNextFirstStory: third, 227 + fourthNextFirstStory: fourth 228 + ) 229 + 230 + // High: current story 1 + next author first 231 + XCTAssertEqual(urls(from: result.high).count, 2) 232 + // Normal: second-next author stories 0, 1 233 + XCTAssertEqual(urls(from: result.normal).count, 2) 234 + // Low: third + fourth author firsts 235 + XCTAssertEqual(urls(from: result.low).count, 2) 236 + } 237 + 238 + // MARK: - Priority correctness 239 + 240 + func testCarousel_page0_allRequestsAreHighPriority() { 241 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 0) 242 + XCTAssertTrue(result.high.allSatisfy { $0.priority == .high }) 243 + XCTAssertTrue(result.normal.isEmpty) 244 + XCTAssertTrue(result.low.isEmpty) 245 + } 246 + 247 + func testCarousel_page1_thumbsAreNormalPriority() { 248 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 1) 249 + XCTAssertTrue(result.high.allSatisfy { $0.priority == .high }) 250 + XCTAssertTrue(result.normal.allSatisfy { $0.priority == .normal }) 251 + } 252 + 253 + func testFeed_priorityDescentAcrossOffsets() { 254 + let galleries: [(firstThumb: String?, firstFullsize: String?)] = (0 ..< 10).map { i in 255 + (firstThumb: "https://cdn.example.com/thumb/\(i).jpg", 256 + firstFullsize: "https://cdn.example.com/full/\(i).jpg") 257 + } 258 + let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 3) 259 + XCTAssertTrue(urls(from: result.high).contains("https://cdn.example.com/full/4.jpg")) 260 + XCTAssertTrue(urls(from: result.normal).contains("https://cdn.example.com/full/5.jpg")) 261 + XCTAssertTrue(urls(from: result.low).contains("https://cdn.example.com/full/6.jpg")) 262 + } 263 + 264 + // MARK: - Story boundary: currentStoryIndex at end 265 + 266 + func testStory_atLastIndex_noCurrentPrefetchButNextAuthorStill() { 267 + let stories = photos(3) 268 + let nextAuthor = photos(2) 269 + 270 + let result = ImagePrefetchPlanning.storyPrefetchRequests( 271 + currentStories: stories, 272 + currentStoryIndex: 2, 273 + nextAuthorStories: nextAuthor, 274 + secondNextAuthorStories: nil, 275 + thirdNextFirstStory: nil, 276 + fourthNextFirstStory: nil 277 + ) 278 + 279 + let highUrls = urls(from: result.high) 280 + XCTAssertFalse(highUrls.contains("https://cdn.example.com/full/3.jpg")) 281 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/0.jpg")) 282 + } 283 + 284 + // MARK: - No duplicate URLs 285 + 286 + func testCarousel_page0_noDuplicateURLs() { 287 + let result = ImagePrefetchPlanning.carouselPrefetchRequests(photos: photos(5), currentPage: 0) 288 + let allUrls = urls(from: result.all) 289 + XCTAssertEqual(allUrls.count, Set(allUrls).count) 290 + } 291 + 292 + func testFeed_noDuplicateURLs() { 293 + let galleries: [(firstThumb: String?, firstFullsize: String?)] = (0 ..< 10).map { i in 294 + (firstThumb: "https://cdn.example.com/thumb/\(i).jpg", 295 + firstFullsize: "https://cdn.example.com/full/\(i).jpg") 296 + } 297 + let result = ImagePrefetchPlanning.feedPrefetchRequests(galleries: galleries, currentIndex: 3) 298 + let allUrls = urls(from: result.all) 299 + XCTAssertEqual(allUrls.count, Set(allUrls).count) 300 + } 301 + }
+139
GrainTests/LabelResolutionTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class LabelResolutionTests: XCTestCase { 5 + // MARK: - LabelAction ordering 6 + 7 + func testLabelActionSeverityOrder() { 8 + XCTAssertTrue(LabelAction.none < .badge) 9 + XCTAssertTrue(LabelAction.badge < .warnMedia) 10 + XCTAssertTrue(LabelAction.warnMedia < .warnContent) 11 + XCTAssertTrue(LabelAction.warnContent < .hide) 12 + } 13 + 14 + // MARK: - resolveLabels with nil/empty 15 + 16 + func testNilLabelsReturnsNone() { 17 + let result = resolveLabels(nil, definitions: []) 18 + XCTAssertEqual(result.action, .none) 19 + } 20 + 21 + func testEmptyLabelsReturnsNone() { 22 + let result = resolveLabels([], definitions: []) 23 + XCTAssertEqual(result.action, .none) 24 + } 25 + 26 + func testLabelWithNilValIsSkipped() { 27 + let label = ATLabel(src: nil, uri: nil, val: nil, cts: nil) 28 + let result = resolveLabels([label], definitions: []) 29 + XCTAssertEqual(result.action, .none) 30 + } 31 + 32 + func testLabelWithEmptyValIsSkipped() { 33 + let label = ATLabel(src: nil, uri: nil, val: "", cts: nil) 34 + let result = resolveLabels([label], definitions: []) 35 + XCTAssertEqual(result.action, .none) 36 + } 37 + 38 + // MARK: - Fallback definitions 39 + 40 + func testPornFallbackToWarnMedia() { 41 + let label = ATLabel(src: nil, uri: nil, val: "porn", cts: nil) 42 + let result = resolveLabels([label], definitions: []) 43 + XCTAssertEqual(result.action, .warnMedia) 44 + XCTAssertEqual(result.label, "porn") 45 + } 46 + 47 + func testGoreFallbackToWarnMedia() { 48 + // gore has blurs=media, setting=hide -> warnMedia (media + hide = warnMedia) 49 + let label = ATLabel(src: nil, uri: nil, val: "gore", cts: nil) 50 + let result = resolveLabels([label], definitions: []) 51 + XCTAssertEqual(result.action, .warnMedia) 52 + } 53 + 54 + func testDMCAFallbackToHide() { 55 + // dmca-violation has blurs=content, setting=hide -> hide 56 + let label = ATLabel(src: nil, uri: nil, val: "dmca-violation", cts: nil) 57 + let result = resolveLabels([label], definitions: []) 58 + XCTAssertEqual(result.action, .hide) 59 + } 60 + 61 + func testDoxxingFallbackToHide() { 62 + let label = ATLabel(src: nil, uri: nil, val: "doxxing", cts: nil) 63 + let result = resolveLabels([label], definitions: []) 64 + XCTAssertEqual(result.action, .hide) 65 + } 66 + 67 + func testBangHideFallback() { 68 + let label = ATLabel(src: nil, uri: nil, val: "!hide", cts: nil) 69 + let result = resolveLabels([label], definitions: []) 70 + XCTAssertEqual(result.action, .hide) 71 + } 72 + 73 + func testBangWarnFallback() { 74 + let label = ATLabel(src: nil, uri: nil, val: "!warn", cts: nil) 75 + let result = resolveLabels([label], definitions: []) 76 + XCTAssertEqual(result.action, .warnContent) 77 + } 78 + 79 + // MARK: - Unknown labels 80 + 81 + func testUnknownLabelFallsToBadge() { 82 + let label = ATLabel(src: nil, uri: nil, val: "totally-unknown", cts: nil) 83 + let result = resolveLabels([label], definitions: []) 84 + XCTAssertEqual(result.action, .badge) 85 + XCTAssertEqual(result.label, "totally-unknown") 86 + } 87 + 88 + // MARK: - Server-provided definitions 89 + 90 + func testServerDefinitionOverridesFallback() { 91 + let def = LabelDefinition( 92 + identifier: "porn", 93 + locales: [LabelLocale(name: "Adult")], 94 + blurs: "content", 95 + defaultSetting: "hide" 96 + ) 97 + let label = ATLabel(src: nil, uri: nil, val: "porn", cts: nil) 98 + let result = resolveLabels([label], definitions: [def]) 99 + // content + hide = .hide (server says hide, overriding fallback's warnMedia) 100 + XCTAssertEqual(result.action, .hide) 101 + XCTAssertEqual(result.name, "Adult") 102 + } 103 + 104 + func testServerDefinitionWithWarnSetting() { 105 + let def = LabelDefinition( 106 + identifier: "custom-label", 107 + locales: [LabelLocale(name: "Custom Warning")], 108 + blurs: "media", 109 + defaultSetting: "warn" 110 + ) 111 + let label = ATLabel(src: nil, uri: nil, val: "custom-label", cts: nil) 112 + let result = resolveLabels([label], definitions: [def]) 113 + XCTAssertEqual(result.action, .warnMedia) 114 + XCTAssertEqual(result.name, "Custom Warning") 115 + } 116 + 117 + // MARK: - Worst wins 118 + 119 + func testWorstActionWins() { 120 + let labels = [ 121 + ATLabel(src: nil, uri: nil, val: "nudity", cts: nil), // warnMedia 122 + ATLabel(src: nil, uri: nil, val: "dmca-violation", cts: nil), // hide 123 + ] 124 + let result = resolveLabels(labels, definitions: []) 125 + XCTAssertEqual(result.action, .hide) 126 + XCTAssertEqual(result.label, "dmca-violation") 127 + } 128 + 129 + func testMultipleSameSeverityKeepsFirst() { 130 + // Both are warnMedia — the first one encountered at that severity stays 131 + let labels = [ 132 + ATLabel(src: nil, uri: nil, val: "porn", cts: nil), 133 + ATLabel(src: nil, uri: nil, val: "nudity", cts: nil), 134 + ] 135 + let result = resolveLabels(labels, definitions: []) 136 + XCTAssertEqual(result.action, .warnMedia) 137 + XCTAssertEqual(result.label, "porn") 138 + } 139 + }
+19
GrainTests/LoginViewTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + @MainActor 5 + final class LoginViewTests: XCTestCase { 6 + func testLegalTextContainsAllThreeLinks() throws { 7 + let attributed = try AttributedString(markdown: LoginView.legalMarkdown) 8 + var urls: [String] = [] 9 + for run in attributed.runs { 10 + if let url = run.link { 11 + urls.append(url.absoluteString) 12 + } 13 + } 14 + XCTAssertTrue(urls.contains("https://grain.social/support/terms"), "Missing Terms link") 15 + XCTAssertTrue(urls.contains("https://grain.social/support/privacy"), "Missing Privacy Policy link") 16 + XCTAssertTrue(urls.contains("https://grain.social/support/community-guidelines"), "Missing Community Guidelines link") 17 + XCTAssertEqual(urls.count, 3, "Expected exactly 3 links") 18 + } 19 + }
+123
GrainTests/NotificationsViewModelTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + @MainActor 5 + final class NotificationsViewModelTests: XCTestCase { 6 + private var client: XRPCClient! 7 + private var vm: NotificationsViewModel! 8 + 9 + override func setUp() { 10 + super.setUp() 11 + client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession()) 12 + vm = NotificationsViewModel(client: client) 13 + } 14 + 15 + override func tearDown() { 16 + MockURLProtocol.handler = nil 17 + super.tearDown() 18 + } 19 + 20 + // MARK: - loadInitial 21 + 22 + func testLoadInitialPopulatesNotifications() async { 23 + MockURLProtocol.respondWithJSON(""" 24 + { 25 + "notifications": [ 26 + { 27 + "uri": "at://did:plc:a/notif/1", 28 + "reason": "follow", 29 + "createdAt": "2024-06-15T12:00:00Z", 30 + "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"} 31 + } 32 + ], 33 + "cursor": "next", 34 + "unseenCount": 3 35 + } 36 + """) 37 + 38 + await vm.loadInitial() 39 + XCTAssertEqual(vm.notifications.count, 1) 40 + XCTAssertEqual(vm.unseenCount, 3) 41 + XCTAssertFalse(vm.isLoading) 42 + } 43 + 44 + func testLoadInitialGuardsAgainstConcurrent() async { 45 + // If already loading, loadInitial should bail 46 + vm.isLoading = true 47 + MockURLProtocol.respondWithJSON(""" 48 + {"notifications": [], "cursor": null} 49 + """) 50 + await vm.loadInitial() 51 + // Should still be "loading" since we skipped 52 + XCTAssertTrue(vm.isLoading) 53 + XCTAssertTrue(vm.notifications.isEmpty) 54 + } 55 + 56 + // MARK: - markAsSeen 57 + 58 + func testMarkAsSeenOptimisticallyZerosCount() async { 59 + vm.unseenCount = 5 60 + MockURLProtocol.respondWithJSON("{}") 61 + 62 + await vm.markAsSeen() 63 + XCTAssertEqual(vm.unseenCount, 0) 64 + } 65 + 66 + func testMarkAsSeenSkipsWhenAlreadyZero() async { 67 + vm.unseenCount = 0 68 + var requestMade = false 69 + MockURLProtocol.handler = { _ in 70 + requestMade = true 71 + return (Data("{}".utf8), HTTPURLResponse(url: URL(string: "https://test.local")!, statusCode: 200, httpVersion: nil, headerFields: nil)!) 72 + } 73 + await vm.markAsSeen() 74 + XCTAssertFalse(requestMade) 75 + } 76 + 77 + func testMarkAsSeenRollsBackOnFailure() async { 78 + vm.unseenCount = 7 79 + MockURLProtocol.respondWithError(statusCode: 500) 80 + 81 + await vm.markAsSeen() 82 + XCTAssertEqual(vm.unseenCount, 7) 83 + } 84 + 85 + // MARK: - Pagination 86 + 87 + func testLoadMoreAppendsResults() async { 88 + // Set up initial state 89 + MockURLProtocol.respondWithJSON(""" 90 + { 91 + "notifications": [ 92 + { 93 + "uri": "at://did:plc:a/notif/1", 94 + "reason": "follow", 95 + "createdAt": "2024-06-15T12:00:00Z", 96 + "author": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"} 97 + } 98 + ], 99 + "cursor": "page2", 100 + "unseenCount": 0 101 + } 102 + """) 103 + await vm.loadInitial() 104 + XCTAssertEqual(vm.notifications.count, 1) 105 + 106 + // Load more 107 + MockURLProtocol.respondWithJSON(""" 108 + { 109 + "notifications": [ 110 + { 111 + "uri": "at://did:plc:b/notif/2", 112 + "reason": "gallery-favorite", 113 + "createdAt": "2024-06-15T13:00:00Z", 114 + "author": {"cid": "c2", "did": "did:plc:b", "handle": "bob.test"} 115 + } 116 + ], 117 + "cursor": null 118 + } 119 + """) 120 + await vm.loadMore() 121 + XCTAssertEqual(vm.notifications.count, 2) 122 + } 123 + }
+109
GrainTests/PhotoEditorTests.swift
··· 1 + @testable import Grain 2 + import UIKit 3 + import XCTest 4 + 5 + final class PhotoEditorTests: XCTestCase { 6 + // MARK: - Grid Target Index Calculation 7 + 8 + func testTargetIndexSameCell() { 9 + // Small drag within same cell → stays put 10 + let result = ReorderablePhotoGrid.targetIndex( 11 + currentIndex: 4, dragOffset: CGSize(width: 5, height: 3), 12 + cellSize: 100, spacing: 4, itemCount: 9 13 + ) 14 + XCTAssertEqual(result, 4) 15 + } 16 + 17 + func testTargetIndexMoveRight() { 18 + // Drag one full cell to the right 19 + let result = ReorderablePhotoGrid.targetIndex( 20 + currentIndex: 0, dragOffset: CGSize(width: 104, height: 0), 21 + cellSize: 100, spacing: 4, itemCount: 6 22 + ) 23 + XCTAssertEqual(result, 1) 24 + } 25 + 26 + func testTargetIndexMoveDown() { 27 + // Drag one full row down 28 + let result = ReorderablePhotoGrid.targetIndex( 29 + currentIndex: 1, dragOffset: CGSize(width: 0, height: 104), 30 + cellSize: 100, spacing: 4, itemCount: 9 31 + ) 32 + XCTAssertEqual(result, 4) 33 + } 34 + 35 + func testTargetIndexClampsToLastItem() { 36 + // Drag way past the end → clamps to last item 37 + let result = ReorderablePhotoGrid.targetIndex( 38 + currentIndex: 0, dragOffset: CGSize(width: 500, height: 500), 39 + cellSize: 100, spacing: 4, itemCount: 5 40 + ) 41 + XCTAssertEqual(result, 4) 42 + } 43 + 44 + func testTargetIndexClampsToFirst() { 45 + // Drag way before the start → clamps to 0 46 + let result = ReorderablePhotoGrid.targetIndex( 47 + currentIndex: 4, dragOffset: CGSize(width: -1000, height: -1000), 48 + cellSize: 100, spacing: 4, itemCount: 5 49 + ) 50 + XCTAssertEqual(result, 0) 51 + } 52 + 53 + func testTargetIndexColumnClamp() { 54 + // From rightmost column, drag right → stays in column 2 55 + let result = ReorderablePhotoGrid.targetIndex( 56 + currentIndex: 2, dragOffset: CGSize(width: 200, height: 0), 57 + cellSize: 100, spacing: 4, itemCount: 9 58 + ) 59 + XCTAssertEqual(result, 2) 60 + } 61 + 62 + // MARK: - PhotoItem Selection Stability 63 + 64 + func testSelectionStableThroughReorder() throws { 65 + let img = UIImage() 66 + var items = (0 ..< 5).map { _ in PhotoItem(thumbnail: img, source: .camera(img)) } 67 + let selectedID = items[2].id 68 + 69 + // Move item at index 0 to index 3 70 + items.move(fromOffsets: IndexSet(integer: 0), toOffset: 4) 71 + 72 + // Selected photo should still be findable by ID 73 + let newIndex = items.firstIndex(where: { $0.id == selectedID }) 74 + XCTAssertNotNil(newIndex) 75 + XCTAssertEqual(try items[XCTUnwrap(newIndex)].id, selectedID) 76 + } 77 + 78 + func testSelectionFallsBackOnDeletion() { 79 + let img = UIImage() 80 + var items = (0 ..< 3).map { _ in PhotoItem(thumbnail: img, source: .camera(img)) } 81 + var selectedID: UUID? = items[2].id 82 + 83 + // Remove the selected item 84 + items.removeAll { $0.id == selectedID } 85 + 86 + // Simulate the fallback logic from CreateGalleryView 87 + if let id = selectedID, !items.contains(where: { $0.id == id }) { 88 + selectedID = items.first?.id 89 + } 90 + 91 + XCTAssertEqual(selectedID, items.first?.id) 92 + } 93 + 94 + func testAltTextPreservedAcrossSelection() { 95 + let img = UIImage() 96 + var items = (0 ..< 3).map { _ in PhotoItem(thumbnail: img, source: .camera(img)) } 97 + items[0].alt = "First photo" 98 + items[1].alt = "Second photo" 99 + 100 + // Simulate switching selection back and forth 101 + let id0 = items[0].id 102 + let id1 = items[1].id 103 + _ = items.firstIndex(where: { $0.id == id1 }) 104 + _ = items.firstIndex(where: { $0.id == id0 }) 105 + 106 + XCTAssertEqual(items[0].alt, "First photo") 107 + XCTAssertEqual(items[1].alt, "Second photo") 108 + } 109 + }
+106
GrainTests/PhotoModelsTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + final class PhotoModelsTests: XCTestCase { 5 + // MARK: - Helpers 6 + 7 + private func makeExif( 8 + make: String? = nil, 9 + model: String? = nil, 10 + lensMake: String? = nil, 11 + lensModel: String? = nil, 12 + focalLength: String? = nil, 13 + fNumber: String? = nil, 14 + exposureTime: String? = nil, 15 + iso: Int? = nil 16 + ) -> GrainExif { 17 + GrainExif( 18 + uri: "at://test/photo.exif/1", 19 + cid: "cid", 20 + photo: "at://test/photo/1", 21 + createdAt: "2024-01-01T00:00:00Z", 22 + exposureTime: exposureTime, 23 + fNumber: fNumber, 24 + focalLengthIn35mmFormat: focalLength, 25 + iSO: iso, 26 + lensMake: lensMake, 27 + lensModel: lensModel, 28 + make: make, 29 + model: model 30 + ) 31 + } 32 + 33 + // MARK: - cameraName 34 + 35 + func testCameraNameWithMakeAndModel() { 36 + let exif = makeExif(make: "Sony", model: "A7III") 37 + XCTAssertEqual(exif.cameraName, "Sony A7III") 38 + } 39 + 40 + func testCameraNameWithOnlyMake() { 41 + let exif = makeExif(make: "Canon") 42 + XCTAssertEqual(exif.cameraName, "Canon") 43 + } 44 + 45 + func testCameraNameWithOnlyModel() { 46 + let exif = makeExif(model: "X100V") 47 + XCTAssertEqual(exif.cameraName, "X100V") 48 + } 49 + 50 + func testCameraNameNilWhenBothMissing() { 51 + let exif = makeExif() 52 + XCTAssertNil(exif.cameraName) 53 + } 54 + 55 + func testCameraNameFiltersEmptyStrings() { 56 + let exif = makeExif(make: "", model: "A7III") 57 + XCTAssertEqual(exif.cameraName, "A7III") 58 + } 59 + 60 + // MARK: - lensName 61 + 62 + func testLensNamePrefersLensModel() { 63 + let exif = makeExif(lensMake: "Sigma", lensModel: "35mm f/1.4") 64 + XCTAssertEqual(exif.lensName, "35mm f/1.4") 65 + } 66 + 67 + func testLensNameNilWhenMissing() { 68 + let exif = makeExif() 69 + XCTAssertNil(exif.lensName) 70 + } 71 + 72 + func testLensNameWithEmptyLensModel() { 73 + let exif = makeExif(lensMake: "Sigma", lensModel: "") 74 + // Empty lensModel → falls to joined path, but lensModel is empty so only lensMake 75 + XCTAssertEqual(exif.lensName, "Sigma") 76 + } 77 + 78 + // MARK: - settingsLine 79 + 80 + func testSettingsLineAllPresent() { 81 + let exif = makeExif(focalLength: "35mm", fNumber: "f/1.4", exposureTime: "1/250", iso: 400) 82 + XCTAssertEqual(exif.settingsLine, "35mm · f/1.4 · 1/250 · ISO 400") 83 + } 84 + 85 + func testSettingsLinePartial() { 86 + let exif = makeExif(fNumber: "f/2.8", iso: 100) 87 + XCTAssertEqual(exif.settingsLine, "f/2.8 · ISO 100") 88 + } 89 + 90 + func testSettingsLineNilWhenEmpty() { 91 + let exif = makeExif() 92 + XCTAssertNil(exif.settingsLine) 93 + } 94 + 95 + // MARK: - hasDisplayableData 96 + 97 + func testHasDisplayableDataTrue() { 98 + let exif = makeExif(make: "Sony") 99 + XCTAssertTrue(exif.hasDisplayableData) 100 + } 101 + 102 + func testHasDisplayableDataFalse() { 103 + let exif = makeExif() 104 + XCTAssertFalse(exif.hasDisplayableData) 105 + } 106 + }
+132
GrainTests/ProfileDetailViewModelTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + @MainActor 5 + final class ProfileDetailViewModelTests: XCTestCase { 6 + private var client: XRPCClient! 7 + private var vm: ProfileDetailViewModel! 8 + 9 + override func setUp() { 10 + super.setUp() 11 + client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession()) 12 + vm = ProfileDetailViewModel(client: client) 13 + } 14 + 15 + override func tearDown() { 16 + MockURLProtocol.handler = nil 17 + super.tearDown() 18 + } 19 + 20 + // MARK: - Helpers 21 + 22 + private func makeProfile( 23 + did: String = "did:plc:target", 24 + followersCount: Int? = 10, 25 + following: String? = nil 26 + ) -> GrainProfileDetailed { 27 + GrainProfileDetailed( 28 + cid: "cid", 29 + did: did, 30 + handle: "target.test", 31 + followersCount: followersCount, 32 + viewer: ActorViewerState(following: following) 33 + ) 34 + } 35 + 36 + private func makeDummyAuth() -> AuthContext { 37 + // DPoP needs a real key for signing — create one for tests 38 + do { 39 + let key = try CryptoKit.P256.Signing.PrivateKey() 40 + let dpop = DPoP(privateKey: key) 41 + return AuthContext(accessToken: "test-token", dpop: dpop) 42 + } catch { 43 + XCTFail("Failed to create P256 private key: \(error)") 44 + fatalError("Unreachable") 45 + } 46 + } 47 + 48 + // MARK: - toggleFollow (follow) 49 + 50 + func testFollowOptimisticallyUpdatesState() async { 51 + vm.profile = makeProfile(followersCount: 10, following: nil) 52 + TokenStorage.userDID = "did:plc:me" 53 + 54 + // Mock createRecord response 55 + MockURLProtocol.respondWithJSON(""" 56 + {"uri": "at://did:plc:me/social.grain.graph.follow/abc123", "cid": "newcid"} 57 + """) 58 + 59 + await vm.toggleFollow(auth: makeDummyAuth()) 60 + 61 + XCTAssertEqual(vm.profile?.followersCount, 11) 62 + XCTAssertEqual(vm.profile?.viewer?.following, "at://did:plc:me/social.grain.graph.follow/abc123") 63 + } 64 + 65 + func testFollowRollsBackOnFailure() async { 66 + vm.profile = makeProfile(followersCount: 10, following: nil) 67 + TokenStorage.userDID = "did:plc:me" 68 + 69 + MockURLProtocol.respondWithError(statusCode: 500) 70 + 71 + await vm.toggleFollow(auth: makeDummyAuth()) 72 + 73 + // Should roll back to original state 74 + XCTAssertEqual(vm.profile?.followersCount, 10) 75 + XCTAssertNil(vm.profile?.viewer?.following) 76 + } 77 + 78 + // MARK: - toggleFollow (unfollow) 79 + 80 + func testUnfollowOptimisticallyUpdatesState() async { 81 + vm.profile = makeProfile(followersCount: 10, following: "at://did:plc:me/social.grain.graph.follow/xyz") 82 + 83 + MockURLProtocol.respondWithJSON("{}") 84 + 85 + await vm.toggleFollow(auth: makeDummyAuth()) 86 + 87 + XCTAssertEqual(vm.profile?.followersCount, 9) 88 + XCTAssertNil(vm.profile?.viewer?.following) 89 + } 90 + 91 + func testUnfollowRollsBackOnFailure() async { 92 + let followUri = "at://did:plc:me/social.grain.graph.follow/xyz" 93 + vm.profile = makeProfile(followersCount: 10, following: followUri) 94 + 95 + MockURLProtocol.respondWithError(statusCode: 500) 96 + 97 + await vm.toggleFollow(auth: makeDummyAuth()) 98 + 99 + XCTAssertEqual(vm.profile?.followersCount, 10) 100 + XCTAssertEqual(vm.profile?.viewer?.following, followUri) 101 + } 102 + 103 + func testUnfollowClampsCountAtZero() async { 104 + vm.profile = makeProfile(followersCount: 0, following: "at://did:plc:me/social.grain.graph.follow/xyz") 105 + 106 + MockURLProtocol.respondWithJSON("{}") 107 + 108 + await vm.toggleFollow(auth: makeDummyAuth()) 109 + 110 + XCTAssertEqual(vm.profile?.followersCount, 0) 111 + } 112 + 113 + // MARK: - toggleFollow guards 114 + 115 + func testToggleFollowBailsWithoutAuth() async { 116 + vm.profile = makeProfile() 117 + let initialCount = vm.profile?.followersCount 118 + 119 + await vm.toggleFollow(auth: nil) 120 + 121 + XCTAssertEqual(vm.profile?.followersCount, initialCount) 122 + } 123 + 124 + func testToggleFollowBailsWithoutProfile() async { 125 + vm.profile = nil 126 + 127 + await vm.toggleFollow(auth: makeDummyAuth()) 128 + // Should not crash 129 + } 130 + } 131 + 132 + import CryptoKit
+71
GrainTests/StoryStatusCacheTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + @MainActor 5 + final class StoryStatusCacheTests: XCTestCase { 6 + private func makeAuthor(did: String, storyCount: Int = 1) -> GrainStoryAuthor { 7 + GrainStoryAuthor( 8 + profile: GrainProfile(cid: "cid", did: did, handle: "\(did).test"), 9 + storyCount: storyCount, 10 + latestAt: "2024-06-15T12:00:00Z" 11 + ) 12 + } 13 + 14 + // MARK: - update(from:) 15 + 16 + func testUpdatePopulatesCache() { 17 + let cache = StoryStatusCache() 18 + cache.update(from: [makeAuthor(did: "did:plc:alice"), makeAuthor(did: "did:plc:bob")]) 19 + XCTAssertEqual(cache.authorsByDid.count, 2) 20 + } 21 + 22 + func testUpdateReplacesOldData() { 23 + let cache = StoryStatusCache() 24 + cache.update(from: [makeAuthor(did: "did:plc:alice", storyCount: 3)]) 25 + cache.update(from: [makeAuthor(did: "did:plc:bob", storyCount: 1)]) 26 + // After second update, alice should be gone 27 + XCTAssertNil(cache.author(for: "did:plc:alice")) 28 + XCTAssertNotNil(cache.author(for: "did:plc:bob")) 29 + } 30 + 31 + // MARK: - hasStory(for:) 32 + 33 + func testHasStoryReturnsTrue() { 34 + let cache = StoryStatusCache() 35 + cache.update(from: [makeAuthor(did: "did:plc:alice")]) 36 + XCTAssertTrue(cache.hasStory(for: "did:plc:alice")) 37 + } 38 + 39 + func testHasStoryReturnsFalse() { 40 + let cache = StoryStatusCache() 41 + XCTAssertFalse(cache.hasStory(for: "did:plc:nobody")) 42 + } 43 + 44 + // MARK: - author(for:) 45 + 46 + func testAuthorReturnsCorrectAuthor() { 47 + let cache = StoryStatusCache() 48 + cache.update(from: [makeAuthor(did: "did:plc:alice", storyCount: 5)]) 49 + let author = cache.author(for: "did:plc:alice") 50 + XCTAssertEqual(author?.storyCount, 5) 51 + XCTAssertEqual(author?.profile.handle, "did:plc:alice.test") 52 + } 53 + 54 + func testAuthorReturnsNilForUnknown() { 55 + let cache = StoryStatusCache() 56 + XCTAssertNil(cache.author(for: "did:plc:unknown")) 57 + } 58 + 59 + // MARK: - didsWithStories 60 + 61 + func testDidsWithStoriesReturnsCorrectSet() { 62 + let cache = StoryStatusCache() 63 + cache.update(from: [makeAuthor(did: "did:plc:a"), makeAuthor(did: "did:plc:b")]) 64 + XCTAssertEqual(cache.didsWithStories, Set(["did:plc:a", "did:plc:b"])) 65 + } 66 + 67 + func testDidsWithStoriesEmptyByDefault() { 68 + let cache = StoryStatusCache() 69 + XCTAssertTrue(cache.didsWithStories.isEmpty) 70 + } 71 + }
+118
GrainTests/ViewedStoryStorageTests.swift
··· 1 + @testable import Grain 2 + import XCTest 3 + 4 + @MainActor 5 + final class ViewedStoryStorageTests: XCTestCase { 6 + private var storage: ViewedStoryStorage! 7 + 8 + override func setUp() { 9 + super.setUp() 10 + storage = ViewedStoryStorage() 11 + } 12 + 13 + override func tearDown() { 14 + storage = nil 15 + super.tearDown() 16 + } 17 + 18 + // MARK: - Helpers 19 + 20 + private struct StubStory: StoryIdentifiable { 21 + let storyUri: String 22 + } 23 + 24 + private func makeStories(_ uris: [String]) -> [StubStory] { 25 + uris.map { StubStory(storyUri: $0) } 26 + } 27 + 28 + // MARK: - markViewed / isViewed 29 + 30 + func testMarkViewedTracksUri() { 31 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 32 + XCTAssertTrue(storage.isViewed(uri: "at://story/1")) 33 + XCTAssertFalse(storage.isViewed(uri: "at://story/2")) 34 + } 35 + 36 + func testMarkViewedMultipleStories() { 37 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 38 + storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z") 39 + XCTAssertTrue(storage.isViewed(uri: "at://story/1")) 40 + XCTAssertTrue(storage.isViewed(uri: "at://story/2")) 41 + } 42 + 43 + // MARK: - hasViewedAll 44 + 45 + func testHasViewedAllReturnsTrueWhenLatestViewed() { 46 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z") 47 + XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")) 48 + } 49 + 50 + func testHasViewedAllReturnsTrueWhenViewedNewer() { 51 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T15:00:00.000Z") 52 + XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")) 53 + } 54 + 55 + func testHasViewedAllReturnsFalseWhenNotViewed() { 56 + XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")) 57 + } 58 + 59 + func testHasViewedAllReturnsFalseWhenOlderViewed() { 60 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 61 + XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")) 62 + } 63 + 64 + func testHasViewedAllTracksMostRecentTimestamp() { 65 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 66 + storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z") 67 + // Viewing an older story doesn't regress the timestamp 68 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 69 + XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")) 70 + } 71 + 72 + func testHasViewedAllIndependentPerAuthor() { 73 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T14:00:00.000Z") 74 + XCTAssertTrue(storage.hasViewedAll(authorDid: "did:plc:alice", latestAt: "2024-06-15T14:00:00.000Z")) 75 + XCTAssertFalse(storage.hasViewedAll(authorDid: "did:plc:bob", latestAt: "2024-06-15T14:00:00.000Z")) 76 + } 77 + 78 + // MARK: - firstUnviewedIndex 79 + 80 + func testFirstUnviewedIndexReturnsFirstUnviewed() { 81 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 82 + storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z") 83 + let stories = makeStories(["at://story/1", "at://story/2", "at://story/3", "at://story/4"]) 84 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 2) 85 + } 86 + 87 + func testFirstUnviewedIndexReturnsZeroWhenNoneViewed() { 88 + let stories = makeStories(["at://story/1", "at://story/2", "at://story/3"]) 89 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 90 + } 91 + 92 + func testFirstUnviewedIndexReturnsZeroWhenAllViewed() { 93 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 94 + storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T13:00:00.000Z") 95 + let stories = makeStories(["at://story/1", "at://story/2"]) 96 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 97 + } 98 + 99 + func testFirstUnviewedIndexSkipsViewedInMiddle() { 100 + // Only the middle story is viewed — should return index 0 101 + storage.markViewed(uri: "at://story/2", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 102 + let stories = makeStories(["at://story/1", "at://story/2", "at://story/3"]) 103 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 104 + } 105 + 106 + func testFirstUnviewedIndexWithSingleStory() { 107 + let stories = makeStories(["at://story/1"]) 108 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 109 + 110 + storage.markViewed(uri: "at://story/1", authorDid: "did:plc:alice", createdAt: "2024-06-15T12:00:00.000Z") 111 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 112 + } 113 + 114 + func testFirstUnviewedIndexEmptyArray() { 115 + let stories: [StubStory] = [] 116 + XCTAssertEqual(storage.firstUnviewedIndex(in: stories), 0) 117 + } 118 + }
+12 -2
README.md
··· 8 8 - iOS 26.0+ 9 9 - [XcodeGen](https://github.com/yonaskolb/XcodeGen) 10 10 - [just](https://github.com/casey/just) (optional, for task running) 11 + - [xcbeautify](https://github.com/cpisciotta/xcbeautify) (for formatted build/test output) 12 + - [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) (code formatter) 13 + - [SwiftLint](https://github.com/realm/SwiftLint) (linter) 11 14 12 15 ## Setup 13 16 14 17 ```bash 15 - brew install xcodegen just 18 + brew install xcodegen just xcbeautify swiftformat swiftlint 16 19 xcodegen generate 17 20 open Grain.xcodeproj 18 21 ``` ··· 24 27 ```bash 25 28 just generate # Regenerate Xcode project from project.yml 26 29 just build # Build for simulator 27 - just install # Build + install to booted simulator 30 + just sim-local # Build + install + launch on simulator (local/dev API) 31 + just sim # Build + install + launch on simulator (production API) 32 + just test # Run tests 33 + just format # Check formatting (list unformatted files) 34 + just format-fix # Fix formatting in-place 35 + just lint # Lint Swift code 36 + just lint-fix # Fix lint violations 37 + just device ID # Build + install to a plugged-in iOS device 28 38 just release # Bump build, archive, upload to App Store Connect 29 39 ``` 30 40
+68
docs/development.md
··· 1 + # Development Setup 2 + 3 + ## Prerequisites 4 + 5 + ```bash 6 + brew install xcodegen just xcbeautify swiftformat swiftlint 7 + ``` 8 + 9 + ## First-time setup 10 + 11 + 1. **Copy the env file** 12 + ```bash 13 + cp .env.example .env 14 + ``` 15 + Fill in your `APPLE_TEAM_ID` (find it at developer.apple.com → Account → Membership). 16 + 17 + 2. **Generate the Xcode project** 18 + ```bash 19 + just generate 20 + ``` 21 + 22 + 3. **Run on simulator** 23 + ```bash 24 + just sim 25 + ``` 26 + 27 + ## Building on a real device 28 + 29 + Plug in your iPhone, then: 30 + ```bash 31 + just device <your-device-udid> 32 + ``` 33 + 34 + Find your UDID: 35 + ```bash 36 + xcrun xctrace list devices 37 + ``` 38 + 39 + > **Note:** Device builds require your Apple ID to have certificate access on the team. If you're on an individual developer account, team members need to use TestFlight or Xcode directly with automatic signing. 40 + 41 + ## Deploying a build 42 + 43 + ```bash 44 + just release 45 + ``` 46 + 47 + This bumps the build number, archives, and uploads to App Store Connect. Builds are automatically distributed to the **Devs** internal TestFlight group after processing. 48 + 49 + ## Environment variables 50 + 51 + | Variable | Description | 52 + |---|---| 53 + | `APPLE_TEAM_ID` | Apple Developer Team ID used for signing device builds and releases | 54 + 55 + `just` loads `.env` automatically. No shell changes needed. 56 + 57 + ## Common commands 58 + 59 + | Command | Description | 60 + |---|---| 61 + | `just generate` | Regenerate Xcode project from `project.yml` | 62 + | `just sim` | Build + run on booted simulator (production API) | 63 + | `just sim-local` | Build + run on booted simulator (local API) | 64 + | `just device <udid>` | Build + install to plugged-in iPhone | 65 + | `just test` | Run tests | 66 + | `just format-fix` | Auto-fix formatting | 67 + | `just lint-fix` | Auto-fix lint violations | 68 + | `just release` | Bump build, archive, upload to App Store Connect |
+58 -8
justfile
··· 1 1 set quiet 2 + set dotenv-load 3 + 4 + # Simulator build settings — skip code signing (no team account needed) 5 + sim_sign := 'CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=""' 6 + 7 + # Apple Developer Team ID (override with APPLE_TEAM_ID env var) 8 + team_id := env_var_or_default("APPLE_TEAM_ID", "54P9BCDR92") 2 9 3 10 # Default: list available recipes 4 11 default: ··· 7 14 # Regenerate Xcode project from project.yml 8 15 generate: 9 16 xcodegen generate 17 + git config core.hooksPath .githooks 10 18 11 - # Build for simulator 19 + # Build for simulator (production API) 12 20 build: 13 - xcodebuild build -scheme Grain -destination 'generic/platform=iOS Simulator' -quiet 21 + set -o pipefail && xcodebuild build -scheme Grain -destination 'generic/platform=iOS Simulator' SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) PRODUCTION_API' {{sim_sign}} 2>&1 | xcbeautify 14 22 15 - # Install to booted simulator 16 - install: build 17 - xcrun simctl install booted ~/Library/Developer/Xcode/DerivedData/Grain-gnyldzofconssnfxpuxpctdsmehu/Build/Products/Debug-iphonesimulator/Grain.app 23 + # Build for simulator (local/dev API) 24 + build-local: 25 + set -o pipefail && xcodebuild build -scheme Grain -destination 'generic/platform=iOS Simulator' {{sim_sign}} 2>&1 | xcbeautify 26 + 27 + # Build + install + launch on simulator (local/dev API) 28 + sim-local: build-local 29 + #!/usr/bin/env bash 30 + set -euo pipefail 31 + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Grain-*/Build/Products/Debug-iphonesimulator -name "Grain.app" -type d | head -1) 32 + xcrun simctl install booted "$APP_PATH" 33 + xcrun simctl launch booted social.grain.grain 34 + echo "Installed and launched on simulator (local/dev API)" 35 + 36 + # Build + install + launch on simulator (production API — grain.social) 37 + sim: 38 + #!/usr/bin/env bash 39 + set -euo pipefail 40 + set -o pipefail && xcodebuild build -scheme Grain -destination 'generic/platform=iOS Simulator' SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) PRODUCTION_API' {{sim_sign}} 2>&1 | xcbeautify 41 + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Grain-*/Build/Products/Debug-iphonesimulator -name "Grain.app" -type d | head -1) 42 + xcrun simctl install booted "$APP_PATH" 43 + xcrun simctl launch booted social.grain.grain 44 + echo "Installed and launched on simulator (grain.social)" 45 + 46 + # Run tests 47 + test: 48 + set -o pipefail && xcodebuild test -scheme Grain -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) PRODUCTION_API' 2>&1 | xcbeautify 49 + 50 + # Check formatting (list unformatted files) 51 + format: 52 + swiftformat Grain GrainTests --lint 53 + 54 + # Fix formatting in-place 55 + format-fix: 56 + swiftformat Grain GrainTests 57 + 58 + # Lint Swift code 59 + lint: 60 + swiftlint lint Grain GrainTests 61 + 62 + # Fix lint violations 63 + lint-fix: 64 + swiftlint lint --fix Grain GrainTests 65 + 66 + # Legacy alias for sim-local 67 + install: sim-local 18 68 19 69 # Build and install to a plugged-in iOS device 20 70 device device_id: 21 71 #!/usr/bin/env bash 22 72 set -euo pipefail 23 73 echo "Building for device {{device_id}}..." 24 - xcodebuild build -scheme Grain -destination 'platform=iOS,id={{device_id}}' CODE_SIGN_STYLE=Automatic -allowProvisioningUpdates -quiet 74 + set -o pipefail && xcodebuild build -scheme Grain -destination 'platform=iOS,id={{device_id}}' CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM={{team_id}} -allowProvisioningUpdates 2>&1 | xcbeautify 25 75 APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Grain-*/Build/Products/Debug-iphoneos -name "Grain.app" -type d | head -1) 26 76 echo "Installing $APP_PATH..." 27 77 xcrun devicectl device install app --device {{device_id}} "$APP_PATH" ··· 38 88 echo "Bumped build number: $current → $next" 39 89 xcodegen generate 40 90 echo "Archiving..." 41 - xcodebuild archive -scheme Grain -destination 'generic/platform=iOS' -archivePath /tmp/Grain.xcarchive CODE_SIGN_STYLE=Automatic -allowProvisioningUpdates -quiet 91 + set -o pipefail && xcodebuild archive -scheme Grain -destination 'generic/platform=iOS' -archivePath /tmp/Grain.xcarchive CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM={{team_id}} -allowProvisioningUpdates 2>&1 | xcbeautify 42 92 echo "Uploading to App Store Connect..." 43 93 cat > /tmp/ExportOptions.plist << 'PLIST' 44 94 <?xml version="1.0" encoding="UTF-8"?> ··· 50 100 <key>destination</key> 51 101 <string>upload</string> 52 102 <key>teamID</key> 53 - <string>YN68LN9T7Z</string> 103 + <string>{{team_id}}</string> 54 104 </dict> 55 105 </plist> 56 106 PLIST
+7 -3
project.yml
··· 9 9 settings: 10 10 base: 11 11 SWIFT_VERSION: "6.0" 12 - DEVELOPMENT_TEAM: YN68LN9T7Z 12 + DEVELOPMENT_TEAM: 54P9BCDR92 13 13 14 14 packages: 15 15 Nuke: ··· 41 41 PRODUCT_BUNDLE_IDENTIFIER: social.grain.grain 42 42 MARKETING_VERSION: "1.0.0" 43 43 CURRENT_PROJECT_VERSION: "38" 44 + CODE_SIGN_STYLE: Automatic 45 + DEVELOPMENT_ASSET_PATHS: "\"$(SRCROOT)/Grain/Preview Content\"" 44 46 dependencies: 45 47 - package: Nuke 46 48 product: Nuke ··· 63 65 - UIInterfaceOrientationLandscapeLeft 64 66 - UIInterfaceOrientationLandscapeRight 65 67 NSCameraUsageDescription: Grain needs camera access to take photos for your stories and galleries. 66 - NSAppTransportSecurity: 67 - NSAllowsLocalNetworking: true 68 + NSPhotoLibraryUsageDescription: Grain needs photo library access to select photos for your galleries and stories. 68 69 UIAppFonts: 69 70 - Syne-Variable.ttf 70 71 UILaunchScreen: {} ··· 74 75 platform: iOS 75 76 sources: 76 77 - path: GrainTests 78 + settings: 79 + base: 80 + GENERATE_INFOPLIST_FILE: YES 77 81 dependencies: 78 82 - target: Grain