Scrapboard.org client
1"use client";
2import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3import {
4 Agent,
5 AppBskyEmbedImages,
6 AppBskyFeedPost,
7 AtUri,
8 moderatePost,
9 ModerationPrefs,
10} from "@atproto/api";
11import { LoaderCircle } from "lucide-react";
12import { motion } from "motion/react";
13import Image from "next/image";
14import Link from "next/link";
15import Masonry from "react-masonry-css";
16import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
17import { SaveButton } from "./SaveButton";
18import { UnsaveButton } from "./UnsaveButton";
19import { LikeButton } from "./LikeButton";
20import { useState, useEffect } from "react";
21import {
22 DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
23 useModerationOpts,
24} from "@/lib/hooks/useModerationOpts";
25import { useAuth } from "@/lib/hooks/useAuth";
26import { ContentWarning } from "./ContentWarning";
27import clsx from "clsx";
28
29export type FeedItem = {
30 id: string;
31 imageUrl: string;
32 alt?: string;
33 author?: {
34 avatar?: string;
35 displayName?: string;
36 handle: string;
37 did?: string;
38 };
39 text?: string;
40 uri: string;
41 aspectRatio?: { width: number; height: number };
42 blurDataURL?: string;
43};
44
45// Props for the Feed component
46interface FeedProps {
47 /**
48 * Map of the index of the embedded media and post view
49 */
50 feed?: [number, PostView][];
51
52 isLoading?: boolean;
53 showUnsaveButton?: boolean;
54}
55
56function getText(post: PostView) {
57 if (!AppBskyFeedPost.isRecord(post.record)) return;
58 return (post.record as AppBskyFeedPost.Record).text;
59}
60
61function getImageFromItem(it: PostView, index: number) {
62 if (
63 AppBskyEmbedImages.isMain(it.embed) ||
64 AppBskyEmbedImages.isView(it.embed)
65 ) {
66 return it.embed.images[index];
67 } else return null;
68}
69
70// Add this function to prefetch and cache images
71function prefetchAndCacheImages(feed: [number, PostView][] | undefined) {
72 if (!feed || typeof window === "undefined") return;
73
74 feed.forEach(([index, item]) => {
75 const image = getImageFromItem(item, index);
76 if (image && image.fullsize) {
77 const img = new window.Image();
78 img.src = image.fullsize;
79
80 // If service worker is active, explicitly add to cache
81 if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
82 fetch(image.fullsize, { mode: "no-cors" }).catch((err) =>
83 console.warn("Error prefetching image:", err)
84 );
85 }
86 }
87 });
88}
89
90function ImageCard({
91 item,
92 showUnsaveButton,
93 index,
94}: {
95 item: PostView;
96 showUnsaveButton?: boolean;
97 index: number;
98}) {
99 const image = getImageFromItem(item, index);
100 const [isDropdownOpen, setDropdownOpen] = useState(false);
101 const modOpts = useModerationOpts();
102 const { session, agent } = useAuth();
103
104 if (!image) return;
105
106 const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton;
107 const txt = getText(item);
108 const opts: ModerationPrefs = modOpts.moderationPrefs ?? {
109 adultContentEnabled: false,
110 labelers: agent.appLabelers.map((did) => ({
111 did,
112 labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
113 })),
114 hiddenPosts: [],
115 mutedWords: [],
116 labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
117 };
118 const mod = moderatePost(item, {
119 prefs: opts,
120 labelDefs: modOpts.labelDefs,
121 userDid: session?.did,
122 });
123
124 // Debug code removed for production
125
126 return (
127 <ContentWarning mod={mod}>
128 <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}>
129 {/* Save/Unsave button – top-left */}
130 <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity">
131 {ActionButton && (
132 <ActionButton
133 image={index}
134 post={item}
135 onDropdownOpenChange={setDropdownOpen}
136 />
137 )}
138 </div>
139
140 {/* Like button – top-right */}
141 <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity">
142 <LikeButton post={item} />
143 </div>
144
145 {/* Link wraps image only */}
146 <Link
147 href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`}
148 className="block"
149 >
150 <motion.div
151 initial={{ opacity: 0, y: 5 }}
152 animate={{ opacity: 1, y: 0 }}
153 transition={{ duration: 0.5, ease: "easeOut" }}
154 whileTap={{ scale: 0.95 }}
155 className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
156 >
157 {/* Blurred background */}
158 <Image
159 src={image.fullsize}
160 alt=""
161 fill
162 placeholder={image.thumb ? "blur" : "empty"}
163 blurDataURL={image.thumb}
164 className="object-cover filter blur-xl scale-110 opacity-30"
165 />
166
167 {/* Foreground image */}
168 <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
169 <Image
170 src={image.fullsize}
171 alt={image.alt || ""}
172 placeholder={image.thumb ? "blur" : "empty"}
173 blurDataURL={image.thumb}
174 width={image.aspectRatio?.width ?? 400}
175 height={image.aspectRatio?.height ?? 400}
176 className="object-contain max-w-full max-h-full rounded-lg"
177 priority
178 />
179 </div>
180
181 {/* Author info */}
182 {item.author && (
183 <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3">
184 <div className="w-fit self-start" />
185
186 <div className="flex flex-col gap-2">
187 <div className="flex items-center gap-2">
188 <Avatar>
189 <AvatarImage
190 className={clsx(
191 mod.ui("avatar").blur ? "blur-3xl" : ""
192 )}
193 src={item.author.avatar}
194 />
195 <AvatarFallback>
196 {item.author.displayName || item.author.handle}
197 </AvatarFallback>
198 </Avatar>
199 <div className="flex flex-col leading-tight">
200 <span>
201 {item.author.displayName || item.author.handle}
202 </span>
203 <span className="text-white/70 text-[0.75rem]">
204 @{item.author.handle}
205 </span>
206 </div>
207 </div>
208
209 {txt && (
210 <div className="text-sm">
211 {txt.length > 100 ? txt.slice(0, 100) + "…" : txt}
212 </div>
213 )}
214 </div>
215 </div>
216 )}
217 </motion.div>
218 </Link>
219 </div>
220 </ContentWarning>
221 );
222}
223
224export function feedAsMap(feed: PostView[]) {
225 const map: [number, PostView][] = [];
226 for (const it of feed) {
227 if (
228 AppBskyEmbedImages.isMain(it.embed) ||
229 AppBskyEmbedImages.isView(it.embed)
230 ) {
231 it.embed.images.forEach((image, index) => map.push([index, it]));
232 }
233 }
234 return map;
235}
236
237export const breakpointColumnsObj = {
238 default: 5,
239 1536: 4,
240 1280: 3,
241 1024: 2,
242 768: 1,
243};
244
245export function Feed({
246 feed,
247 isLoading = false,
248 showUnsaveButton = false,
249}: FeedProps) {
250 // Use effect to prefetch and cache images when feed changes
251 useEffect(() => {
252 prefetchAndCacheImages(feed);
253 }, [feed]);
254
255 return (
256 <>
257 <Masonry
258 breakpointCols={breakpointColumnsObj}
259 className="flex -mx-2 w-auto"
260 columnClassName="px-2 space-y-4"
261 >
262 {feed?.map(([index, item]) => (
263 <ImageCard
264 key={`${item.uri}-${index}`}
265 item={item}
266 index={index}
267 showUnsaveButton={showUnsaveButton}
268 />
269 ))}
270 </Masonry>
271
272 {isLoading && (
273 <div className="flex justify-center py-6 text-sm text-black/70 dark:text-white/70">
274 <LoaderCircle className="animate-spin" />
275 </div>
276 )}
277 </>
278 );
279}