+8
bun.lock
+8
bun.lock
···
10
10
"@radix-ui/react-avatar": "^1.1.10",
11
11
"@radix-ui/react-dialog": "^1.1.14",
12
12
"@radix-ui/react-dropdown-menu": "^2.1.15",
13
+
"@radix-ui/react-scroll-area": "^1.2.9",
13
14
"@radix-ui/react-slot": "^1.2.3",
14
15
"@radix-ui/react-tabs": "^1.1.12",
15
16
"class-variance-authority": "^0.7.1",
···
22
23
"react-dom": "19.1.0",
23
24
"react-masonry-css": "^1.0.16",
24
25
"tailwind-merge": "^3.3.1",
26
+
"zustand": "^5.0.7",
25
27
},
26
28
"devDependencies": {
27
29
"@eslint/eslintrc": "^3",
···
296
298
297
299
"@poppinss/exception": ["@poppinss/exception@1.2.2", "", {}, "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg=="],
298
300
301
+
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
302
+
299
303
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
300
304
301
305
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
···
333
337
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
334
338
335
339
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
340
+
341
+
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
336
342
337
343
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
338
344
···
1447
1453
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
1448
1454
1449
1455
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
1456
+
1457
+
"zustand": ["zustand@5.0.7", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg=="],
1450
1458
1451
1459
"@atproto/jwk-jose/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
1452
1460
+4
-4
next.config.ts
+4
-4
next.config.ts
···
1
1
import type { NextConfig } from "next";
2
-
import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
2
+
import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev";
3
3
4
4
const nextConfig: NextConfig = {
5
5
/* config options here */
···
8
8
},
9
9
};
10
10
11
-
if (process.env.NODE_ENV === 'development') {
12
-
await setupDevPlatform();
13
-
}
11
+
// if (process.env.NODE_ENV === "development") {
12
+
// await setupDevPlatform();
13
+
// }
14
14
15
15
export default nextConfig;
+3
-1
package.json
+3
-1
package.json
···
18
18
"@radix-ui/react-avatar": "^1.1.10",
19
19
"@radix-ui/react-dialog": "^1.1.14",
20
20
"@radix-ui/react-dropdown-menu": "^2.1.15",
21
+
"@radix-ui/react-scroll-area": "^1.2.9",
21
22
"@radix-ui/react-slot": "^1.2.3",
22
23
"@radix-ui/react-tabs": "^1.1.12",
23
24
"class-variance-authority": "^0.7.1",
···
29
30
"react": "19.1.0",
30
31
"react-dom": "19.1.0",
31
32
"react-masonry-css": "^1.0.16",
32
-
"tailwind-merge": "^3.3.1"
33
+
"tailwind-merge": "^3.3.1",
34
+
"zustand": "^5.0.7"
33
35
},
34
36
"devDependencies": {
35
37
"@eslint/eslintrc": "^3",
+1
-1
src/app/[did]/[uri]/page.tsx
+1
-1
src/app/[did]/[uri]/page.tsx
+53
-200
src/app/page.tsx
+53
-200
src/app/page.tsx
···
1
1
"use client";
2
-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3
-
import { useAuth } from "@/lib/useAuth";
4
-
import {
5
-
AppBskyEmbedImages,
6
-
AppBskyFeedDefs,
7
-
AppBskyFeedPost,
8
-
} from "@atproto/api";
9
-
import { LoaderCircle } from "lucide-react";
10
-
import Image from "next/image";
11
-
import { useEffect, useRef, useState, useCallback } from "react";
12
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
13
-
import Link from "next/link";
14
-
import Masonry from "react-masonry-css";
15
-
import { motion } from "motion/react";
16
2
17
-
export const runtime = "edge";
3
+
import { Feed } from "@/components/Feed";
4
+
import { useFetchTimeline } from "@/lib/hooks/useTimeline";
5
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
6
+
import { useRef, useEffect } from "react";
7
+
import { useFeedStore } from "@/lib/stores/feeds";
8
+
import { useFeeds } from "@/lib/hooks/useFeeds";
9
+
import { LoaderCircle } from "lucide-react";
10
+
import { useFeedDefsStore } from "@/lib/stores/feedDefs";
11
+
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
12
+
import { useAuth } from "@/lib/useAuth";
18
13
19
14
export default function Home() {
20
-
const [timeline, setTimeline] = useState<AppBskyFeedDefs.FeedViewPost[]>([]);
21
-
const [cursor, setCursor] = useState<string | null>(null);
22
-
const [loading, setLoading] = useState(false);
23
-
const { agent, session } = useAuth();
15
+
const { fetchFeed } = useFetchTimeline();
16
+
const feedStore = useFeedStore();
17
+
const { isLoading } = useFeeds();
18
+
const { feeds } = useFeedDefsStore();
19
+
const { session } = useAuth();
24
20
const sentinelRef = useRef<HTMLDivElement>(null);
25
21
26
-
const seenImageUrls = new Set<string>();
27
-
28
-
const fetchFeed = useCallback(async () => {
29
-
if (!agent || loading) return [];
30
-
setLoading(true);
31
-
try {
32
-
const response = await agent.getTimeline({
33
-
cursor: cursor ?? undefined,
34
-
limit: 100,
35
-
});
36
-
if (!response.success) throw new Error("Failed to fetch timeline");
37
-
38
-
const nextCursor = response.data.cursor || null;
39
-
setCursor(nextCursor);
40
-
41
-
const filtered = response.data.feed.filter((it) => {
42
-
// Filter out reposts
43
-
if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost")
44
-
return false;
45
-
46
-
// Must be an image embed
47
-
if (
48
-
!(
49
-
AppBskyEmbedImages.isMain(it.post.embed) ||
50
-
AppBskyEmbedImages.isView(it.post.embed)
51
-
)
52
-
) {
53
-
return false;
54
-
}
55
-
56
-
// Check for new image URLs (to avoid repeats)
57
-
const images = (it.post.embed as AppBskyEmbedImages.View)?.images || [];
58
-
const hasNewImage = images.some(
59
-
(img) => !seenImageUrls.has(img.fullsize)
60
-
);
61
-
if (!hasNewImage) return false;
62
-
63
-
// Add seen image URLs
64
-
images.forEach((img) => seenImageUrls.add(img.fullsize));
65
-
return true;
66
-
});
67
-
68
-
return filtered;
69
-
} catch (err) {
70
-
console.error("Error fetching timeline", err);
71
-
return [];
72
-
} finally {
73
-
setLoading(false);
74
-
}
75
-
}, [agent, cursor, loading]);
76
-
77
-
// Initial + fill up to 15 images
78
-
useEffect(() => {
79
-
if (!agent) return;
80
-
81
-
const loadMinimumPosts = async () => {
82
-
let accumulated: AppBskyFeedDefs.FeedViewPost[] = [];
83
-
let newCursor = cursor;
84
-
85
-
while (
86
-
accumulated.flatMap(
87
-
(p) => (p.post.embed as AppBskyEmbedImages.View)?.images || []
88
-
).length < 15
89
-
) {
90
-
const batch = await fetchFeed();
91
-
if (batch.length === 0) break;
92
-
accumulated = [...accumulated, ...batch];
93
-
newCursor = cursor;
94
-
}
95
-
96
-
setTimeline(accumulated);
97
-
};
98
-
99
-
loadMinimumPosts();
100
-
}, [agent]);
101
-
102
-
// Load more on scroll to sentinel
103
22
useEffect(() => {
104
23
const observer = new IntersectionObserver(
105
-
async (entries) => {
106
-
const entry = entries[0];
107
-
if (entry.isIntersecting && !loading && cursor) {
108
-
const more = await fetchFeed();
109
-
setTimeline((prev) => [...prev, ...more]);
24
+
(entries) => {
25
+
if (entries[0].isIntersecting) {
26
+
fetchFeed();
110
27
}
111
28
},
112
-
{
113
-
rootMargin: "200px",
114
-
}
29
+
{ rootMargin: "200px" }
115
30
);
116
31
117
32
const sentinel = sentinelRef.current;
···
119
34
return () => {
120
35
if (sentinel) observer.unobserve(sentinel);
121
36
};
122
-
}, [fetchFeed, cursor, loading]);
123
-
124
-
const breakpointColumnsObj = {
125
-
default: 5,
126
-
1536: 4,
127
-
1280: 3,
128
-
1024: 2,
129
-
768: 1,
130
-
};
131
-
132
-
console.log(session);
37
+
}, [fetchFeed]);
133
38
134
39
if (session == null) {
135
40
return (
···
142
47
);
143
48
}
144
49
50
+
if (isLoading)
51
+
return (
52
+
<div className="flex justify-center py-6 text-sm text-black/70 dark:text-white/70">
53
+
<LoaderCircle className="animate-spin" />
54
+
</div>
55
+
);
56
+
145
57
return (
146
-
<div className="items-center justify-items-center">
147
-
<div className="h-5" />
148
-
<main className="px-5">
149
-
<Masonry
150
-
breakpointCols={breakpointColumnsObj}
151
-
className="flex -mx-2 w-auto"
152
-
columnClassName="px-2 space-y-4"
58
+
<main className="px-5">
59
+
<Tabs defaultValue="timeline" className="w-full">
60
+
<TabsList
61
+
className="flex w-full overflow-x-auto whitespace-nowrap no-scrollbar px-4 space-x-4"
62
+
style={{ justifyItems: "unset" }}
153
63
>
154
-
{timeline.flatMap((post) => {
155
-
if (!AppBskyEmbedImages.isView(post.post.embed)) return;
156
-
const images = post.post.embed.images || [];
157
-
if (images.length === 0) return [];
158
-
const t: string = (post.post.record.text as string) || "";
159
-
const maxLength = 100;
160
-
return images.map((image, index) => (
161
-
<Link
162
-
href={`/${post.post.author.did}/${post.post.uri
163
-
.split("/")
164
-
.pop()}?image=${index}`}
165
-
key={image.fullsize}
166
-
className="block"
167
-
>
168
-
<motion.div
169
-
initial={{ opacity: 0, y: 5 }}
170
-
animate={{ opacity: 1, y: 0 }}
171
-
transition={{ duration: 0.5, ease: "easeOut" }}
172
-
whileTap={{ scale: 0.99 }}
173
-
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
174
-
>
175
-
{/* Blurred background */}
176
-
<Image
177
-
src={image.fullsize}
178
-
alt=""
179
-
fill
180
-
placeholder="blur"
181
-
blurDataURL={image.thumb}
182
-
className="object-cover filter blur-xl scale-110 opacity-30"
183
-
/>
64
+
<TabsTrigger value="timeline" className="shrink-0 ml-10">
65
+
Timeline
66
+
</TabsTrigger>
67
+
{Object.entries(feeds).map(([value, it]) => (
68
+
<TabsTrigger key={value} value={value} className="shrink-0">
69
+
{it?.displayName}
70
+
</TabsTrigger>
71
+
))}
72
+
</TabsList>
184
73
185
-
{/* Centered foreground image */}
186
-
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
187
-
<Image
188
-
src={image.fullsize}
189
-
alt={image.alt}
190
-
placeholder="blur"
191
-
blurDataURL={image.thumb}
192
-
width={image?.aspectRatio?.width ?? 400}
193
-
height={image?.aspectRatio?.height ?? 400}
194
-
className="object-contain max-w-full max-h-full rounded-lg"
195
-
/>
196
-
</div>
74
+
<TabsContent value="timeline">
75
+
<Feed
76
+
feed={feedStore.timeline.posts.map((it) => it.post)}
77
+
isLoading={feedStore.timeline.isLoading}
78
+
/>
79
+
</TabsContent>
197
80
198
-
{/* Hover overlay */}
199
-
<div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-3">
200
-
<div className="text-sm mb-1">
201
-
{AppBskyFeedPost.isRecord(post.post.record) && (
202
-
<>
203
-
{t.length > maxLength
204
-
? t.slice(0, maxLength) + "…"
205
-
: t}
206
-
</>
207
-
)}
208
-
</div>
209
-
<div className="text-xs flex gap-2">
210
-
<Avatar>
211
-
<AvatarImage src={post.post.author.avatar} />
212
-
<AvatarFallback>
213
-
{post.post.author.displayName ||
214
-
post.post.author.handle}
215
-
</AvatarFallback>
216
-
</Avatar>
217
-
{post.post.author.displayName || post.post.author.handle}
218
-
</div>
219
-
</div>
220
-
</motion.div>
221
-
</Link>
222
-
));
223
-
})}
224
-
</Masonry>
225
-
<div ref={sentinelRef} className="h-1 col-span-full" />
226
-
{loading && (
227
-
<div className="col-span-full flex justify-center text-sm text-black/70 dark:text-white/70">
228
-
<LoaderCircle className="animate-spin" />
229
-
</div>
230
-
)}
231
-
</main>
81
+
{Object.entries(feeds).map(([value]) => (
82
+
<TabsContent key={value} value={value}></TabsContent>
83
+
))}
84
+
</Tabs>
232
85
233
-
{/* <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"></footer> */}
234
-
</div>
86
+
<div ref={sentinelRef} className="h-1" />
87
+
</main>
235
88
);
236
89
}
+129
src/components/Feed.tsx
+129
src/components/Feed.tsx
···
1
+
"use client";
2
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3
+
import { useAuth } from "@/lib/useAuth";
4
+
import {
5
+
AppBskyEmbedImages,
6
+
AppBskyFeedDefs,
7
+
AppBskyFeedPost,
8
+
} from "@atproto/api";
9
+
import { LoaderCircle } from "lucide-react";
10
+
import { useEffect, useRef, useState, useCallback } from "react";
11
+
import { motion } from "motion/react";
12
+
import { Button } from "@/components/ui/button";
13
+
import Image from "next/image";
14
+
import Link from "next/link";
15
+
import Masonry from "react-masonry-css";
16
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
17
+
18
+
function getText(post: PostView) {
19
+
if (!AppBskyFeedPost.isRecord(post.record)) return;
20
+
return (post.record as AppBskyFeedPost.Record).text;
21
+
}
22
+
23
+
export function Feed({
24
+
feed,
25
+
isLoading = false,
26
+
}: {
27
+
feed: PostView[];
28
+
isLoading?: boolean;
29
+
}) {
30
+
const breakpointColumnsObj = {
31
+
default: 5,
32
+
1536: 4,
33
+
1280: 3,
34
+
1024: 2,
35
+
768: 1,
36
+
};
37
+
38
+
return (
39
+
<>
40
+
<Masonry
41
+
breakpointCols={breakpointColumnsObj}
42
+
className="flex -mx-2 w-auto"
43
+
columnClassName="px-2 space-y-4"
44
+
>
45
+
{feed.flatMap((post) => {
46
+
if (!AppBskyEmbedImages.isView(post.embed)) return [];
47
+
const images = post.embed.images || [];
48
+
if (images.length === 0) return [];
49
+
const t: string = getText(post) || "";
50
+
const maxLength = 100;
51
+
return images.map((image, index) => (
52
+
<Link
53
+
href={`/${post.author.did}/${post.uri
54
+
.split("/")
55
+
.pop()}?image=${index}`}
56
+
key={image.fullsize}
57
+
className="block"
58
+
>
59
+
<motion.div
60
+
initial={{ opacity: 0, y: 5 }}
61
+
animate={{ opacity: 1, y: 0 }}
62
+
transition={{ duration: 0.5, ease: "easeOut" }}
63
+
whileTap={{ scale: 0.99 }}
64
+
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
65
+
>
66
+
{/* Blurred background */}
67
+
<Image
68
+
src={image.fullsize}
69
+
alt=""
70
+
fill
71
+
placeholder="blur"
72
+
blurDataURL={image.thumb}
73
+
className="object-cover filter blur-xl scale-110 opacity-30"
74
+
/>
75
+
76
+
{/* Centered foreground image */}
77
+
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
78
+
<Image
79
+
src={image.fullsize}
80
+
alt={image.alt}
81
+
placeholder="blur"
82
+
blurDataURL={image.thumb}
83
+
width={image?.aspectRatio?.width ?? 400}
84
+
height={image?.aspectRatio?.height ?? 400}
85
+
className="object-contain max-w-full max-h-full rounded-lg"
86
+
/>
87
+
</div>
88
+
89
+
{/* Bottom: Avatar, display name, and handle */}
90
+
<div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3">
91
+
<div className="w-fit self-start" />
92
+
93
+
<div className="flex flex-col gap-2">
94
+
<div className="flex items-center gap-2">
95
+
<Avatar>
96
+
<AvatarImage src={post.author.avatar} />
97
+
<AvatarFallback>
98
+
{post.author.displayName || post.author.handle}
99
+
</AvatarFallback>
100
+
</Avatar>
101
+
<div className="flex flex-col leading-tight">
102
+
<span>
103
+
{post.author.displayName || post.author.handle}
104
+
</span>
105
+
<span className="text-white/70 text-[0.75rem]">
106
+
@{post.author.handle}
107
+
</span>
108
+
</div>
109
+
</div>
110
+
111
+
<div className="text-sm">
112
+
{t.length > maxLength ? t.slice(0, maxLength) + "…" : t}
113
+
</div>
114
+
</div>
115
+
</div>
116
+
</motion.div>
117
+
</Link>
118
+
));
119
+
})}
120
+
</Masonry>
121
+
122
+
{isLoading && (
123
+
<div className="flex justify-center py-6 text-sm text-black/70 dark:text-white/70">
124
+
<LoaderCircle className="animate-spin" />
125
+
</div>
126
+
)}
127
+
</>
128
+
);
129
+
}
+48
src/components/SaveButton.tsx
+48
src/components/SaveButton.tsx
···
1
+
import {
2
+
Dialog,
3
+
DialogContent,
4
+
DialogDescription,
5
+
DialogFooter,
6
+
DialogHeader,
7
+
DialogTitle,
8
+
DialogTrigger,
9
+
} from "@/components/ui/dialog";
10
+
import { useAuth } from "@/lib/useAuth";
11
+
import { useState } from "react";
12
+
import { Button } from "./ui/button";
13
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
14
+
import { LoaderCircle } from "lucide-react";
15
+
16
+
function SaveButton(post: PostView) {
17
+
const { login } = useAuth();
18
+
const [handle, setHandle] = useState("");
19
+
const [isLoading, setLoading] = useState(false);
20
+
return (
21
+
<Dialog>
22
+
<DialogTrigger>
23
+
<Button size="sm" className="cursor-pointer">
24
+
Login
25
+
</Button>
26
+
</DialogTrigger>
27
+
<DialogContent>
28
+
<DialogHeader>
29
+
<DialogTitle>Save post to board</DialogTitle>
30
+
<DialogDescription className="pt-5"></DialogDescription>
31
+
</DialogHeader>
32
+
<DialogFooter>
33
+
<Button
34
+
onClick={() => {
35
+
setLoading(true);
36
+
login(handle);
37
+
}}
38
+
disabled={!handle}
39
+
className="cursor-pointer"
40
+
>
41
+
{isLoading && <LoaderCircle className="animate-spin ml-2" />}
42
+
Save
43
+
</Button>
44
+
</DialogFooter>
45
+
</DialogContent>
46
+
</Dialog>
47
+
);
48
+
}
+58
src/components/ui/scroll-area.tsx
+58
src/components/ui/scroll-area.tsx
···
1
+
"use client"
2
+
3
+
import * as React from "react"
4
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
+
6
+
import { cn } from "@/lib/utils"
7
+
8
+
function ScrollArea({
9
+
className,
10
+
children,
11
+
...props
12
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
13
+
return (
14
+
<ScrollAreaPrimitive.Root
15
+
data-slot="scroll-area"
16
+
className={cn("relative", className)}
17
+
{...props}
18
+
>
19
+
<ScrollAreaPrimitive.Viewport
20
+
data-slot="scroll-area-viewport"
21
+
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
22
+
>
23
+
{children}
24
+
</ScrollAreaPrimitive.Viewport>
25
+
<ScrollBar />
26
+
<ScrollAreaPrimitive.Corner />
27
+
</ScrollAreaPrimitive.Root>
28
+
)
29
+
}
30
+
31
+
function ScrollBar({
32
+
className,
33
+
orientation = "vertical",
34
+
...props
35
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
36
+
return (
37
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
38
+
data-slot="scroll-area-scrollbar"
39
+
orientation={orientation}
40
+
className={cn(
41
+
"flex touch-none p-px transition-colors select-none",
42
+
orientation === "vertical" &&
43
+
"h-full w-2.5 border-l border-l-transparent",
44
+
orientation === "horizontal" &&
45
+
"h-2.5 flex-col border-t border-t-transparent",
46
+
className
47
+
)}
48
+
{...props}
49
+
>
50
+
<ScrollAreaPrimitive.ScrollAreaThumb
51
+
data-slot="scroll-area-thumb"
52
+
className="bg-border relative flex-1 rounded-full"
53
+
/>
54
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
55
+
)
56
+
}
57
+
58
+
export { ScrollArea, ScrollBar }
+53
src/lib/hooks/useFeeds.tsx
+53
src/lib/hooks/useFeeds.tsx
···
1
+
import { useEffect, useState } from "react";
2
+
import { useAuth } from "@/lib/useAuth";
3
+
import { useFeedDefsStore } from "../stores/feedDefs";
4
+
import { AtUri } from "@atproto/api";
5
+
6
+
export function useFeeds() {
7
+
const { agent } = useAuth();
8
+
const store = useFeedDefsStore();
9
+
const [isLoading, setLoading] = useState(store.feeds == null);
10
+
11
+
useEffect(() => {
12
+
if (agent == null) return;
13
+
const loadFeeds = async () => {
14
+
try {
15
+
const prefs = await agent.getPreferences();
16
+
if (prefs?.savedFeeds == null) return;
17
+
18
+
for (const feed of prefs.savedFeeds) {
19
+
if (!feed.value.startsWith("at")) continue;
20
+
const urip = AtUri.make(feed.value);
21
+
22
+
console.log("host", urip.host);
23
+
if (!urip.host.startsWith("did:")) {
24
+
const res = await agent.resolveHandle({ handle: urip.host });
25
+
urip.host = res.data.did;
26
+
}
27
+
28
+
console.log("Fetching feed defs", feed);
29
+
if (feed.type == "feed") {
30
+
const feedDef = await agent.app.bsky.feed.getFeedGenerators({
31
+
feeds: [urip.toString()],
32
+
});
33
+
34
+
store.setFeedDef(feed.value, feedDef.data.feeds[0]);
35
+
} else if (feed.type == "list") {
36
+
const listDef = await agent.app.bsky.graph.getList({
37
+
list: urip.toString(),
38
+
});
39
+
40
+
store.setFeedDef(feed.value, {
41
+
displayName: listDef.data.list.name,
42
+
});
43
+
}
44
+
}
45
+
} finally {
46
+
setLoading(false);
47
+
}
48
+
};
49
+
loadFeeds();
50
+
}, [agent]);
51
+
52
+
return { isLoading };
53
+
}
+69
src/lib/hooks/useTimeline.tsx
+69
src/lib/hooks/useTimeline.tsx
···
1
+
// lib/hooks/useFetchTimeline.ts
2
+
import { useEffect, useRef, useCallback } from "react";
3
+
import { AppBskyEmbedImages } from "@atproto/api";
4
+
import { useAuth } from "@/lib/useAuth";
5
+
import { useFeedStore } from "../stores/feeds";
6
+
7
+
export function useFetchTimeline() {
8
+
const { agent } = useAuth();
9
+
const { timeline, setTimeline, appendTimeline, setTimelineLoading } =
10
+
useFeedStore();
11
+
const seenImageUrls = useRef<Set<string>>(new Set());
12
+
13
+
const fetchFeed = useCallback(async () => {
14
+
if (!agent || timeline.isLoading) return;
15
+
setTimelineLoading(true);
16
+
try {
17
+
const response = await agent.getTimeline({
18
+
cursor: timeline.cursor,
19
+
limit: 100,
20
+
});
21
+
if (!response.success) throw new Error("Failed to fetch timeline");
22
+
23
+
const newCursor = response.data.cursor;
24
+
25
+
const filtered = response.data.feed.filter((it) => {
26
+
if (it.reason?.$type === "app.bsky.feed.defs#reasonRepost")
27
+
return false;
28
+
if (
29
+
!(
30
+
AppBskyEmbedImages.isMain(it.post.embed) ||
31
+
AppBskyEmbedImages.isView(it.post.embed)
32
+
)
33
+
)
34
+
return false;
35
+
36
+
const images = (it.post.embed as AppBskyEmbedImages.View)?.images || [];
37
+
const hasNew = images.some(
38
+
(img) => !seenImageUrls.current.has(img.fullsize)
39
+
);
40
+
if (!hasNew) return false;
41
+
42
+
images.forEach((img) => seenImageUrls.current.add(img.fullsize));
43
+
return true;
44
+
});
45
+
46
+
appendTimeline(filtered, newCursor);
47
+
} catch (err) {
48
+
console.error("Fetch failed", err);
49
+
} finally {
50
+
setTimelineLoading(false);
51
+
}
52
+
}, [agent, timeline]);
53
+
54
+
useEffect(() => {
55
+
const loadMinimum = async () => {
56
+
while (
57
+
timeline.posts.flatMap(
58
+
(p) => (p.post.embed as AppBskyEmbedImages.View)?.images || []
59
+
).length < 30
60
+
) {
61
+
await fetchFeed();
62
+
if (!timeline.cursor) break;
63
+
}
64
+
};
65
+
loadMinimum();
66
+
}, [agent]);
67
+
68
+
return { fetchFeed };
69
+
}
+32
src/lib/stores/feedDefs.tsx
+32
src/lib/stores/feedDefs.tsx
···
1
+
import { create } from "zustand";
2
+
import { persist } from "zustand/middleware";
3
+
4
+
export interface BasicFeedItem {
5
+
displayName: string;
6
+
}
7
+
8
+
type FeedDefsState = {
9
+
feeds: Record<string, BasicFeedItem>;
10
+
setFeedDef: (id: string, feed: BasicFeedItem) => void;
11
+
};
12
+
13
+
export const useFeedDefsStore = create<FeedDefsState>()(
14
+
persist(
15
+
(set) => ({
16
+
feeds: {},
17
+
setFeedDef: (id, feed) =>
18
+
set((state) => ({
19
+
feeds: {
20
+
...state.feeds,
21
+
[id]: feed,
22
+
},
23
+
})),
24
+
}),
25
+
{
26
+
name: "feed-defs",
27
+
partialize: (state) => ({
28
+
feeds: state.feeds, // Only persist this part
29
+
}),
30
+
}
31
+
)
32
+
);
+122
src/lib/stores/feeds.tsx
+122
src/lib/stores/feeds.tsx
···
1
+
import { AppBskyFeedDefs } from "@atproto/api";
2
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
3
+
import { create } from "zustand";
4
+
import { combine } from "zustand/middleware";
5
+
6
+
type Post = AppBskyFeedDefs.FeedViewPost;
7
+
8
+
type FeedState = {
9
+
posts: PostView[];
10
+
isLoading: boolean;
11
+
cursor?: string;
12
+
};
13
+
14
+
type FeedMap = Record<string, FeedState>;
15
+
16
+
export const useFeedStore = create(
17
+
combine(
18
+
{
19
+
timeline: {
20
+
posts: [] as Post[],
21
+
isLoading: false,
22
+
cursor: undefined as string | undefined,
23
+
},
24
+
customFeeds: {} as FeedMap,
25
+
},
26
+
(set, get) => ({
27
+
// TIMELINE METHODS
28
+
setTimeline: (posts: Post[], cursor?: string) =>
29
+
set({
30
+
timeline: {
31
+
posts,
32
+
isLoading: false,
33
+
cursor,
34
+
},
35
+
}),
36
+
37
+
appendTimeline: (newPosts: Post[], cursor?: string) =>
38
+
set((state) => {
39
+
const existing = state.timeline.posts;
40
+
const deduped = [
41
+
...existing,
42
+
...newPosts.filter(
43
+
(p) => !existing.some((ep) => ep.post.uri === p.post.uri)
44
+
),
45
+
];
46
+
return {
47
+
timeline: {
48
+
posts: deduped,
49
+
isLoading: false,
50
+
cursor,
51
+
},
52
+
};
53
+
}),
54
+
55
+
setTimelineLoading: (isLoading: boolean) =>
56
+
set((state) => ({
57
+
timeline: {
58
+
...state.timeline,
59
+
isLoading,
60
+
},
61
+
})),
62
+
63
+
// CUSTOM FEED METHODS
64
+
setCustomFeed: (feedId: string, posts: PostView[], cursor?: string) =>
65
+
set((state) => ({
66
+
customFeeds: {
67
+
...state.customFeeds,
68
+
[feedId]: {
69
+
posts,
70
+
isLoading: false,
71
+
cursor,
72
+
},
73
+
},
74
+
})),
75
+
76
+
appendCustomFeed: (
77
+
feedId: string,
78
+
newPosts: PostView[],
79
+
cursor?: string
80
+
) => {
81
+
const current = get().customFeeds[feedId] || {
82
+
posts: [],
83
+
isLoading: false,
84
+
cursor: undefined,
85
+
};
86
+
const deduped = [
87
+
...current.posts,
88
+
...newPosts.filter(
89
+
(p) => !current.posts.some((ep) => ep.uri === p.uri)
90
+
),
91
+
];
92
+
set((state) => ({
93
+
customFeeds: {
94
+
...state.customFeeds,
95
+
[feedId]: {
96
+
posts: deduped,
97
+
isLoading: false,
98
+
cursor,
99
+
},
100
+
},
101
+
}));
102
+
},
103
+
104
+
setCustomFeedLoading: (feedId: string, isLoading: boolean) => {
105
+
const current = get().customFeeds[feedId] || {
106
+
posts: [],
107
+
isLoading: false,
108
+
cursor: undefined,
109
+
};
110
+
set((state) => ({
111
+
customFeeds: {
112
+
...state.customFeeds,
113
+
[feedId]: {
114
+
...current,
115
+
isLoading,
116
+
},
117
+
},
118
+
}));
119
+
},
120
+
})
121
+
)
122
+
);