import Nuke import os import SwiftUI private let logger = Logger(subsystem: "social.grain.grain", category: "GalleryCard") // MARK: - Self-animating heart with heap-backed state @Observable @MainActor final class HeartAnimationState: Identifiable { let id = UUID() let position: CGPoint let rotation: Double var heartScale: CGFloat = 0 var ripple1Scale: CGFloat = 0.3 var ripple1Opacity: Double = 0 var ripple2Scale: CGFloat = 0.3 var ripple2Opacity: Double = 0 var ripple3Scale: CGFloat = 0.3 var ripple3Opacity: Double = 0 var isComplete = false init(position: CGPoint) { self.position = position rotation = Double.random(in: -20 ... 20) } func start() { withAnimation(.spring(response: 0.25, dampingFraction: 0.5)) { heartScale = 1 } ripple1Opacity = 0.6 withAnimation(.easeOut(duration: 0.35)) { ripple1Scale = 1.5 } withAnimation(.easeIn(duration: 0.3).delay(0.2)) { ripple1Opacity = 0 } ripple2Opacity = 0.4 withAnimation(.easeOut(duration: 0.4).delay(0.05)) { ripple2Scale = 2.0 } withAnimation(.easeIn(duration: 0.3).delay(0.25)) { ripple2Opacity = 0 } ripple3Opacity = 0.25 withAnimation(.easeOut(duration: 0.5).delay(0.1)) { ripple3Scale = 2.6 } withAnimation(.easeIn(duration: 0.35).delay(0.3)) { ripple3Opacity = 0 } Task { try? await Task.sleep(for: .milliseconds(500)) withAnimation(.easeInOut(duration: 0.2)) { heartScale = 1.6 } try? await Task.sleep(for: .milliseconds(400)) isComplete = true } } } struct DoubleTapHeartView: View { let state: HeartAnimationState var body: some View { ZStack { Image(systemName: "heart") .font(.system(size: 80, weight: .light)) .foregroundStyle(Color.heart) .scaleEffect(state.ripple3Scale) .opacity(state.ripple3Opacity) .rotationEffect(.degrees(state.rotation * 0.6)) Image(systemName: "heart") .font(.system(size: 80, weight: .ultraLight)) .foregroundStyle(Color.heart) .scaleEffect(state.ripple2Scale) .opacity(state.ripple2Opacity) .rotationEffect(.degrees(state.rotation * 0.8)) Image(systemName: "heart") .font(.system(size: 80, weight: .thin)) .foregroundStyle(Color.heart) .scaleEffect(state.ripple1Scale) .opacity(state.ripple1Opacity) .rotationEffect(.degrees(state.rotation * 0.9)) Image(systemName: "heart.fill") .font(.system(size: 80)) .foregroundStyle(Color.heart) .shadow(color: .pink.opacity(0.4), radius: 12) .scaleEffect(state.heartScale) .opacity(state.heartScale > 1.2 ? 0 : 1) .rotationEffect(.degrees(state.rotation)) } .position(x: state.position.x, y: state.position.y) .allowsHitTesting(false) .accessibilityHidden(true) .onAppear { state.start() } } } // MARK: - Subtle like-button particle burst struct LikeParticleView: View { let index: Int /// Deterministic per slot โ€” no random state stored in parent private static let configs: [(x: CGFloat, y: CGFloat, scale: CGFloat)] = [ (x: -15, y: -30, scale: 0.85), (x: -4, y: -38, scale: 1.00), (x: 7, y: -32, scale: 0.90), (x: 17, y: -26, scale: 0.80), (x: 2, y: -43, scale: 0.95), ] @State private var scale: CGFloat = 0.3 @State private var offset: CGSize = .zero @State private var opacity: Double = 0.9 var body: some View { let cfg = Self.configs[index] Image(systemName: "heart.fill") .font(.system(size: 10)) .foregroundStyle(Color.heart.opacity(0.9)) .scaleEffect(scale) .offset(offset) .opacity(opacity) .onAppear { let delay = Double(index) * 0.07 withAnimation(.easeOut(duration: 0.55).delay(delay)) { scale = cfg.scale offset = CGSize(width: cfg.x, height: cfg.y) } withAnimation(.easeIn(duration: 0.38).delay(delay + 0.32)) { opacity = 0 } } } } struct PageIndicatorView: View { let photos: [GrainPhoto] let currentPage: Int let hasPortrait: Bool var body: some View { if photos.count > 1 { HStack(spacing: 5) { let total = photos.count let maxVisible = 5 let start = total <= maxVisible ? 0 : min(max(currentPage - 2, 0), total - maxVisible) let end = total <= maxVisible ? total : start + maxVisible ForEach(start ..< end, id: \.self) { index in let distance = abs(index - currentPage) let currentIsLandscape = photos[currentPage].aspectRatio.ratio >= 1 let dotColor: Color = hasPortrait && currentIsLandscape ? .secondary : .white Circle() .fill(dotColor.opacity(index == currentPage ? 1.0 : distance == 1 ? 0.5 : distance == 2 ? 0.3 : 0.2)) .frame( width: distance <= 1 ? 6 : distance == 2 ? 4 : 3, height: distance <= 1 ? 6 : distance == 2 ? 4 : 3 ) .animation(.easeInOut(duration: 0.2), value: currentPage) } } .padding(.vertical, 8) } } } struct CopiedToastView: View { var body: some View { HStack(spacing: 6) { Image(systemName: "doc.on.doc.fill") .font(.caption) Text("Link copied") .font(.subheadline.weight(.medium)) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(.ultraThinMaterial, in: Capsule()) .shadow(color: .black.opacity(0.15), radius: 8, y: 4) .transition(.scale.combined(with: .opacity)) } } private extension View { /// Overlays a "Link copied" toast and drives its entrance/exit animation. /// Bundles the overlay and animation so they can't be accidentally separated. func copiedToast(isShowing: Bool) -> some View { overlay { if isShowing { CopiedToastView() } } .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isShowing) } } struct GalleryCardView: View { @Environment(AuthManager.self) private var auth @Environment(StoryStatusCache.self) private var storyStatusCache @Environment(ViewedStoryStorage.self) private var viewedStories @Environment(LabelDefinitionsCache.self) private var labelDefsCache @Binding var gallery: GrainGallery let client: XRPCClient @Environment(Router.self) private var router var onCommentTap: (() -> Void)? var onStoryTap: ((GrainStoryAuthor) -> Void)? var onReport: (() -> Void)? var onDelete: (() -> Void)? /// Whether the location line navigates. /// /// False on `LocationFeedView`, where the destination is the screen you are /// already on. This used to be expressed by omitting `onLocationTap`, which /// left the reason implicit in the absence of an argument. var showsLocationLink = true @State private var isFavoriting = false @State private var showCardActions = false @State private var likeParticleBursts: [UUID] = [] @State private var currentPage = 0 @State private var showingAlt = false @State private var hearts: [HeartAnimationState] = [] @State private var showCopiedToast = false @State private var shareAnimating = false @State private var prefetcher = ImagePrefetcher() private var isFavorited: Bool { gallery.viewer?.fav != nil } private var labelResult: LabelResolution { resolveLabels(gallery.labels, definitions: labelDefsCache.definitions) } private var galleryShareURL: URL { let rkey = gallery.uri.split(separator: "/").last.map(String.init) ?? "" return URL(string: "https://grain.social/profile/\(gallery.creator.did)/gallery/\(rkey)") ?? URL(string: "https://grain.social")! } var body: some View { let lr = labelResult if lr.action == .hide || lr.action == .warnContent, !gallery.labelRevealed { VStack(spacing: 0) { ContentWarningOverlay(name: lr.name, action: lr.action) { gallery.labelRevealed = true } .frame(height: 200) } } else { cardContent(lr: lr) } } private func cardContent(lr: LabelResolution) -> some View { VStack(alignment: .leading, spacing: 0) { cardHeader if let photos = gallery.items, !photos.isEmpty { photoCarousel(photos: photos, lr: lr) } engagementRow favedByRow captionSection(lr: lr) } .copiedToast(isShowing: showCopiedToast) } private var cardHeader: some View { HStack(spacing: 8) { let hasStory = storyStatusCache.hasStory(for: gallery.creator.did) let allViewed = gallery.creator.did != auth.userDID && viewedStories.hasViewedAll(did: gallery.creator.did, storyStatusCache: storyStatusCache) StoryRingView(hasStory: hasStory, viewed: allViewed, size: 32) { AvatarView(url: gallery.creator.avatar, size: 32) } .onTapGesture { if let author = storyStatusCache.author(for: gallery.creator.did) { onStoryTap?(author) } else { router.push(.profile(did: gallery.creator.did)) } } .onLongPressGesture { router.push(.profile(did: gallery.creator.did)) } VStack(alignment: .leading, spacing: 3) { HStack(spacing: 4) { Text(gallery.creator.displayName ?? gallery.creator.handle) .font(.subheadline.weight(.semibold)) .lineLimit(1) Text("@\(gallery.creator.handle)") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Text("ยท \(DateFormatting.relativeTime(gallery.createdAt ?? gallery.indexedAt))") .font(.caption) .foregroundStyle(.secondary) .fixedSize() } if let location = gallery.location, let locationName = gallery.locationDisplay ?? location.name ?? gallery.address?.locality { Text(locationName) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .onTapGesture { guard showsLocationLink else { return } router.push(.location(h3Index: location.value, name: locationName)) } } } Spacer() if onReport != nil || onDelete != nil { Button { showCardActions = true } label: { Image(systemName: "ellipsis") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.primary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("More options") .highPriorityGesture(TapGesture().onEnded { showCardActions = true }) } } .padding(.horizontal, 12) .padding(.vertical, 8) .contentShape(Rectangle()) .onTapGesture { router.push(.profile(did: gallery.creator.did)) } .sheet(isPresented: $showCardActions) { GalleryActionsSheet(onReport: onReport, onDelete: onDelete) .presentationDetents([.height(200)]) } } } // MARK: - Card sections extension GalleryCardView { @ViewBuilder private func photoCarousel(photos: [GrainPhoto], lr: LabelResolution) -> some View { let hasPortrait = photos.contains { $0.aspectRatio.ratio < 1 } let hasMixedRatios = Set(photos.map { Int($0.aspectRatio.ratio * 100) }).count > 1 let carouselRatio = hasMixedRatios ? max(photos.map(\.aspectRatio.ratio).min() ?? 1, 0.56) : photos[currentPage].aspectRatio.ratio GeometryReader { geo in let height = geo.size.width / carouselRatio ZStack(alignment: .bottom) { TabView(selection: $currentPage) { ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in ZStack { ZoomableImage( url: photo.fullsize, thumbURL: photo.thumb, aspectRatio: photo.aspectRatio.ratio, onDoubleTap: { point in doubleTapLike(at: point) } ) // Alt text overlay โ€” always in the tree so opacity // animates smoothly (conditional `if` inside TabView // swallows transitions). if let alt = photo.alt, !alt.isEmpty { ZStack { Color.black.opacity(0.6) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { showingAlt = false } } GeometryReader { geo in ScrollView { Text(alt) .font(.subheadline) .foregroundStyle(.white) .multilineTextAlignment(.center) .padding(20) .frame(maxWidth: .infinity) .frame(minHeight: geo.size.height) } .scrollBounceBehavior(.basedOnSize) } } .opacity(showingAlt && currentPage == index ? 1 : 0) .allowsHitTesting(showingAlt && currentPage == index) .animation(.easeInOut(duration: 0.2), value: showingAlt && currentPage == index) } } .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) .overlay { if lr.action == .warnMedia, !gallery.labelRevealed { Rectangle().fill(Color(.secondarySystemBackground)) } } .allowsHitTesting(lr.action != .warnMedia || gallery.labelRevealed) PageIndicatorView(photos: photos, currentPage: currentPage, hasPortrait: hasPortrait) altButton(photos: photos) // Double-tap heart animations ForEach(hearts) { heart in DoubleTapHeartView(state: heart) .onChange(of: heart.isComplete) { hearts.removeAll { $0.isComplete } } } // Media warning overlay if lr.action == .warnMedia, !gallery.labelRevealed { MediaWarningOverlay(name: lr.name) { withAnimation { gallery.labelRevealed = true } } } } .contentShape(Rectangle()) .onTapGesture { let photo = currentPage < photos.count ? photos[currentPage] : nil if let alt = photo?.alt, !alt.isEmpty { withAnimation(.easeInOut(duration: 0.2)) { showingAlt.toggle() } } } .frame(height: height) } .aspectRatio(carouselRatio, contentMode: .fit) .onAppear { prefetchCarousel(photos: photos, page: 0) } .onChange(of: currentPage) { withAnimation(.easeInOut(duration: 0.2)) { showingAlt = false } prefetchCarousel(photos: photos, page: currentPage) } .onDisappear { prefetcher.stopPrefetching() } } @ViewBuilder private func altButton(photos: [GrainPhoto]) -> some View { if let alt = photos[currentPage].alt, !alt.isEmpty { VStack { Spacer() HStack { Spacer() Button { withAnimation(.easeInOut(duration: 0.2)) { showingAlt.toggle() } } label: { Text("ALT") .font(.caption2.weight(.bold)) .padding(.horizontal, 6) .padding(.vertical, 3) .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) .foregroundStyle(.white) } .accessibilityLabel(showingAlt ? "Hide alt text" : "Show alt text") } .padding(8) } } } /// Count text sized to a fixed-width digit proxy so the row doesn't shift /// when the tally changes. private var favCountLabel: some View { ZStack { let count = gallery.favCount ?? 0 Text(count.compactCount.digitWidthProxy) .hidden() Text(count.compactCount) } } private var engagementRow: some View { HStack(spacing: 16) { // Heart toggles; the count next to it opens the favorited-by list. // They're separate hit targets so tapping the tally can't like. HStack(spacing: 5) { Button { guard !isFavoriting else { return } if !isFavorited { addParticleBurst() } triggerFavoriteToggle() } label: { Image(systemName: isFavorited ? "heart.fill" : "heart") .font(.system(size: 22)) .contentTransition(.symbolEffect(.replace.downUp.byLayer, options: .nonRepeating)) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isFavorited) // The count used to be part of this button. Grow the hit // target back to what it was, then negatively pad so the // row lays out exactly as before. .frame(width: 32, height: 32) .contentShape(Rectangle()) .padding(.horizontal, -4) .padding(.vertical, -5) } .accessibilityLabel(isFavorited ? "Remove favorite" : "Favorite") .accessibilityValue("\(gallery.favCount ?? 0) favorites") .overlay(alignment: .leading) { ZStack { ForEach(likeParticleBursts, id: \.self) { _ in ForEach(0 ..< 5, id: \.self) { i in LikeParticleView(index: i) } } } .offset(x: 11) .allowsHitTesting(false) .accessibilityHidden(true) } if (gallery.favCount ?? 0) > 0 { Button { router.push(.galleryFavorites(uri: gallery.uri)) } label: { favCountLabel } .accessibilityLabel("\(gallery.favCount ?? 0) favorites") .accessibilityHint("Shows everyone who favorited this gallery") } else { favCountLabel } } .foregroundStyle(isFavorited ? AnyShapeStyle(Color.heart) : AnyShapeStyle(.secondary)) Button { onCommentTap?() } label: { HStack(spacing: 5) { Image(systemName: "bubble") .font(.system(size: 20)) Text((gallery.commentCount ?? 0).compactCount) } } .foregroundStyle(.secondary) .accessibilityLabel("Comments") .accessibilityValue("\(gallery.commentCount ?? 0)") ShareLink(item: galleryShareURL) { Image(systemName: "paperplane") .font(.system(size: 20)) .rotationEffect(.degrees(shareAnimating ? -15 : 0)) .animation( shareAnimating ? .easeInOut(duration: 0.08).repeatCount(5, autoreverses: true) : .default, value: shareAnimating ) } .foregroundStyle(.secondary) .accessibilityLabel("Share gallery") .disabled(shareAnimating) .simultaneousGesture( LongPressGesture(minimumDuration: 0.5) .onEnded { _ in UIPasteboard.general.url = galleryShareURL UIImpactFeedbackGenerator(style: .medium).impactOccurred() shareAnimating = true showCopiedToast = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { shareAnimating = false } DispatchQueue.main.asyncAfter(deadline: .now() + 2) { showCopiedToast = false } } ) Spacer() } .font(.subheadline) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: gallery.favCount) .padding(.horizontal, 12) .padding(.top, 12) .padding(.bottom, 4) } /// "Favorited by " facepile. The server caps the list, /// so past two names we say "and others" rather than a count we don't have. @ViewBuilder private var favedByRow: some View { let people = gallery.favedByFollowing ?? [] if !people.isEmpty { let names = people.prefix(2).map { person -> String in if let name = person.displayName, !name.isEmpty { return name } return person.handle } HStack(spacing: 6) { FacepileView(people: people, size: 20) Group { if names.count == 1 { Text("Favorited by **\(names[0])**") } else if people.count > 2 { Text("Favorited by **\(names[0])**, **\(names[1])** and others you follow") } else { Text("Favorited by **\(names[0])** and **\(names[1])**") } } .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.top, 6) .contentShape(Rectangle()) .onTapGesture { router.push(.galleryFavorites(uri: gallery.uri)) } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) } } @ViewBuilder private func captionSection(lr: LabelResolution) -> some View { // EXIF info โ€” collapses when the current photo has no metadata, // expands smoothly when swiping to a photo that does. let allPhotos = gallery.items ?? [] if allPhotos.contains(where: { $0.exif?.hasDisplayableData ?? false }) { let currentExif = allPhotos.indices.contains(currentPage) ? allPhotos[currentPage].exif : nil let hasCurrentExif = currentExif?.hasDisplayableData ?? false ExifInfoView( exif: currentExif?.displayData, reserveCameraRow: allPhotos.contains(where: { $0.exif?.cameraName != nil }), reserveLensRow: allPhotos.contains(where: { $0.exif?.lensName != nil }) ) .transaction { $0.animation = nil } .padding(.horizontal, 12) .padding(.top, 8) .frame(maxHeight: hasCurrentExif ? nil : 0, alignment: .top) .clipped() .animation(.easeInOut(duration: 0.2), value: hasCurrentExif) } // Title & description VStack(alignment: .leading, spacing: 4) { Text(gallery.title ?? "") .font(.subheadline.weight(.semibold)) .lineLimit(3) if let description = gallery.description, !description.isEmpty { ExpandableDescriptionView( text: description, onMentionTap: { router.push(.profile(did: $0)) }, onHashtagTap: { router.push(.hashtag($0)) } ) } if lr.action == .badge { LabelBadge(name: lr.name) } } .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 16) } private func addParticleBurst() { let id = UUID() likeParticleBursts.append(id) DispatchQueue.main.asyncAfter(deadline: .now() + 1.3) { likeParticleBursts.removeAll { $0 == id } } } private func prefetchCarousel(photos: [GrainPhoto], page: Int) { let input = photos.map { (thumb: $0.thumb, fullsize: $0.fullsize) } let plan = ImagePrefetchPlanning.carouselPrefetchRequests(photos: input, currentPage: page) prefetcher.startPrefetching(with: plan.all) } private func doubleTapLike(at point: CGPoint) { hearts.append(HeartAnimationState(position: point)) addParticleBurst() guard !isFavorited, !isFavoriting else { return } triggerFavoriteToggle() } private func triggerFavoriteToggle() { isFavoriting = true Task { await toggleFavorite() isFavoriting = false } } private func toggleFavorite() async { guard let authContext = await auth.authContext() else { logger.error("No auth context") return } if let favUri = gallery.viewer?.fav { gallery.viewer?.fav = nil gallery.favCount = max((gallery.favCount ?? 1) - 1, 0) do { try await FavoriteService.delete(favoriteUri: favUri, client: client, auth: authContext) } catch { logger.error("Unfavorite failed: \(error)") gallery.viewer?.fav = favUri gallery.favCount = (gallery.favCount ?? 0) + 1 } } else { let prevViewer = gallery.viewer let prevCount = gallery.favCount gallery.viewer = GalleryViewerState(fav: "pending") gallery.favCount = (gallery.favCount ?? 0) + 1 do { let response = try await FavoriteService.create(subject: gallery.uri, client: client, auth: authContext) gallery.viewer = GalleryViewerState(fav: response.uri) } catch { logger.error("Favorite failed: \(error)") gallery.viewer = prevViewer gallery.favCount = prevCount } } } } #Preview { @Previewable @State var gallery = PreviewData.gallery1 ScrollView { GalleryCardView( gallery: $gallery, client: .preview ) GalleryCardView( gallery: .constant(PreviewData.gallery2), client: .preview ) } .previewEnvironments() .preferredColorScheme(.dark) .tint(Color.accentColor) .frame(maxHeight: .infinity, alignment: .top) } struct GalleryActionsSheet: View { @Environment(\.dismiss) private var dismiss var onReport: (() -> Void)? var onDelete: (() -> Void)? var body: some View { List { if let onReport { Button { dismiss() onReport() } label: { Label("Report", systemImage: "flag") .foregroundStyle(.primary) } } if let onDelete { Button { dismiss() onDelete() } label: { Label("Delete gallery", systemImage: "trash") .foregroundStyle(.red) } } } .tint(.primary) } }