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: unify profile context menus and rewrite avatar overlay

- New profileContextMenu(handle:hasStory:onViewProfile:onViewStory:)
modifier in Components/ProfileContextMenu.swift — replaces ad-hoc
.onLongPressGesture rows in FollowListView and StoryStripView with
a consistent system contextMenu (View Story / View Profile / Copy
Username / Share Profile). The NotificationsView and SearchView
callers landed in the previous commit; this one adds the modifier
they were already referencing.
- ProfileView: handle subtitle gains its own contextMenu for Copy
Handle, Copy DID, and View Profile Photo. AvatarOverlay is
rewritten on top of ZoomableImage with pinch-to-zoom, drag-to-
dismiss, and a Nuke Circle processor so the loaded image is
already round when it appears. The .if helper from
ProfileContextMenu lets self vs. non-self avatars pick different
gesture branches without stacking both.
- SettingsView: Handle and DID rows become Menus with a Copy
action, backed by a shared CopiedCheckmarkToast capsule (also used
by ProfileView) and haptic feedback.
- ZoomableImage: only register the double-tap recognizer when the
caller passes onDoubleTap, so the avatar overlay's drag-to-dismiss
doesn't fight a recognizer with no handler.

authored by

Hima Aramona and committed by
Chad Miller
6c6936e1 728a2f6b

+372 -76
+99
Grain/Views/Components/ProfileContextMenu.swift
··· 1 + import SwiftUI 2 + 3 + extension View { 4 + /// Long-press context menu for profile avatars/rows. 5 + /// Omit `onViewProfile` when already on the profile page. 6 + /// Pass `hasStory: true` and a non-nil `onViewStory` to include "View Story". 7 + func profileContextMenu( 8 + handle: String?, 9 + hasStory: Bool, 10 + onViewProfile: (() -> Void)? = nil, 11 + onViewStory: (() -> Void)? = nil, 12 + onAddStory: (() -> Void)? = nil, 13 + onViewPhoto: (() -> Void)? = nil, 14 + showSharingActions: Bool = true 15 + ) -> some View { 16 + contextMenu { 17 + profileMenuItems( 18 + handle: handle, 19 + hasStory: hasStory, 20 + onViewProfile: onViewProfile, 21 + onViewStory: onViewStory, 22 + onAddStory: onAddStory, 23 + onViewPhoto: onViewPhoto, 24 + showSharingActions: showSharingActions 25 + ) 26 + } 27 + } 28 + 29 + /// Variant with an explicit preview rendered directly in the system overlay — 30 + /// use when the default "lift" animation is clipped by the surrounding layout. 31 + func profileContextMenu( 32 + handle: String?, 33 + hasStory: Bool, 34 + onViewProfile: (() -> Void)? = nil, 35 + onViewStory: (() -> Void)? = nil, 36 + onAddStory: (() -> Void)? = nil, 37 + onViewPhoto: (() -> Void)? = nil, 38 + showSharingActions: Bool = true, 39 + @ViewBuilder preview: @escaping () -> some View 40 + ) -> some View { 41 + contextMenu { 42 + profileMenuItems( 43 + handle: handle, 44 + hasStory: hasStory, 45 + onViewProfile: onViewProfile, 46 + onViewStory: onViewStory, 47 + onAddStory: onAddStory, 48 + onViewPhoto: onViewPhoto, 49 + showSharingActions: showSharingActions 50 + ) 51 + } preview: { 52 + preview() 53 + } 54 + } 55 + 56 + /// Conditionally apply a view modifier without duplicating the entire view tree. 57 + @ViewBuilder 58 + func `if`(_ condition: Bool, transform: (Self) -> some View) -> some View { 59 + if condition { transform(self) } else { self } 60 + } 61 + } 62 + 63 + @ViewBuilder 64 + private func profileMenuItems( 65 + handle: String?, 66 + hasStory: Bool, 67 + onViewProfile: (() -> Void)?, 68 + onViewStory: (() -> Void)?, 69 + onAddStory: (() -> Void)?, 70 + onViewPhoto: (() -> Void)?, 71 + showSharingActions: Bool = true 72 + ) -> some View { 73 + if hasStory, let onViewStory { 74 + Button(action: onViewStory) { 75 + Label("View Story", systemImage: "play.circle") 76 + } 77 + } 78 + if let onAddStory { 79 + Button(action: onAddStory) { 80 + Label("New Story", systemImage: "plus.circle") 81 + } 82 + } 83 + if let onViewProfile { 84 + Button(action: onViewProfile) { 85 + Label("View Profile", systemImage: "person.circle") 86 + } 87 + } 88 + if let onViewPhoto { 89 + Button(action: onViewPhoto) { 90 + Label("View Profile Photo", systemImage: "person.crop.circle.fill") 91 + } 92 + } 93 + if showSharingActions, let handle { 94 + Divider() 95 + ShareLink(item: URL(string: "https://grain.social/profile/\(handle)") ?? URL(string: "https://grain.social")!) { 96 + Label("Share Profile", systemImage: "square.and.arrow.up") 97 + } 98 + } 99 + }
+28 -5
Grain/Views/Components/ZoomableImage.swift
··· 66 66 let zoomState: ImageZoomState 67 67 var onBegan: (UnitPoint, CGRect) -> Void 68 68 var onEnded: () -> Void 69 + var onSingleTap: (() -> Void)? 69 70 var onDoubleTap: ((CGPoint) -> Void)? 70 71 71 72 func makeUIView(context: Context) -> UIView { ··· 82 83 pan.delegate = context.coordinator 83 84 view.addGestureRecognizer(pan) 84 85 85 - let doubleTap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap)) 86 - doubleTap.numberOfTapsRequired = 2 87 - view.addGestureRecognizer(doubleTap) 86 + var doubleTapRecognizer: UITapGestureRecognizer? 87 + if onDoubleTap != nil { 88 + let doubleTap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap)) 89 + doubleTap.numberOfTapsRequired = 2 90 + view.addGestureRecognizer(doubleTap) 91 + doubleTapRecognizer = doubleTap 92 + } 93 + 94 + if onSingleTap != nil { 95 + let singleTap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleSingleTap)) 96 + singleTap.numberOfTapsRequired = 1 97 + singleTap.numberOfTouchesRequired = 1 98 + if let doubleTap = doubleTapRecognizer { 99 + singleTap.require(toFail: doubleTap) 100 + } 101 + view.addGestureRecognizer(singleTap) 102 + } 88 103 89 104 return view 90 105 } ··· 139 154 default: 140 155 break 141 156 } 157 + } 158 + 159 + @objc func handleSingleTap(_: UITapGestureRecognizer) { 160 + parent.onSingleTap?() 142 161 } 143 162 144 163 @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { ··· 179 198 /// lazily-loaded hi-res version here so the normal display path uses a 180 199 /// lighter image while zoom gets more detail if it's ready. 181 200 var zoomImage: UIImage? 201 + var onSingleTap: (() -> Void)? 182 202 var onDoubleTap: ((CGPoint) -> Void)? 183 203 @Environment(ImageZoomState.self) private var zoomState: ImageZoomState? 184 204 ··· 191 211 /// identity-based equality and leave the base image visible behind the overlay. 192 212 @State private var isZoomingMe = false 193 213 194 - init(url: String, thumbURL: String? = nil, aspectRatio: CGFloat, onDoubleTap: ((CGPoint) -> Void)? = nil) { 214 + init(url: String, thumbURL: String? = nil, aspectRatio: CGFloat, onSingleTap: (() -> Void)? = nil, onDoubleTap: ((CGPoint) -> Void)? = nil) { 195 215 source = .url(url, thumbURL: thumbURL) 196 216 self.aspectRatio = aspectRatio 217 + self.onSingleTap = onSingleTap 197 218 self.onDoubleTap = onDoubleTap 198 219 } 199 220 200 - init(localImage: UIImage, aspectRatio: CGFloat, zoomImage: UIImage? = nil, onDoubleTap: ((CGPoint) -> Void)? = nil) { 221 + init(localImage: UIImage, aspectRatio: CGFloat, zoomImage: UIImage? = nil, onSingleTap: (() -> Void)? = nil, onDoubleTap: ((CGPoint) -> Void)? = nil) { 201 222 source = .local(localImage) 202 223 self.aspectRatio = aspectRatio 203 224 self.zoomImage = zoomImage 225 + self.onSingleTap = onSingleTap 204 226 self.onDoubleTap = onDoubleTap 205 227 } 206 228 ··· 255 277 isZoomingMe = true 256 278 }, 257 279 onEnded: { scheduleSnapBack() }, 280 + onSingleTap: onSingleTap, 258 281 onDoubleTap: onDoubleTap 259 282 ) 260 283 }
+10 -3
Grain/Views/Profile/FollowListView.swift
··· 120 120 selectedProfileDid = item.did 121 121 } 122 122 } 123 - .onLongPressGesture { 124 - selectedProfileDid = item.did 125 - } 123 + .profileContextMenu( 124 + handle: item.handle, 125 + hasStory: storyStatusCache.hasStory(for: item.did), 126 + onViewProfile: { selectedProfileDid = item.did }, 127 + onViewStory: { 128 + if let author = storyStatusCache.author(for: item.did) { 129 + cardStoryAuthor = author 130 + } 131 + } 132 + ) 126 133 VStack(alignment: .leading, spacing: 2) { 127 134 HStack(spacing: 4) { 128 135 if let displayName = item.displayName, !displayName.isEmpty {
+165 -64
Grain/Views/Profile/ProfileView.swift
··· 1 + import Nuke 1 2 import NukeUI 3 + import OSLog 2 4 import SwiftUI 5 + 6 + private let avatarOverlaySignposter = OSSignposter(subsystem: "social.grain.grain", category: "AvatarOverlay") 7 + private let avatarOverlayLogger = Logger(subsystem: "social.grain.grain", category: "AvatarOverlay") 8 + private let profileLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch") 3 9 4 10 enum ProfileViewMode: String, CaseIterable { 5 11 case grid, favorites, stories ··· 22 28 @State private var viewMode: ProfileViewMode = .grid 23 29 @State private var zoomState = ImageZoomState() 24 30 @State private var cardStoryAuthor: GrainStoryAuthor? 25 - @State private var avatarPressed = false 26 31 let client: XRPCClient 27 32 @State private var selectedArchivedStory: GrainStory? 28 33 let actor: String 29 34 var isRoot = false 35 + @State private var showCopiedToast = false 30 36 31 37 /// Resolved DID from the loaded profile, or the original actor identifier 32 38 private var did: String { ··· 35 41 36 42 init(client: XRPCClient, did: String, isRoot: Bool = false) { 37 43 self.client = client 44 + let _spid = profileLaunchSignposter.makeSignpostID() 45 + let _state = profileLaunchSignposter.beginInterval("ProfileViewModelInit", id: _spid) 38 46 _viewModel = State(initialValue: ProfileDetailViewModel(client: client)) 47 + profileLaunchSignposter.endInterval("ProfileViewModelInit", _state) 39 48 actor = did 40 49 self.isRoot = isRoot 41 50 } 42 51 43 52 var body: some View { 53 + let _ = profileLaunchSignposter.emitEvent("ProfileViewBodyBegin") 44 54 if isRoot { 45 55 NavigationStack { 46 56 profileContent 47 57 } 48 58 } else { 49 59 profileContent 60 + } 61 + } 62 + 63 + @ViewBuilder 64 + private func avatarButton(profile: GrainProfileDetailed) -> some View { 65 + let hasStory = !viewModel.stories.isEmpty 66 + StoryRingView( 67 + hasStory: hasStory, 68 + viewed: did != auth.userDID && viewedStories.hasViewedAll(authorDid: did, latestAt: viewModel.stories.last?.createdAt ?? ""), 69 + size: 80 70 + ) { 71 + AvatarView(url: profile.avatar, size: 80) 72 + .liquidGlassCircle() 73 + } 74 + .overlay(alignment: .bottomTrailing) { 75 + if did == auth.userDID { 76 + Image(systemName: "plus.circle.fill") 77 + .font(.system(size: 22)) 78 + .foregroundStyle(.white, Color("AccentColor")) 79 + .offset(x: 4, y: 4) 80 + } 81 + } 82 + .padding(4) 83 + .contentShape(Circle()) 84 + .onTapGesture { 85 + if did == auth.userDID { 86 + if hasStory { showStoryViewer = true } else { showStoryCreate = true } 87 + } else { 88 + if hasStory { showStoryViewer = true } 89 + else if profile.avatar != nil { showAvatarOverlay = true } 90 + } 91 + } 92 + .profileContextMenu( 93 + handle: profile.handle, 94 + hasStory: hasStory, 95 + onViewStory: hasStory ? { showStoryViewer = true } : nil, 96 + onAddStory: did == auth.userDID ? { showStoryCreate = true } : nil, 97 + onViewPhoto: profile.avatar != nil ? { showAvatarOverlay = true } : nil, 98 + showSharingActions: false 99 + ) { 100 + StoryRingView(hasStory: hasStory, viewed: false, size: 120) { 101 + AvatarView(url: profile.avatar, size: 120) 102 + } 103 + .padding(6) 50 104 } 51 105 } 52 106 ··· 56 110 VStack(spacing: 12) { 57 111 // Avatar + stats row 58 112 HStack(alignment: .center, spacing: 16) { 59 - StoryRingView(hasStory: !viewModel.stories.isEmpty, viewed: did != auth.userDID && viewedStories.hasViewedAll(authorDid: did, latestAt: viewModel.stories.last?.createdAt ?? ""), size: 80) { 60 - AvatarView(url: profile.avatar, size: 80) 61 - .liquidGlassCircle() 62 - } 63 - .overlay(alignment: .bottomTrailing) { 64 - if did == auth.userDID { 65 - Image(systemName: "plus.circle.fill") 66 - .font(.system(size: 22)) 67 - .foregroundStyle(.white, Color("AccentColor")) 68 - .offset(x: 4, y: 4) 69 - } 70 - } 71 - .scaleEffect(avatarPressed ? 1.08 : 1.0) 72 - .animation(.spring(response: 0.2, dampingFraction: 0.6), value: avatarPressed) 73 - .contentShape(Circle()) 74 - .onTapGesture { 75 - if did == auth.userDID { 76 - if !viewModel.stories.isEmpty { 77 - showStoryViewer = true 78 - } else { 79 - showStoryCreate = true 80 - } 81 - } else { 82 - if !viewModel.stories.isEmpty { 83 - showStoryViewer = true 84 - } else if profile.avatar != nil { 85 - showAvatarOverlay = true 86 - } 87 - } 88 - } 89 - .onLongPressGesture(minimumDuration: 0.5) { 90 - if did == auth.userDID { 91 - UIImpactFeedbackGenerator(style: .medium).impactOccurred() 92 - showStoryCreate = true 93 - } 94 - } 95 - .simultaneousGesture( 96 - DragGesture(minimumDistance: 0) 97 - .onChanged { _ in if !avatarPressed { avatarPressed = true } } 98 - .onEnded { _ in avatarPressed = false } 99 - ) 113 + avatarButton(profile: profile) 100 114 101 115 if !viewModel.isBlockHidden { 102 116 HStack(spacing: 0) { ··· 139 153 Text("@\(profile.handle)") 140 154 .font(.subheadline) 141 155 .foregroundStyle(.secondary) 156 + .contextMenu { 157 + if profile.avatar != nil { 158 + Button { showAvatarOverlay = true } label: { 159 + Label("View Profile Photo", systemImage: "person.crop.circle") 160 + } 161 + Divider() 162 + } 163 + Button { copyText("@\(profile.handle)") } label: { 164 + Label("Copy Handle", systemImage: "doc.on.doc") 165 + } 166 + Button { copyText(did) } label: { 167 + Label("Copy DID", systemImage: "number") 168 + } 169 + } 142 170 } 143 171 144 172 if viewModel.isBlockHidden { ··· 316 344 .navigationBarTitleDisplayMode(.inline) 317 345 .toolbar { 318 346 if did == auth.userDID { 347 + if let handle = viewModel.profile?.handle, 348 + let profileURL = URL(string: "https://grain.social/profile/\(handle)") 349 + { 350 + ToolbarItem(placement: .topBarTrailing) { 351 + ShareLink(item: profileURL) { 352 + Image(systemName: "square.and.arrow.up") 353 + } 354 + .tint(.primary) 355 + } 356 + } 319 357 ToolbarItem(placement: .topBarTrailing) { 320 358 NavigationLink { 321 359 SettingsView(client: client) ··· 324 362 } 325 363 .tint(.primary) 326 364 } 327 - } else if viewModel.profile != nil { 365 + } else if let profile = viewModel.profile { 328 366 ToolbarItem(placement: .topBarTrailing) { 329 367 Menu { 368 + if let profileURL = URL(string: "https://grain.social/profile/\(profile.handle)") { 369 + ShareLink(item: profileURL) { 370 + Label("Share Profile", systemImage: "square.and.arrow.up") 371 + } 372 + Button { 373 + UIPasteboard.general.string = profile.handle 374 + } label: { 375 + Label("Copy Username", systemImage: "at") 376 + } 377 + Divider() 378 + } 330 379 if !viewModel.isBlockHidden { 331 - Button(role: viewModel.profile?.viewer?.muted == true ? nil : .destructive) { 380 + Button(role: profile.viewer?.muted == true ? nil : .destructive) { 332 381 Task { await viewModel.toggleMute(auth: auth.authContext()) } 333 382 } label: { 334 383 Label( 335 - viewModel.profile?.viewer?.muted == true ? "Unmute" : "Mute", 336 - systemImage: viewModel.profile?.viewer?.muted == true ? "speaker.wave.2" : "speaker.slash" 384 + profile.viewer?.muted == true ? "Unmute" : "Mute", 385 + systemImage: profile.viewer?.muted == true ? "speaker.wave.2" : "speaker.slash" 337 386 ) 338 387 } 339 388 } 340 - Button(role: viewModel.profile?.viewer?.blocking != nil ? nil : .destructive) { 389 + Button(role: profile.viewer?.blocking != nil ? nil : .destructive) { 341 390 Task { await viewModel.toggleBlock(auth: auth.authContext()) } 342 391 } label: { 343 392 Label( 344 - viewModel.profile?.viewer?.blocking != nil ? "Unblock" : "Block", 345 - systemImage: viewModel.profile?.viewer?.blocking != nil ? "circle" : "nosign" 393 + profile.viewer?.blocking != nil ? "Unblock" : "Block", 394 + systemImage: profile.viewer?.blocking != nil ? "circle" : "nosign" 346 395 ) 347 396 } 348 397 } label: { ··· 457 506 Button("OK", role: .cancel) {} 458 507 } message: { 459 508 Text("Please sign out and back in to enable blocking. This is a one-time step after the update.") 509 + } 510 + .overlay(alignment: .center) { 511 + if showCopiedToast { CopiedCheckmarkToast() } 512 + } 513 + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 514 + .sensoryFeedback(.impact(weight: .medium), trigger: showCopiedToast) 515 + } 516 + 517 + private func copyText(_ text: String) { 518 + UIPasteboard.general.string = text 519 + showCopiedToast = true 520 + Task { 521 + try? await Task.sleep(for: .seconds(2)) 522 + showCopiedToast = false 460 523 } 461 524 } 462 525 ··· 766 829 } 767 830 } 768 831 769 - private struct AvatarOverlay: View { 832 + struct AvatarOverlay: View { 770 833 let url: String 771 834 let onDismiss: () -> Void 772 835 836 + @State private var zoomState = ImageZoomState() 837 + @State private var dragOffset: CGFloat = 0 838 + @State private var circularImage: UIImage? 839 + 840 + private var backgroundOpacity: Double { 841 + guard !zoomState.showOverlay else { return 0.92 } 842 + return max(0, 0.92 - abs(dragOffset) / 250) 843 + } 844 + 773 845 var body: some View { 774 846 ZStack { 775 - Color.black.opacity(0.85) 847 + Color.black 848 + .opacity(backgroundOpacity) 776 849 .ignoresSafeArea() 777 - .onTapGesture { onDismiss() } 778 850 779 - LazyImage(url: URL(string: url)) { state in 780 - if let image = state.image { 781 - image 782 - .resizable() 783 - .scaledToFit() 784 - .clipShape(.circle) 785 - .padding(40) 786 - } else { 787 - ProgressView() 788 - } 851 + if let image = circularImage { 852 + ZoomableImage(localImage: image, aspectRatio: 1.0, onSingleTap: { 853 + guard !zoomState.showOverlay else { return } 854 + onDismiss() 855 + }) 856 + .padding(32) 857 + .offset(y: dragOffset) 858 + } else { 859 + ProgressView() 789 860 } 790 861 } 791 - .transition(.opacity) 792 - .animation(.easeInOut(duration: 0.2), value: true) 862 + .gesture( 863 + DragGesture() 864 + .onChanged { val in 865 + guard !zoomState.showOverlay else { return } 866 + dragOffset = val.translation.height 867 + } 868 + .onEnded { val in 869 + let shouldDismiss = !zoomState.showOverlay && ( 870 + abs(val.translation.height) > 80 || 871 + abs(val.predictedEndTranslation.height) > 150 872 + ) 873 + if shouldDismiss { 874 + onDismiss() 875 + } else { 876 + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { 877 + dragOffset = 0 878 + } 879 + } 880 + } 881 + ) 882 + .environment(zoomState) 883 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 884 + .task { 885 + guard let imageURL = URL(string: url) else { return } 886 + let spid = avatarOverlaySignposter.makeSignpostID() 887 + let state = avatarOverlaySignposter.beginInterval("LoadCircularAvatar", id: spid, "url=\(url)") 888 + avatarOverlayLogger.debug("[LoadCircularAvatar] begin url=\(url)") 889 + let request = ImageRequest(url: imageURL, processors: [ImageProcessors.Circle()]) 890 + circularImage = try? await ImagePipeline.shared.image(for: request) 891 + avatarOverlayLogger.debug("[LoadCircularAvatar] end success=\(circularImage != nil)") 892 + avatarOverlaySignposter.endInterval("LoadCircularAvatar", state, "success=\(circularImage != nil)") 893 + } 793 894 } 794 895 } 795 896
+56 -3
Grain/Views/Settings/SettingsView.swift
··· 10 10 @State private var includeLocation = true 11 11 @State private var hasLoadedPrefs = false 12 12 @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 13 + @State private var showCopiedToast = false 13 14 14 15 var body: some View { 15 16 List { 16 17 Section("Account") { 17 18 if let handle = auth.userHandle { 18 - LabeledContent("Handle", value: "@\(handle)") 19 + Menu { 20 + Button { copyText("@\(handle)") } label: { 21 + Label("Copy", systemImage: "doc.on.doc") 22 + } 23 + } label: { 24 + LabeledContent("Handle", value: "@\(handle)") 25 + } 26 + .foregroundStyle(.primary) 19 27 } 20 28 if let did = auth.userDID { 21 - LabeledContent("DID", value: did) 22 - .font(.caption) 29 + Menu { 30 + Button { copyText(did) } label: { 31 + Label("Copy", systemImage: "doc.on.doc") 32 + } 33 + } label: { 34 + LabeledContent("DID", value: did) 35 + .font(.caption) 36 + } 37 + .foregroundStyle(.primary) 23 38 } 24 39 } 25 40 ··· 92 107 } 93 108 } 94 109 .navigationTitle("Settings") 110 + .overlay(alignment: .center) { 111 + if showCopiedToast { CopiedCheckmarkToast() } 112 + } 113 + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 114 + .sensoryFeedback(.impact(weight: .medium), trigger: showCopiedToast) 95 115 .task { 96 116 if let authContext = await auth.authContext(), 97 117 let prefs = try? await client.getPreferences(auth: authContext).preferences ··· 103 123 } 104 124 } 105 125 126 + private func copyText(_ text: String) { 127 + UIPasteboard.general.string = text 128 + showCopiedToast = true 129 + Task { 130 + try? await Task.sleep(for: .seconds(2)) 131 + showCopiedToast = false 132 + } 133 + } 134 + 106 135 private func updateCacheSize() { 107 136 guard let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache else { 108 137 cacheSizeText = "Unknown" ··· 118 147 dataCache.removeAll() 119 148 } 120 149 cacheSizeText = "Zero KB" 150 + } 151 + } 152 + 153 + struct CopiedCheckmarkToast: View { 154 + @State private var checkScale = 0.3 155 + 156 + var body: some View { 157 + HStack(spacing: 6) { 158 + Image(systemName: "checkmark.circle.fill") 159 + .font(.subheadline) 160 + .scaleEffect(checkScale) 161 + .onAppear { 162 + withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { 163 + checkScale = 1.0 164 + } 165 + } 166 + Text("Copied") 167 + .font(.subheadline.weight(.medium)) 168 + } 169 + .padding(.horizontal, 16) 170 + .padding(.vertical, 10) 171 + .background(.ultraThinMaterial, in: Capsule()) 172 + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 173 + .transition(.scale.combined(with: .opacity)) 121 174 } 122 175 } 123 176
+14 -1
Grain/Views/Stories/StoryStripView.swift
··· 8 8 var sortVersion: Int = 0 9 9 let onAuthorTap: (GrainStoryAuthor, Int) -> Void 10 10 var onAuthorLongPress: ((String) -> Void)? 11 + var onViewPhoto: ((String) -> Void)? 11 12 let onCreateTap: () -> Void 12 13 13 14 private let avatarSize: CGFloat = 68 ··· 64 65 StoryRingView(hasStory: true, viewed: isViewed, size: avatarSize) { 65 66 AvatarView(url: author.profile.avatar, size: avatarSize) 66 67 } 68 + .padding(4) 69 + .profileContextMenu( 70 + handle: author.profile.handle, 71 + hasStory: true, 72 + onViewProfile: { onAuthorLongPress?(author.profile.did) }, 73 + onViewStory: { onAuthorTap(author, 0) }, 74 + onViewPhoto: author.profile.avatar.map { url in { onViewPhoto?(url) } } 75 + ) { 76 + StoryRingView(hasStory: true, viewed: isViewed, size: 96) { 77 + AvatarView(url: author.profile.avatar, size: 96) 78 + } 79 + .padding(6) 80 + } 67 81 Text(author.profile.displayName ?? author.profile.handle) 68 82 .font(.caption2) 69 83 .foregroundStyle(.secondary) ··· 74 88 .offset(y: isLifted ? -3 : 0) 75 89 .zIndex(isViewed ? 0 : 1) 76 90 .onTapGesture { onAuthorTap(author, 0) } 77 - .onLongPressGesture { onAuthorLongPress?(author.profile.did) } 78 91 } 79 92 } 80 93 .padding(.horizontal)