tangled
alpha
login
or
join now
whey.party
/
forumtest
7
fork
atom
ATProto forum built with ESAV
7
fork
atom
overview
issues
pulls
pipelines
tanstack query
rimar1337
7 months ago
7ba87718
46243fdd
+1460
-1058
10 changed files
expand all
collapse all
unified
split
index.html
src
components
Header.tsx
helpers
cachedidentityresolver.ts
main.tsx
routes
__root.tsx
f
$forumHandle
index.tsx
t
$userHandle
$topicRKey.tsx
$forumHandle.tsx
index.tsx
search.tsx
+1
-1
index.html
···
14
14
<title>ForumTest</title>
15
15
</head>
16
16
<body class="bg-gray-900">
17
17
-
<div id="app" class="overflow-auto h-dvh max-h-dvh"></div>
17
17
+
<div id="app" class="overflow-auto h-dvh max-h-dvh [scrollbar-gutter:stable]"></div>
18
18
<script type="module" src="/src/main.tsx"></script>
19
19
</body>
20
20
</html>
+1
-1
src/components/Header.tsx
···
7
7
export default function Header(){
8
8
9
9
10
10
-
return <div className="flex flex-row h-10 items-center px-2 sticky top-0 bg-gray-700 z-50">
10
10
+
return <div className=" flex flex-row h-10 items-center px-2 sticky top-0 bg-gray-700 z-50">
11
11
<Link to="/"><span className=" text-gray-50 font-bold">ForumTest</span></Link>
12
12
{/* <div className="spacer flex-1" /> */}
13
13
<SearchBox />
+17
src/helpers/cachedidentityresolver.ts
···
46
46
set(`handleDid:${data.did}`, JSON.stringify(data));
47
47
}
48
48
return data;
49
49
+
}
50
50
+
51
51
+
export async function resolveIdentity({
52
52
+
didOrHandle,
53
53
+
}: {
54
54
+
didOrHandle: string;
55
55
+
}): Promise<ResolvedIdentity|undefined> {
56
56
+
const isDidInput = didOrHandle.startsWith("did:");
57
57
+
const url = `https://free-fly-24.deno.dev/?${
58
58
+
isDidInput
59
59
+
? `did=${encodeURIComponent(didOrHandle)}`
60
60
+
: `handle=${encodeURIComponent(didOrHandle)}`
61
61
+
}`;
62
62
+
const res = await fetch(url);
63
63
+
if (!res.ok) throw new Error("Failed to resolve handle/did");
64
64
+
const data = await res.json();
65
65
+
return data;
49
66
}
+11
-3
src/main.tsx
···
9
9
import reportWebVitals from "./reportWebVitals.ts";
10
10
import { AuthProvider } from "./providers/PassAuthProvider.tsx";
11
11
import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx";
12
12
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13
13
+
14
14
+
const queryClient = new QueryClient();
12
15
13
16
// Create a new router instance
14
17
const router = createRouter({
15
18
routeTree,
16
16
-
context: {},
19
19
+
context: {
20
20
+
queryClient,
21
21
+
},
17
22
defaultPreload: "intent",
18
23
scrollRestoration: true,
19
24
defaultStructuralSharing: true,
···
35
40
<StrictMode>
36
41
<PersistentStoreProvider>
37
42
<AuthProvider>
38
38
-
<RouterProvider router={router} />
43
43
+
<QueryClientProvider client={queryClient}>
44
44
+
{/* Pass the router instance with the context to the provider */}
45
45
+
<RouterProvider router={router} />
46
46
+
</QueryClientProvider>
39
47
</AuthProvider>
40
48
</PersistentStoreProvider>
41
49
</StrictMode>
···
45
53
// If you want to start measuring performance in your app, pass a function
46
54
// to log results (for example: reportWebVitals(console.log))
47
55
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
48
48
-
reportWebVitals();
56
56
+
reportWebVitals();
+12
-5
src/routes/__root.tsx
···
1
1
-
import Header from '@/components/Header'
2
2
-
import { Outlet, createRootRoute } from '@tanstack/react-router'
3
3
-
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
1
1
+
import Header from "@/components/Header";
2
2
+
import type { QueryClient } from "@tanstack/react-query";
3
3
+
import {
4
4
+
Outlet,
5
5
+
createRootRoute,
6
6
+
createRootRouteWithContext,
7
7
+
} from "@tanstack/react-router";
8
8
+
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
4
9
5
5
-
export const Route = createRootRoute({
10
10
+
export const Route = createRootRouteWithContext<{
11
11
+
queryClient: QueryClient;
12
12
+
}>()({
6
13
component: () => (
7
14
<>
8
15
<Header />
···
10
17
<TanStackRouterDevtools />
11
18
</>
12
19
),
13
13
-
})
20
20
+
});
+166
-152
src/routes/f/$forumHandle.tsx
···
1
1
import {
2
2
-
cachedResolveIdentity,
2
2
+
resolveIdentity,
3
3
type ResolvedIdentity,
4
4
} from "@/helpers/cachedidentityresolver";
5
5
import { esavQuery } from "@/helpers/esquery";
6
6
-
import { usePersistentStore } from "@/providers/PersistentStoreProvider";
7
7
-
import { createFileRoute, Link, useLoaderData, useNavigate } from "@tanstack/react-router";
6
6
+
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
8
7
import { Outlet } from "@tanstack/react-router";
9
9
-
import { useEffect, useState } from "react";
10
10
-
11
11
-
export const Route = createFileRoute("/f/$forumHandle")({
12
12
-
loader: ({ params }) => {
13
13
-
console.log("[loader] params.forumHandle:", params.forumHandle);
14
14
-
return { forumHandle: params.forumHandle };
15
15
-
},
16
16
-
component: ForumHeader,
17
17
-
});
8
8
+
import { useState } from "react";
9
9
+
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
18
10
19
11
type ForumDoc = {
20
20
-
$type: "com.example.ft.forum.definition";
21
21
-
$metadata: {
22
22
-
uri: string;
23
23
-
did: string;
24
24
-
};
12
12
+
"$metadata.uri": string;
13
13
+
"$metadata.cid": string;
14
14
+
"$metadata.did": string;
15
15
+
"$metadata.collection": string;
16
16
+
"$metadata.rkey": string;
17
17
+
"$metadata.indexedAt": string;
25
18
displayName?: string;
26
19
description?: string;
27
20
$raw?: {
···
30
23
};
31
24
};
32
25
26
26
+
type ResolvedForumData = {
27
27
+
forumDoc: ForumDoc;
28
28
+
identity: ResolvedIdentity;
29
29
+
};
30
30
+
31
31
+
const forumQueryOptions = (queryClient: QueryClient, forumHandle: string) => ({
32
32
+
queryKey: ["forum", forumHandle],
33
33
+
queryFn: async (): Promise<ResolvedForumData> => {
34
34
+
if (!forumHandle) {
35
35
+
throw new Error("Forum handle is required.");
36
36
+
}
37
37
+
const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, "");
38
38
+
39
39
+
const identity = await queryClient.fetchQuery({
40
40
+
queryKey: ["identity", normalizedHandle],
41
41
+
queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }),
42
42
+
staleTime: 1000 * 60 * 60 * 24, // 24 hours
43
43
+
});
44
44
+
45
45
+
if (!identity) {
46
46
+
throw new Error(`Could not resolve forum handle: @${normalizedHandle}`);
47
47
+
}
48
48
+
49
49
+
const forumRes = await esavQuery<{
50
50
+
hits: { hits: { _source: ForumDoc }[] };
51
51
+
}>({
52
52
+
query: {
53
53
+
bool: {
54
54
+
must: [
55
55
+
{ term: { "$metadata.did": identity.did } },
56
56
+
{
57
57
+
term: {
58
58
+
"$metadata.collection": "com.example.ft.forum.definition",
59
59
+
},
60
60
+
},
61
61
+
{ term: { "$metadata.rkey": "self" } },
62
62
+
],
63
63
+
},
64
64
+
},
65
65
+
});
66
66
+
67
67
+
const forumDoc = forumRes.hits.hits[0]?._source;
68
68
+
if (!forumDoc) {
69
69
+
throw new Error("Forum definition not found.");
70
70
+
}
71
71
+
72
72
+
return { forumDoc, identity };
73
73
+
},
74
74
+
});
75
75
+
76
76
+
export const Route = createFileRoute("/f/$forumHandle")({
77
77
+
loader: ({ context: { queryClient }, params }) =>
78
78
+
queryClient.ensureQueryData(
79
79
+
forumQueryOptions(queryClient, params.forumHandle)
80
80
+
),
81
81
+
component: ForumHeader,
82
82
+
pendingComponent: ForumHeaderContentSkeleton,
83
83
+
errorComponent: ({ error }) => (
84
84
+
<div className="text-red-500 text-center pt-10">
85
85
+
Error: {(error as Error).message}
86
86
+
</div>
87
87
+
),
88
88
+
});
89
89
+
33
90
function ForumHeaderContentSkeleton() {
34
91
return (
35
92
<>
···
54
111
<div className="flex items-center justify-between pl-3 pr-[6px] py-1.5">
55
112
<div className="flex flex-wrap items-center gap-3 text-sm">
56
113
{[...Array(6)].map((_, i) => (
57
57
-
<div key={i} className="h-5 w-20 bg-gray-700 rounded animate-pulse" />
114
114
+
<div
115
115
+
key={i}
116
116
+
className="h-5 w-20 bg-gray-700 rounded animate-pulse"
117
117
+
/>
58
118
))}
59
119
</div>
60
120
<div className="relative w-48">
61
61
-
<div className="h-[34px] w-full bg-gray-700 rounded-[11px] animate-pulse" />
121
121
+
<div className="h-[34px] w-full bg-gray-700 rounded-[11px] animate-pulse" />
62
122
</div>
63
123
</div>
64
124
</div>
···
103
163
</form>
104
164
);
105
165
}
106
106
-
function ForumHeaderContent({ forumDoc, identity, forumHandle }:{ forumDoc:ForumDoc, identity: ResolvedIdentity, forumHandle: string }) {
166
166
+
function ForumHeaderContent({
167
167
+
forumDoc,
168
168
+
identity,
169
169
+
forumHandle,
170
170
+
}: {
171
171
+
forumDoc: ForumDoc;
172
172
+
identity: ResolvedIdentity;
173
173
+
forumHandle: string;
174
174
+
}) {
107
175
const did = identity?.did;
108
176
const bannerCid = forumDoc?.$raw?.banner?.ref?.$link;
109
177
const avatarCid = forumDoc?.$raw?.avatar?.ref?.$link;
···
118
186
119
187
return (
120
188
<div className="w-full flex flex-col items-center pt-6">
121
121
-
<div className="w-full max-w-5xl rounded-2xl bg-gray-800 border border-t-0 shadow-2xl overflow-hidden">
122
122
-
<div className="relative w-full h-32">
123
123
-
{bannerUrl ? (
124
124
-
<div
125
125
-
className="absolute inset-0 bg-cover bg-center"
126
126
-
style={{ backgroundImage: `url(${bannerUrl})` }}
127
127
-
/>
128
128
-
) : (
129
129
-
<div className="absolute inset-0 bg-gray-700/50" />
130
130
-
)}
131
131
-
<div className="absolute inset-0 bg-black/60" />
132
132
-
<div className="relative z-10 flex items-center p-6 h-full">
133
133
-
<div className="flex items-center gap-4 max-w-1/2">
134
134
-
{/*//@ts-ignore */}
135
135
-
<Link to={`/f/${forumHandle}`} className="flex items-center gap-4 no-underline">
136
136
-
{avatarUrl ? (
137
137
-
<img
138
138
-
src={avatarUrl}
139
139
-
alt="Forum avatar"
140
140
-
className="w-16 h-16 rounded-full border border-gray-700 object-cover"
141
141
-
/>
142
142
-
) : (
143
143
-
<div className="w-16 h-16 rounded-full bg-gray-700 flex items-center justify-center text-gray-400">
144
144
-
?
145
145
-
</div>
146
146
-
)}
147
147
-
<div>
148
148
-
<div className="text-white text-3xl font-bold">
149
149
-
{forumDoc.displayName || "Unnamed Forum"}
150
150
-
</div>
151
151
-
<div className="text-blue-300 font-mono">
152
152
-
/f/{decodeURIComponent(forumHandle || "")}
153
153
-
</div>
189
189
+
<div className="w-full max-w-5xl rounded-2xl bg-gray-800 border border-t-0 shadow-2xl overflow-hidden">
190
190
+
<div className="relative w-full h-32">
191
191
+
{bannerUrl ? (
192
192
+
<div
193
193
+
className="absolute inset-0 bg-cover bg-center"
194
194
+
style={{ backgroundImage: `url(${bannerUrl})` }}
195
195
+
/>
196
196
+
) : (
197
197
+
<div className="absolute inset-0 bg-gray-700/50" />
198
198
+
)}
199
199
+
<div className="absolute inset-0 bg-black/60" />
200
200
+
<div className="relative z-10 flex items-center p-6 h-full">
201
201
+
<div className="flex items-center gap-4 max-w-1/2">
202
202
+
<Link
203
203
+
//@ts-ignore
204
204
+
to={`/f/${forumHandle}`}
205
205
+
className="flex items-center gap-4 no-underline"
206
206
+
>
207
207
+
{avatarUrl ? (
208
208
+
<img
209
209
+
src={avatarUrl}
210
210
+
alt="Forum avatar"
211
211
+
className="w-16 h-16 rounded-full border border-gray-700 object-cover"
212
212
+
/>
213
213
+
) : (
214
214
+
<div className="w-16 h-16 rounded-full bg-gray-700 flex items-center justify-center text-gray-400">
215
215
+
?
154
216
</div>
155
155
-
</Link>
156
156
-
</div>
157
157
-
<div className="ml-auto text-gray-300 text-base text-end max-w-1/2">
158
158
-
{forumDoc.description || "No description provided."}
159
159
-
</div>
217
217
+
)}
218
218
+
<div>
219
219
+
<div className="text-white text-3xl font-bold">
220
220
+
{forumDoc.displayName || "Unnamed Forum"}
221
221
+
</div>
222
222
+
<div className="text-blue-300 font-mono">
223
223
+
/f/{decodeURIComponent(forumHandle || "")}
224
224
+
</div>
225
225
+
</div>
226
226
+
</Link>
160
227
</div>
161
161
-
</div>
162
162
-
163
163
-
<div className="flex items-center justify-between pl-3 pr-[6px] py-1.5">
164
164
-
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-300 font-medium">
165
165
-
{[
166
166
-
"All Topics",
167
167
-
"Announcements",
168
168
-
"General",
169
169
-
"Support",
170
170
-
"Off-topic",
171
171
-
"Introductions",
172
172
-
"Guides",
173
173
-
"Feedback",
174
174
-
].map((label) => (
175
175
-
<button
176
176
-
key={label}
177
177
-
className="hover:underline hover:text-white transition"
178
178
-
onClick={() => console.log(`Clicked ${label}`)}
179
179
-
>
180
180
-
{label}
181
181
-
</button>
182
182
-
))}
228
228
+
<div className="ml-auto text-gray-300 text-base text-end max-w-1/2">
229
229
+
{forumDoc.description || "No description provided."}
183
230
</div>
231
231
+
</div>
232
232
+
</div>
184
233
185
185
-
<ForumHeaderSearch />
234
234
+
<div className="flex items-center justify-between pl-3 pr-[6px] py-1.5">
235
235
+
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-300 font-medium">
236
236
+
{[
237
237
+
"All Topics",
238
238
+
"Announcements",
239
239
+
"General",
240
240
+
"Support",
241
241
+
"Off-topic",
242
242
+
"Introductions",
243
243
+
"Guides",
244
244
+
"Feedback",
245
245
+
].map((label) => (
246
246
+
<button
247
247
+
key={label}
248
248
+
className="hover:underline hover:text-white transition"
249
249
+
onClick={() => console.log(`Clicked ${label}`)}
250
250
+
>
251
251
+
{label}
252
252
+
</button>
253
253
+
))}
186
254
</div>
255
255
+
256
256
+
<ForumHeaderSearch />
187
257
</div>
188
258
</div>
259
259
+
</div>
189
260
);
190
261
}
191
262
192
263
function ForumHeader() {
193
193
-
const { forumHandle } = useLoaderData({
194
194
-
from: "/f/$forumHandle",
264
264
+
const { forumHandle } = Route.useParams();
265
265
+
const initialData = Route.useLoaderData();
266
266
+
const queryClient = useQueryClient();
267
267
+
268
268
+
const { data } = useQuery({
269
269
+
...forumQueryOptions(queryClient, forumHandle),
270
270
+
initialData,
195
271
});
196
196
-
const { get, set } = usePersistentStore();
197
197
-
const [forumDoc, setForumDoc] = useState<ForumDoc | null>(null);
198
198
-
const [error, setError] = useState<string | null>(null);
199
199
-
const [identity, setIdentity] = useState<ResolvedIdentity | null>(null);
200
272
201
201
-
useEffect(() => {
202
202
-
setForumDoc(null);
203
203
-
setError(null);
204
204
-
setIdentity(null);
205
205
-
206
206
-
async function loadForum() {
207
207
-
if (!forumHandle) return;
208
208
-
209
209
-
try {
210
210
-
const normalizedHandle = decodeURIComponent(forumHandle).replace(
211
211
-
/^@/,
212
212
-
""
213
213
-
);
214
214
-
const identity = await cachedResolveIdentity({
215
215
-
didOrHandle: normalizedHandle,
216
216
-
get,
217
217
-
set,
218
218
-
});
219
219
-
setIdentity(identity);
220
220
-
221
221
-
if (!identity) throw new Error("Could not resolve forum handle");
222
222
-
const resolvedDid = identity.did;
223
223
-
//setDid(resolvedDid);
224
224
-
225
225
-
const forumRes = await esavQuery<{
226
226
-
hits: { hits: { _source: ForumDoc }[] };
227
227
-
}>({
228
228
-
query: {
229
229
-
bool: {
230
230
-
must: [
231
231
-
{ term: { "$metadata.did": resolvedDid } },
232
232
-
{
233
233
-
term: {
234
234
-
"$metadata.collection": "com.example.ft.forum.definition",
235
235
-
},
236
236
-
},
237
237
-
{ term: { "$metadata.rkey": "self" } },
238
238
-
],
239
239
-
},
240
240
-
},
241
241
-
});
242
242
-
243
243
-
const doc = forumRes.hits.hits[0]?._source;
244
244
-
if (!doc) throw new Error("Forum definition not found.");
245
245
-
246
246
-
setForumDoc(doc);
247
247
-
} catch (e) {
248
248
-
setError((e as Error).message);
249
249
-
}
250
250
-
}
251
251
-
252
252
-
loadForum();
253
253
-
}, [forumHandle, get, set]);
254
254
-
255
255
-
if (error) return <div className="text-red-500 text-center pt-10">Error: {error}</div>;
273
273
+
const { forumDoc, identity } = data;
256
274
257
275
return (
258
276
<>
259
259
-
{!forumDoc || !identity ? (
260
260
-
<ForumHeaderContentSkeleton />
261
261
-
) : (
262
262
-
<ForumHeaderContent
263
263
-
forumDoc={forumDoc}
264
264
-
identity={identity}
265
265
-
forumHandle={forumHandle}
266
266
-
/>
267
267
-
)}
277
277
+
<ForumHeaderContent
278
278
+
forumDoc={forumDoc}
279
279
+
identity={identity}
280
280
+
forumHandle={forumHandle}
281
281
+
/>
268
282
<Outlet />
269
283
</>
270
284
);
271
271
-
}
285
285
+
}
+596
-472
src/routes/f/$forumHandle/index.tsx
···
1
1
import {
2
2
createFileRoute,
3
3
-
useLoaderData,
4
3
useNavigate,
5
4
Link,
5
5
+
useParams,
6
6
} from "@tanstack/react-router";
7
7
import { useEffect, useState } from "react";
8
8
import {
9
9
-
cachedResolveIdentity,
9
9
+
resolveIdentity,
10
10
type ResolvedIdentity,
11
11
} from "@/helpers/cachedidentityresolver";
12
12
-
import { usePersistentStore } from "@/providers/PersistentStoreProvider";
13
12
import { esavQuery } from "@/helpers/esquery";
14
13
import * as Select from "@radix-ui/react-select";
15
14
import * as Dialog from "@radix-ui/react-dialog";
16
16
-
import {
17
17
-
ChevronDownIcon,
18
18
-
CheckIcon,
19
19
-
Cross2Icon,
20
20
-
} from "@radix-ui/react-icons";
15
15
+
import { ChevronDownIcon, CheckIcon, Cross2Icon } from "@radix-ui/react-icons";
21
16
import { useAuth } from "@/providers/PassAuthProvider";
22
22
-
import { AtUri } from "@atproto/api";
17
17
+
import { AtUri, BskyAgent } from "@atproto/api";
18
18
+
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
23
19
24
20
type PostDoc = {
25
25
-
$type: "com.example.ft.topic.post";
26
26
-
$metadata: {
27
27
-
uri: string;
28
28
-
did: string;
29
29
-
rkey: string;
30
30
-
indexedAt: string;
31
31
-
};
21
21
+
"$metadata.uri": string;
22
22
+
"$metadata.cid": string;
23
23
+
"$metadata.did": string;
24
24
+
"$metadata.collection": string;
25
25
+
"$metadata.rkey": string;
26
26
+
"$metadata.indexedAt": string;
32
27
forum: string;
33
28
text: string;
34
29
title: string;
35
30
reply?: any;
31
31
+
};
32
32
+
33
33
+
type LatestReply = {
34
34
+
"$metadata.uri": string;
35
35
+
"$metadata.cid": string;
36
36
+
"$metadata.did": string;
37
37
+
"$metadata.collection": string;
38
38
+
"$metadata.rkey": string;
39
39
+
"$metadata.indexedAt": string;
40
40
+
};
41
41
+
42
42
+
type TopReaction = {
43
43
+
emoji: string;
44
44
+
count: number;
45
45
+
};
46
46
+
47
47
+
type EnrichedPostDoc = PostDoc & {
36
48
participants?: string[];
37
49
replyCount?: number;
38
38
-
[key: string]: any;
50
50
+
latestReply: LatestReply | null;
51
51
+
topReaction: TopReaction | null;
52
52
+
};
53
53
+
54
54
+
type ProfileData = {
55
55
+
did: string;
56
56
+
handle: string | null;
57
57
+
pdsUrl: string | null;
58
58
+
profile: {
59
59
+
displayName?: string;
60
60
+
avatar?: { ref: { $link: string } };
61
61
+
} | null;
39
62
};
40
63
41
41
-
export const Route = createFileRoute("/f/$forumHandle/")({
42
42
-
loader: ({ params }) => ({ forumHandle: params.forumHandle }),
43
43
-
component: Forum,
64
64
+
type TopicListData = {
65
65
+
posts: EnrichedPostDoc[];
66
66
+
identity: ResolvedIdentity;
67
67
+
profilesMap: Record<string, ProfileData>;
68
68
+
};
69
69
+
70
70
+
const topicListQueryOptions = (
71
71
+
queryClient: QueryClient,
72
72
+
forumHandle: string
73
73
+
) => ({
74
74
+
queryKey: ["topics", forumHandle],
75
75
+
queryFn: async (): Promise<TopicListData> => {
76
76
+
const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, "");
77
77
+
78
78
+
const identity = await queryClient.fetchQuery({
79
79
+
queryKey: ["identity", normalizedHandle],
80
80
+
queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }),
81
81
+
staleTime: 1000 * 60 * 60 * 24, // 24 hours
82
82
+
});
83
83
+
84
84
+
if (!identity) {
85
85
+
throw new Error(`Could not resolve forum handle: @${normalizedHandle}`);
86
86
+
}
87
87
+
88
88
+
const postRes = await esavQuery<{
89
89
+
hits: { hits: { _source: PostDoc }[] };
90
90
+
}>({
91
91
+
query: {
92
92
+
bool: {
93
93
+
must: [
94
94
+
{ term: { forum: identity.did } },
95
95
+
{ term: { "$metadata.collection": "com.example.ft.topic.post" } },
96
96
+
{ bool: { must_not: [{ exists: { field: "root" } }] } },
97
97
+
],
98
98
+
},
99
99
+
},
100
100
+
sort: [{ "$metadata.indexedAt": { order: "desc" } }],
101
101
+
size: 100,
102
102
+
});
103
103
+
const initialPosts = postRes.hits.hits.map((h) => h._source);
104
104
+
105
105
+
const postsWithDetails = await Promise.all(
106
106
+
initialPosts.map(async (post) => {
107
107
+
const [repliesRes, latestReplyRes] = await Promise.all([
108
108
+
esavQuery<{
109
109
+
hits: { total: { value: number } };
110
110
+
aggregations: { unique_dids: { buckets: { key: string }[] } };
111
111
+
}>({
112
112
+
size: 0,
113
113
+
track_total_hits: true,
114
114
+
query: {
115
115
+
bool: { must: [{ term: { root: post["$metadata.uri"] } }] },
116
116
+
},
117
117
+
aggs: {
118
118
+
unique_dids: { terms: { field: "$metadata.did", size: 10000 } },
119
119
+
},
120
120
+
}),
121
121
+
esavQuery<{
122
122
+
hits: { hits: { _source: LatestReply }[] };
123
123
+
}>({
124
124
+
query: {
125
125
+
bool: { must: [{ term: { root: post["$metadata.uri"] } }] },
126
126
+
},
127
127
+
sort: [{ "$metadata.indexedAt": { order: "desc" } }],
128
128
+
size: 1,
129
129
+
_source: ["$metadata.did", "$metadata.indexedAt"],
130
130
+
}),
131
131
+
]);
132
132
+
133
133
+
const replyCount = repliesRes.hits.total.value;
134
134
+
const replyDids = repliesRes.aggregations.unique_dids.buckets.map(
135
135
+
(b) => b.key
136
136
+
);
137
137
+
const participants = Array.from(
138
138
+
new Set([post["$metadata.did"], ...replyDids])
139
139
+
);
140
140
+
const latestReply = latestReplyRes.hits.hits[0]?._source ?? null;
141
141
+
142
142
+
return { ...post, replyCount, participants, latestReply };
143
143
+
})
144
144
+
);
145
145
+
146
146
+
const postUris = postsWithDetails.map((p) => p["$metadata.uri"]);
147
147
+
const didsToResolve = new Set<string>();
148
148
+
postsWithDetails.forEach((p) => {
149
149
+
didsToResolve.add(p["$metadata.did"]);
150
150
+
p.participants?.forEach((did) => didsToResolve.add(did));
151
151
+
if (p.latestReply) {
152
152
+
didsToResolve.add(p.latestReply["$metadata.did"]);
153
153
+
}
154
154
+
});
155
155
+
const authorDids = Array.from(didsToResolve);
156
156
+
157
157
+
const [reactionsRes, pdsProfiles] = await Promise.all([
158
158
+
esavQuery<{
159
159
+
hits: {
160
160
+
hits: {
161
161
+
_source: { reactionSubject: string; reactionEmoji: string };
162
162
+
}[];
163
163
+
};
164
164
+
}>({
165
165
+
query: {
166
166
+
bool: {
167
167
+
must: [
168
168
+
{
169
169
+
term: {
170
170
+
"$metadata.collection": "com.example.ft.topic.reaction",
171
171
+
},
172
172
+
},
173
173
+
{ terms: { reactionSubject: postUris } },
174
174
+
],
175
175
+
},
176
176
+
},
177
177
+
_source: ["reactionSubject", "reactionEmoji"],
178
178
+
size: 10000,
179
179
+
}),
180
180
+
Promise.all(
181
181
+
authorDids.map(async (did) => {
182
182
+
try {
183
183
+
const identityRes = await queryClient.fetchQuery({
184
184
+
queryKey: ["identity", did],
185
185
+
queryFn: () => resolveIdentity({ didOrHandle: did }),
186
186
+
staleTime: 1000 * 60 * 60 * 24,
187
187
+
});
188
188
+
189
189
+
if (!identityRes?.pdsUrl) {
190
190
+
return {
191
191
+
did,
192
192
+
handle: identityRes?.handle ?? null,
193
193
+
pdsUrl: null,
194
194
+
profile: null,
195
195
+
};
196
196
+
}
197
197
+
198
198
+
const profileUrl = `${identityRes.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`;
199
199
+
const profileReq = await fetch(profileUrl);
200
200
+
201
201
+
if (!profileReq.ok) {
202
202
+
console.warn(
203
203
+
`Failed to fetch profile for ${did} from ${identityRes.pdsUrl}`
204
204
+
);
205
205
+
return {
206
206
+
did,
207
207
+
handle: identityRes.handle,
208
208
+
pdsUrl: identityRes.pdsUrl,
209
209
+
profile: null,
210
210
+
};
211
211
+
}
212
212
+
213
213
+
const profileData = await profileReq.json();
214
214
+
return {
215
215
+
did,
216
216
+
handle: identityRes.handle,
217
217
+
pdsUrl: identityRes.pdsUrl,
218
218
+
profile: profileData.value,
219
219
+
};
220
220
+
} catch (e) {
221
221
+
console.error(`Error resolving or fetching profile for ${did}`, e);
222
222
+
return { did, handle: null, pdsUrl: null, profile: null };
223
223
+
}
224
224
+
})
225
225
+
),
226
226
+
]);
227
227
+
228
228
+
const reactionsByPost: Record<string, Record<string, number>> = {};
229
229
+
for (const hit of reactionsRes.hits.hits) {
230
230
+
const { reactionSubject, reactionEmoji } = hit._source;
231
231
+
if (!reactionsByPost[reactionSubject])
232
232
+
reactionsByPost[reactionSubject] = {};
233
233
+
reactionsByPost[reactionSubject][reactionEmoji] =
234
234
+
(reactionsByPost[reactionSubject][reactionEmoji] || 0) + 1;
235
235
+
}
236
236
+
237
237
+
const topReactions: Record<string, TopReaction> = {};
238
238
+
for (const uri in reactionsByPost) {
239
239
+
const counts = reactionsByPost[uri];
240
240
+
const topEmoji = Object.entries(counts).reduce(
241
241
+
(a, b) => (b[1] > a[1] ? b : a),
242
242
+
["", 0]
243
243
+
);
244
244
+
if (topEmoji[0]) {
245
245
+
topReactions[uri] = { emoji: topEmoji[0], count: topEmoji[1] };
246
246
+
}
247
247
+
}
248
248
+
249
249
+
const profilesMap: Record<string, ProfileData> = {};
250
250
+
for (const p of pdsProfiles) {
251
251
+
profilesMap[p.did] = p;
252
252
+
}
253
253
+
254
254
+
const finalPosts = postsWithDetails.map((post) => ({
255
255
+
...post,
256
256
+
topReaction: topReactions[post["$metadata.uri"]] || null,
257
257
+
}));
258
258
+
259
259
+
return { posts: finalPosts, identity, profilesMap };
260
260
+
},
44
261
});
45
262
46
263
function getRelativeTimeString(input: string | Date): string {
···
49
266
if (isNaN(date.getTime())) return "invalid date";
50
267
const diff = (date.getTime() - now.getTime()) / 1000;
51
268
const units: [Intl.RelativeTimeFormatUnit, number][] = [
52
52
-
["year", 31536000],["month", 2592000],["week", 604800],["day", 86400],["hour", 3600],["minute", 60],["second", 1],
269
269
+
["year", 31536000],
270
270
+
["month", 2592000],
271
271
+
["week", 604800],
272
272
+
["day", 86400],
273
273
+
["hour", 3600],
274
274
+
["minute", 60],
275
275
+
["second", 1],
53
276
];
54
277
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
55
278
for (const [unit, secondsInUnit] of units) {
···
59
282
return "just now";
60
283
}
61
284
285
285
+
export const Route = createFileRoute("/f/$forumHandle/")({
286
286
+
loader: ({ context: { queryClient }, params }) =>
287
287
+
queryClient.ensureQueryData(
288
288
+
topicListQueryOptions(queryClient, params.forumHandle)
289
289
+
),
290
290
+
component: Forum,
291
291
+
pendingComponent: TopicListSkeleton,
292
292
+
errorComponent: ({ error }) => (
293
293
+
<div className="text-red-500 p-8 text-center">
294
294
+
Error: {(error as Error).message}
295
295
+
</div>
296
296
+
),
297
297
+
});
298
298
+
62
299
function ForumHeaderSkeleton() {
63
300
return (
64
301
<div className="flex flex-wrap items-center justify-between mb-4 gap-4 animate-pulse">
···
103
340
</td>
104
341
105
342
<td className="px-4 py-3 text-right rounded-r-lg">
106
106
-
<div className="flex flex-col items-end space-y-1.5">
107
107
-
<div className="h-4 w-24 bg-gray-700 rounded-md" />
108
108
-
<div className="h-3 w-20 bg-gray-600 rounded-md" />
343
343
+
<div className="flex items-center justify-end gap-2">
344
344
+
<div className="flex flex-col items-end space-y-1.5">
345
345
+
<div className="h-4 w-24 bg-gray-700 rounded-md" />
346
346
+
<div className="h-3 w-20 bg-gray-600 rounded-md" />
347
347
+
</div>
348
348
+
<div className="w-8 h-8 rounded-full bg-gray-700" />
109
349
</div>
110
350
</td>
111
351
</tr>
112
352
);
113
353
}
354
354
+
function TopicListSkeleton() {
355
355
+
return (
356
356
+
<div className="w-full flex flex-col items-center pt-6 px-4">
357
357
+
<div className="w-full max-w-5xl">
358
358
+
<ForumHeaderSkeleton />
359
359
+
<table className="w-full table-auto border-separate border-spacing-y-2">
360
360
+
<thead>
361
361
+
<tr className="text-left text-sm text-gray-400">
362
362
+
<th className="px-4 py-2">Topic</th>
363
363
+
<th className="px-4 py-2 text-center">Participants</th>
364
364
+
<th className="px-4 py-2 text-center">Replies</th>
365
365
+
<th className="px-4 py-2 text-center">Reactions</th>
366
366
+
<th className="px-4 py-2 text-right">Last Post</th>
367
367
+
</tr>
368
368
+
</thead>
369
369
+
<tbody>
370
370
+
{Array.from({ length: 10 }).map((_, i) => (
371
371
+
<TopicRowSkeleton key={i} />
372
372
+
))}
373
373
+
</tbody>
374
374
+
</table>
375
375
+
</div>
376
376
+
</div>
377
377
+
);
378
378
+
}
114
379
115
115
-
export function Forum({ forumHandle: propHandle }: { forumHandle?: string }) {
380
380
+
export function Forum() {
116
381
const navigate = useNavigate();
117
382
const { agent, loading: authLoading } = useAuth();
118
118
-
const { forumHandle: routeHandle } = useLoaderData({ from: "/f/$forumHandle/" });
119
119
-
const forumHandle = propHandle ?? routeHandle;
383
383
+
const { forumHandle } = useParams({ from: "/f/$forumHandle/" });
384
384
+
385
385
+
const initialData = Route.useLoaderData();
386
386
+
const queryClient = useQueryClient();
120
387
121
121
-
const { get, set } = usePersistentStore();
122
122
-
const [posts, setPosts] = useState<PostDoc[]>([]);
123
123
-
const [error, setError] = useState<string | null>(null);
124
124
-
const [identity, setIdentity] = useState<ResolvedIdentity | null>(null);
125
125
-
const [participantAvatars, setParticipantAvatars] = useState<Record<string, { avatarCid?: string; pdsUrl: string; handle?: string }>>({});
126
126
-
const [postAuthors, setPostAuthors] = useState<Record<string, string>>({});
127
127
-
const [isLoading, setIsLoading] = useState(true);
388
388
+
const { data } = useQuery({
389
389
+
...topicListQueryOptions(queryClient, forumHandle),
390
390
+
initialData,
391
391
+
refetchInterval: 1000 * 60, // refresh every minute
392
392
+
});
393
393
+
394
394
+
const { posts, identity, profilesMap } = data;
128
395
129
396
const [selectedCategory, setSelectedCategory] = useState("uncategorized");
130
397
const [sortOrder, setSortOrder] = useState("latest");
···
134
401
const [isSubmitting, setIsSubmitting] = useState(false);
135
402
const [formError, setFormError] = useState<string | null>(null);
136
403
137
137
-
useEffect(() => {
138
138
-
async function loadForum() {
139
139
-
if (!forumHandle) return;
140
140
-
141
141
-
setIsLoading(true);
142
142
-
setPosts([]);
143
143
-
setError(null);
144
144
-
145
145
-
try {
146
146
-
const normalizedHandle = decodeURIComponent(forumHandle).replace(
147
147
-
/^@/,
148
148
-
""
149
149
-
);
150
150
-
const identity = await cachedResolveIdentity({
151
151
-
didOrHandle: normalizedHandle,
152
152
-
get,
153
153
-
set,
154
154
-
});
155
155
-
setIdentity(identity);
156
156
-
157
157
-
if (!identity) throw new Error("Could not resolve forum handle");
158
158
-
const resolvedDid = identity.did;
159
159
-
160
160
-
const postRes = await esavQuery<{
161
161
-
hits: { hits: { _source: PostDoc }[] };
162
162
-
}>({
163
163
-
query: {
164
164
-
bool: {
165
165
-
must: [
166
166
-
{ term: { forum: resolvedDid } },
167
167
-
{
168
168
-
term: { "$metadata.collection": "com.example.ft.topic.post" },
169
169
-
},
170
170
-
{ bool: { must_not: [{ exists: { field: "root" } }] } },
171
171
-
],
172
172
-
},
173
173
-
},
174
174
-
sort: [
175
175
-
{
176
176
-
"$metadata.indexedAt": {
177
177
-
order: "desc",
178
178
-
},
179
179
-
},
180
180
-
],
181
181
-
size: 100,
182
182
-
});
183
183
-
184
184
-
const initialPosts = postRes.hits.hits.map((h) => h._source);
185
185
-
186
186
-
const postsWithReplies = await Promise.all(
187
187
-
initialPosts.map(async (post) => {
188
188
-
const topicUri = post["$metadata.uri"];
189
189
-
190
190
-
const repliesRes = await esavQuery<{
191
191
-
hits: { total: { value: number } };
192
192
-
aggregations: {
193
193
-
unique_dids: { buckets: { key: string }[] };
194
194
-
};
195
195
-
}>({
196
196
-
size: 0,
197
197
-
track_total_hits: true,
198
198
-
query: {
199
199
-
bool: {
200
200
-
must: [{ term: { root: topicUri } }],
201
201
-
},
202
202
-
},
203
203
-
aggs: {
204
204
-
unique_dids: {
205
205
-
terms: {
206
206
-
field: "$metadata.did",
207
207
-
size: 10000,
208
208
-
},
209
209
-
},
210
210
-
},
211
211
-
});
212
212
-
213
213
-
const replyCount = repliesRes.hits.total.value;
214
214
-
const replyDids = repliesRes.aggregations.unique_dids.buckets.map(
215
215
-
(bucket) => bucket.key
216
216
-
);
217
217
-
218
218
-
const allParticipants = Array.from(
219
219
-
new Set([post["$metadata.did"], ...replyDids])
220
220
-
);
221
221
-
222
222
-
return {
223
223
-
...post,
224
224
-
replyCount: replyCount,
225
225
-
participants: allParticipants,
226
226
-
};
227
227
-
})
228
228
-
);
229
229
-
230
230
-
setPosts(postsWithReplies);
231
231
-
232
232
-
const authorsToResolve = new Set(
233
233
-
// @ts-ignore
234
234
-
postsWithReplies.map((post) => post["$metadata.did"])
235
235
-
);
236
236
-
237
237
-
const participantsToResolve = new Set<string>();
238
238
-
postsWithReplies.forEach((post) => {
239
239
-
post.participants?.forEach((did) => {
240
240
-
if (did) participantsToResolve.add(did);
241
241
-
});
242
242
-
});
243
243
-
244
244
-
const peopleToResolve = new Set<string>([
245
245
-
...authorsToResolve,
246
246
-
...participantsToResolve,
247
247
-
]);
248
248
-
249
249
-
const resolvedAuthors: Record<string, string> = {};
250
250
-
await Promise.all(
251
251
-
Array.from(peopleToResolve).map(async (did) => {
252
252
-
try {
253
253
-
const identity = await cachedResolveIdentity({
254
254
-
didOrHandle: did,
255
255
-
get,
256
256
-
set,
257
257
-
});
258
258
-
if (identity?.handle) resolvedAuthors[did] = identity.handle;
259
259
-
} catch {}
260
260
-
})
261
261
-
);
262
262
-
263
263
-
setPostAuthors(resolvedAuthors);
264
264
-
} catch (e) {
265
265
-
setError((e as Error).message);
266
266
-
} finally {
267
267
-
setIsLoading(false);
268
268
-
}
269
269
-
}
270
270
-
271
271
-
loadForum();
272
272
-
}, [forumHandle, get, set]);
273
273
-
274
274
-
useEffect(() => {
275
275
-
if (!agent || authLoading || posts.length === 0) return;
276
276
-
277
277
-
const fetchAvatars = async () => {
278
278
-
const participantsToResolve = new Set<string>();
279
279
-
posts.forEach((post) => {
280
280
-
post.participants?.forEach((did) => {
281
281
-
if (did) participantsToResolve.add(did);
282
282
-
});
283
283
-
});
284
284
-
285
285
-
const avatarMap: Record<
286
286
-
string,
287
287
-
{ avatarCid?: string; pdsUrl: string; handle?: string }
288
288
-
> = {};
289
289
-
290
290
-
await Promise.all(
291
291
-
Array.from(participantsToResolve).map(async (did) => {
292
292
-
try {
293
293
-
const identity = await cachedResolveIdentity({
294
294
-
didOrHandle: did,
295
295
-
get,
296
296
-
set,
297
297
-
});
298
298
-
if (!identity) return;
299
299
-
300
300
-
let avatarCid: string | undefined;
301
301
-
try {
302
302
-
const profile = await agent.com.atproto.repo.getRecord({
303
303
-
repo: did,
304
304
-
collection: "app.bsky.actor.profile",
305
305
-
rkey: "self",
306
306
-
});
307
307
-
const rejason = JSON.parse(JSON.stringify(profile, null, 2));
308
308
-
avatarCid = rejason.data?.value?.avatar?.ref?.["$link"];
309
309
-
} catch {}
310
310
-
311
311
-
avatarMap[did] = {
312
312
-
avatarCid,
313
313
-
pdsUrl: identity.pdsUrl,
314
314
-
handle: identity.handle,
315
315
-
};
316
316
-
} catch {}
317
317
-
})
318
318
-
);
319
319
-
320
320
-
setParticipantAvatars(avatarMap);
321
321
-
};
322
322
-
323
323
-
fetchAvatars();
324
324
-
}, [agent, authLoading, posts, get, set]);
325
325
-
326
404
const handleCreateTopic = async () => {
327
405
if (!agent || !agent.did || !identity) {
328
406
setFormError("You must be logged in to create a topic.");
···
337
415
setFormError(null);
338
416
339
417
try {
340
340
-
const topicRecord = {
341
341
-
$type: "com.example.ft.topic.post",
342
342
-
title: newTopicTitle,
343
343
-
text: newTopicText,
344
344
-
createdAt: new Date().toISOString(),
345
345
-
forum: identity.did,
346
346
-
};
347
347
-
348
418
const response = await agent.com.atproto.repo.createRecord({
349
349
-
repo: agent?.did,
419
419
+
repo: agent.did,
350
420
collection: "com.example.ft.topic.post",
351
351
-
record: topicRecord,
421
421
+
record: {
422
422
+
$type: "com.example.ft.topic.post",
423
423
+
title: newTopicTitle,
424
424
+
text: newTopicText,
425
425
+
createdAt: new Date().toISOString(),
426
426
+
forum: identity.did,
427
427
+
},
352
428
});
353
429
354
430
const postUri = new AtUri(response.data.uri);
···
356
432
setIsModalOpen(false);
357
433
setNewTopicTitle("");
358
434
setNewTopicText("");
435
435
+
436
436
+
queryClient.invalidateQueries({ queryKey: ["topics", forumHandle] });
437
437
+
359
438
navigate({
360
360
-
to: `/f/${forumHandle}/t/${agent?.did}/${postUri.rkey}`,
439
439
+
to: `/f/${forumHandle}/t/${agent.did}/${postUri.rkey}`,
361
440
});
362
441
} catch (e) {
363
442
console.error("Failed to create topic:", e);
···
367
446
}
368
447
};
369
448
370
370
-
if (error) return <div className="text-red-500 p-8">Error: {error}</div>;
371
371
-
372
449
return (
373
450
<div className="w-full flex flex-col items-center pt-6 px-4">
374
451
<div className="w-full max-w-5xl">
375
375
-
{isLoading ? (
376
376
-
<ForumHeaderSkeleton />
377
377
-
) : (
378
378
-
<div className="flex flex-wrap items-center justify-between mb-4 gap-4">
379
379
-
<div className="flex items-center gap-2">
380
380
-
<span className="text-gray-100 text-sm">Category:</span>
381
381
-
<Select.Root
382
382
-
value={selectedCategory}
383
383
-
onValueChange={setSelectedCategory}
452
452
+
<div className="flex flex-wrap items-center justify-between mb-4 gap-4">
453
453
+
<div className="flex items-center gap-2">
454
454
+
<span className="text-gray-100 text-sm">Category:</span>
455
455
+
<Select.Root
456
456
+
value={selectedCategory}
457
457
+
onValueChange={setSelectedCategory}
458
458
+
>
459
459
+
<Select.Trigger
460
460
+
className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none"
461
461
+
aria-label="Category"
384
462
>
385
385
-
<Select.Trigger
386
386
-
className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none"
387
387
-
aria-label="Category"
388
388
-
>
389
389
-
<Select.Value placeholder="Select category" />
390
390
-
<Select.Icon className="text-gray-400">
391
391
-
<ChevronDownIcon />
392
392
-
</Select.Icon>
393
393
-
</Select.Trigger>
394
394
-
<Select.Portal>
395
395
-
<Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg">
396
396
-
<Select.Viewport className="p-1">
397
397
-
{["uncategorized", "general", "meta", "support"].map(
398
398
-
(category) => (
399
399
-
<Select.Item
400
400
-
key={category}
401
401
-
value={category}
402
402
-
className="flex items-center px-3 py-2 text-sm hover:bg-gray-700 rounded-md cursor-pointer select-none"
403
403
-
>
404
404
-
<Select.ItemIndicator className="mr-2">
405
405
-
<CheckIcon className="h-3 w-3 text-gray-100" />
406
406
-
</Select.ItemIndicator>
407
407
-
<Select.ItemText>{category}</Select.ItemText>
408
408
-
</Select.Item>
409
409
-
)
410
410
-
)}
411
411
-
</Select.Viewport>
412
412
-
</Select.Content>
413
413
-
</Select.Portal>
414
414
-
</Select.Root>
415
415
-
</div>
416
416
-
417
417
-
<div className="flex items-center gap-2">
418
418
-
<span className="text-gray-100 text-sm">Sort by:</span>
419
419
-
<Select.Root value={sortOrder} onValueChange={setSortOrder}>
420
420
-
<Select.Trigger
421
421
-
className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none"
422
422
-
aria-label="Sort"
423
423
-
>
424
424
-
<Select.Value placeholder="Sort by" />
425
425
-
<Select.Icon className="text-gray-400">
426
426
-
<ChevronDownIcon />
427
427
-
</Select.Icon>
428
428
-
</Select.Trigger>
429
429
-
<Select.Portal>
430
430
-
<Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg">
431
431
-
<Select.Viewport className="p-1">
432
432
-
{["latest", "top", "active", "views"].map((sort) => (
463
463
+
<Select.Value placeholder="Select category" />
464
464
+
<Select.Icon className="text-gray-400">
465
465
+
<ChevronDownIcon />
466
466
+
</Select.Icon>
467
467
+
</Select.Trigger>
468
468
+
<Select.Portal>
469
469
+
<Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg">
470
470
+
<Select.Viewport className="p-1">
471
471
+
{["uncategorized", "general", "meta", "support"].map(
472
472
+
(category) => (
433
473
<Select.Item
434
434
-
key={sort}
435
435
-
value={sort}
474
474
+
key={category}
475
475
+
value={category}
436
476
className="flex items-center px-3 py-2 text-sm hover:bg-gray-700 rounded-md cursor-pointer select-none"
437
477
>
438
478
<Select.ItemIndicator className="mr-2">
439
479
<CheckIcon className="h-3 w-3 text-gray-100" />
440
480
</Select.ItemIndicator>
441
441
-
<Select.ItemText>{sort}</Select.ItemText>
481
481
+
<Select.ItemText>{category}</Select.ItemText>
442
482
</Select.Item>
443
443
-
))}
444
444
-
</Select.Viewport>
445
445
-
</Select.Content>
446
446
-
</Select.Portal>
447
447
-
</Select.Root>
448
448
-
</div>
483
483
+
)
484
484
+
)}
485
485
+
</Select.Viewport>
486
486
+
</Select.Content>
487
487
+
</Select.Portal>
488
488
+
</Select.Root>
489
489
+
</div>
490
490
+
491
491
+
<div className="flex items-center gap-2">
492
492
+
<span className="text-gray-100 text-sm">Sort by:</span>
493
493
+
<Select.Root value={sortOrder} onValueChange={setSortOrder}>
494
494
+
<Select.Trigger
495
495
+
className="inline-flex items-center justify-between rounded-md bg-gray-900 px-3 py-2 text-sm text-gray-100 border border-gray-700 w-[150px] focus:outline-none"
496
496
+
aria-label="Sort"
497
497
+
>
498
498
+
<Select.Value placeholder="Sort by" />
499
499
+
<Select.Icon className="text-gray-400">
500
500
+
<ChevronDownIcon />
501
501
+
</Select.Icon>
502
502
+
</Select.Trigger>
503
503
+
<Select.Portal>
504
504
+
<Select.Content className="z-50 overflow-hidden rounded-md bg-gray-800 text-gray-100 shadow-lg">
505
505
+
<Select.Viewport className="p-1">
506
506
+
{["latest", "top", "active", "views"].map((sort) => (
507
507
+
<Select.Item
508
508
+
key={sort}
509
509
+
value={sort}
510
510
+
className="flex items-center px-3 py-2 text-sm hover:bg-gray-700 rounded-md cursor-pointer select-none"
511
511
+
>
512
512
+
<Select.ItemIndicator className="mr-2">
513
513
+
<CheckIcon className="h-3 w-3 text-gray-100" />
514
514
+
</Select.ItemIndicator>
515
515
+
<Select.ItemText>{sort}</Select.ItemText>
516
516
+
</Select.Item>
517
517
+
))}
518
518
+
</Select.Viewport>
519
519
+
</Select.Content>
520
520
+
</Select.Portal>
521
521
+
</Select.Root>
522
522
+
</div>
449
523
450
450
-
<Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}>
451
451
-
<Dialog.Trigger asChild>
452
452
-
<button
453
453
-
className="ml-auto bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md text-sm font-semibold transition disabled:bg-gray-500"
454
454
-
disabled={!identity}
455
455
-
title={
456
456
-
!identity ? "Loading forum..." : "Create a new topic"
457
457
-
}
458
458
-
>
459
459
-
+ New Topic
460
460
-
</button>
461
461
-
</Dialog.Trigger>
462
462
-
<Dialog.Portal>
463
463
-
<Dialog.Overlay className="bg-black/60 data-[state=open]:animate-overlayShow fixed inset-0 z-50" />
464
464
-
<Dialog.Content className="data-[state=open]:animate-contentShow fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[90vw] max-w-lg p-6 bg-gray-800 text-gray-100 rounded-lg shadow-xl focus:outline-none">
465
465
-
<Dialog.Title className="text-xl font-bold mb-4">
466
466
-
Create New Topic in @{forumHandle}
467
467
-
</Dialog.Title>
468
468
-
<Dialog.Close asChild>
469
469
-
<button
470
470
-
className="absolute top-4 right-4 text-gray-400 hover:text-white"
471
471
-
aria-label="Close"
472
472
-
>
473
473
-
<Cross2Icon />
474
474
-
</button>
475
475
-
</Dialog.Close>
524
524
+
<Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}>
525
525
+
<Dialog.Trigger asChild>
526
526
+
<button
527
527
+
className="ml-auto bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md text-sm font-semibold transition disabled:bg-gray-500"
528
528
+
disabled={!identity}
529
529
+
title={!identity ? "Loading forum..." : "Create a new topic"}
530
530
+
>
531
531
+
+ New Topic
532
532
+
</button>
533
533
+
</Dialog.Trigger>
534
534
+
<Dialog.Portal>
535
535
+
<Dialog.Overlay className="bg-black/60 data-[state=open]:animate-overlayShow fixed inset-0 z-50" />
536
536
+
<Dialog.Content className="data-[state=open]:animate-contentShow fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-[90vw] max-w-lg p-6 bg-gray-800 text-gray-100 rounded-lg shadow-xl focus:outline-none">
537
537
+
<Dialog.Title className="text-xl font-bold mb-4">
538
538
+
Create New Topic in {forumHandle}
539
539
+
</Dialog.Title>
540
540
+
<Dialog.Close asChild>
541
541
+
<button
542
542
+
className="absolute top-4 right-4 text-gray-400 hover:text-white"
543
543
+
aria-label="Close"
544
544
+
>
545
545
+
<Cross2Icon />
546
546
+
</button>
547
547
+
</Dialog.Close>
476
548
477
477
-
{!agent || !agent.did ? (
478
478
-
<div className="text-center py-4">
479
479
-
<p className="text-gray-300">
480
480
-
You must be logged in to create a new topic.
481
481
-
</p>
482
482
-
<span className="inline-block mt-4 bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md font-semibold">
483
483
-
Log In
484
484
-
</span>
485
485
-
</div>
486
486
-
) : (
487
487
-
<form
488
488
-
onSubmit={(e) => {
489
489
-
e.preventDefault();
490
490
-
handleCreateTopic();
491
491
-
}}
492
492
-
>
493
493
-
<fieldset disabled={isSubmitting} className="space-y-4">
494
494
-
<div>
495
495
-
<label
496
496
-
htmlFor="topic-title"
497
497
-
className="text-sm font-medium text-gray-300 block mb-1"
498
498
-
>
499
499
-
Topic Title
500
500
-
</label>
501
501
-
<input
502
502
-
id="topic-title"
503
503
-
value={newTopicTitle}
504
504
-
onChange={(e) => setNewTopicTitle(e.target.value)}
505
505
-
className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
506
506
-
placeholder="A short, descriptive title"
507
507
-
required
508
508
-
/>
509
509
-
</div>
510
510
-
<div>
511
511
-
<label
512
512
-
htmlFor="topic-text"
513
513
-
className="text-sm font-medium text-gray-300 block mb-1"
514
514
-
>
515
515
-
Topic Content
516
516
-
</label>
517
517
-
<textarea
518
518
-
id="topic-text"
519
519
-
value={newTopicText}
520
520
-
onChange={(e) => setNewTopicText(e.target.value)}
521
521
-
className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
522
522
-
rows={8}
523
523
-
placeholder="Write the main content of your topic here..."
524
524
-
required
525
525
-
/>
526
526
-
</div>
527
527
-
</fieldset>
528
528
-
{formError && (
529
529
-
<p className="text-red-400 text-sm mt-2">
530
530
-
{formError}
531
531
-
</p>
532
532
-
)}
533
533
-
<div className="flex justify-end gap-4 mt-6">
534
534
-
<Dialog.Close asChild>
535
535
-
<button
536
536
-
type="button"
537
537
-
className="px-4 py-2 rounded-md bg-gray-600 hover:bg-gray-500 font-semibold"
538
538
-
disabled={isSubmitting}
539
539
-
>
540
540
-
Cancel
541
541
-
</button>
542
542
-
</Dialog.Close>
549
549
+
{!agent || !agent.did ? (
550
550
+
<div className="text-center py-4">
551
551
+
<p className="text-gray-300">
552
552
+
You must be logged in to create a new topic.
553
553
+
</p>
554
554
+
<span className="inline-block mt-4 bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md font-semibold">
555
555
+
Log In
556
556
+
</span>
557
557
+
</div>
558
558
+
) : (
559
559
+
<form
560
560
+
onSubmit={(e) => {
561
561
+
e.preventDefault();
562
562
+
handleCreateTopic();
563
563
+
}}
564
564
+
>
565
565
+
<fieldset disabled={isSubmitting} className="space-y-4">
566
566
+
<div>
567
567
+
<label
568
568
+
htmlFor="topic-title"
569
569
+
className="text-sm font-medium text-gray-300 block mb-1"
570
570
+
>
571
571
+
Topic Title
572
572
+
</label>
573
573
+
<input
574
574
+
id="topic-title"
575
575
+
value={newTopicTitle}
576
576
+
onChange={(e) => setNewTopicTitle(e.target.value)}
577
577
+
className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
578
578
+
placeholder="A short, descriptive title"
579
579
+
required
580
580
+
/>
581
581
+
</div>
582
582
+
<div>
583
583
+
<label
584
584
+
htmlFor="topic-text"
585
585
+
className="text-sm font-medium text-gray-300 block mb-1"
586
586
+
>
587
587
+
Topic Content
588
588
+
</label>
589
589
+
<textarea
590
590
+
id="topic-text"
591
591
+
value={newTopicText}
592
592
+
onChange={(e) => setNewTopicText(e.target.value)}
593
593
+
className="w-full p-2 bg-gray-900 border border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none"
594
594
+
rows={8}
595
595
+
placeholder="Write the main content of your topic here..."
596
596
+
required
597
597
+
/>
598
598
+
</div>
599
599
+
</fieldset>
600
600
+
{formError && (
601
601
+
<p className="text-red-400 text-sm mt-2">{formError}</p>
602
602
+
)}
603
603
+
<div className="flex justify-end gap-4 mt-6">
604
604
+
<Dialog.Close asChild>
543
605
<button
544
544
-
type="submit"
545
545
-
className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-500 font-semibold disabled:bg-gray-500 disabled:cursor-not-allowed"
546
546
-
disabled={
547
547
-
isSubmitting ||
548
548
-
!newTopicTitle.trim() ||
549
549
-
!newTopicText.trim()
550
550
-
}
606
606
+
type="button"
607
607
+
className="px-4 py-2 rounded-md bg-gray-600 hover:bg-gray-500 font-semibold"
608
608
+
disabled={isSubmitting}
551
609
>
552
552
-
{isSubmitting ? "Creating..." : "Create Topic"}
610
610
+
Cancel
553
611
</button>
554
554
-
</div>
555
555
-
</form>
556
556
-
)}
557
557
-
</Dialog.Content>
558
558
-
</Dialog.Portal>
559
559
-
</Dialog.Root>
560
560
-
</div>
561
561
-
)}
612
612
+
</Dialog.Close>
613
613
+
<button
614
614
+
type="submit"
615
615
+
className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-500 font-semibold disabled:bg-gray-500 disabled:cursor-not-allowed"
616
616
+
disabled={
617
617
+
isSubmitting ||
618
618
+
!newTopicTitle.trim() ||
619
619
+
!newTopicText.trim()
620
620
+
}
621
621
+
>
622
622
+
{isSubmitting ? "Creating..." : "Create Topic"}
623
623
+
</button>
624
624
+
</div>
625
625
+
</form>
626
626
+
)}
627
627
+
</Dialog.Content>
628
628
+
</Dialog.Portal>
629
629
+
</Dialog.Root>
630
630
+
</div>
562
631
563
632
<table className="w-full table-auto border-separate border-spacing-y-2">
564
633
<thead>
···
566
635
<th className="px-4 py-2">Topic</th>
567
636
<th className="px-4 py-2 text-center">Participants</th>
568
637
<th className="px-4 py-2 text-center">Replies</th>
569
569
-
<th className="px-4 py-2 text-center">Views</th>
638
638
+
<th className="px-4 py-2 text-center">Reactions</th>
570
639
<th className="px-4 py-2 text-right">Last Post</th>
571
640
</tr>
572
641
</thead>
573
642
<tbody>
574
574
-
{isLoading ? (
575
575
-
Array.from({ length: 10 }).map((_, i) => <TopicRowSkeleton key={i} />)
576
576
-
) : posts.length > 0 ? (
577
577
-
posts.map((post) => (
578
578
-
<tr
579
579
-
onClick={() =>
580
580
-
navigate({
581
581
-
to: `/f/${forumHandle}/t/${post?.["$metadata.did"]}/${post?.["$metadata.rkey"]}`,
582
582
-
})
583
583
-
}
584
584
-
key={post?.["$metadata.uri"]}
585
585
-
className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative"
586
586
-
>
587
587
-
<td className="px-4 py-3 text-white rounded-l-lg">
588
588
-
<Link
589
589
-
// @ts-ignore
590
590
-
to={`/f/${forumHandle}/t/${post?.["$metadata.did"]}/${post?.["$metadata.rkey"]}`}
591
591
-
className="stretched-link"
592
592
-
>
593
593
-
<span className="sr-only">View topic:</span>
594
594
-
</Link>
595
595
-
<div className="font-semibold text-gray-50 line-clamp-1">{post.title}</div>
596
596
-
<div className="text-sm text-gray-400">#general • #meta</div>
597
597
-
</td>
598
598
-
<td className="px-4 py-3">
599
599
-
<div className="flex -space-x-2 justify-center">
600
600
-
{post.participants?.slice(0, 5).map((did) => {
601
601
-
const participant = participantAvatars[did];
602
602
-
const avatarUrl =
603
603
-
participant?.avatarCid && participant?.pdsUrl
604
604
-
? `${participant.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${participant.avatarCid}`
605
605
-
: undefined;
606
606
-
return (
607
607
-
avatarUrl ?
643
643
+
{posts.length > 0 ? (
644
644
+
posts.map((post) => {
645
645
+
const rootAuthorProfile = profilesMap[post["$metadata.did"]];
646
646
+
647
647
+
const lastPostAuthorDid = post.latestReply
648
648
+
? post.latestReply["$metadata.did"]
649
649
+
: post["$metadata.did"];
650
650
+
const lastPostTimestamp = post.latestReply
651
651
+
? post.latestReply["$metadata.indexedAt"]
652
652
+
: post["$metadata.indexedAt"];
653
653
+
const lastPostAuthorProfile = profilesMap[lastPostAuthorDid];
654
654
+
655
655
+
const lastPostAuthorAvatar =
656
656
+
lastPostAuthorProfile?.profile?.avatar?.ref?.$link &&
657
657
+
lastPostAuthorProfile.pdsUrl
658
658
+
? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}`
659
659
+
: undefined;
660
660
+
661
661
+
return (
662
662
+
<tr
663
663
+
onClick={() =>
664
664
+
navigate({
665
665
+
to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`,
666
666
+
})
667
667
+
}
668
668
+
key={post["$metadata.uri"]}
669
669
+
className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative"
670
670
+
>
671
671
+
<td className="px-4 py-3 text-white rounded-l-lg min-w-52">
672
672
+
<Link
673
673
+
// @ts-ignore
674
674
+
to={`/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`}
675
675
+
className="stretched-link"
676
676
+
>
677
677
+
<span className="sr-only">View topic:</span>
678
678
+
</Link>
679
679
+
<div className="font-semibold text-gray-50 line-clamp-1">
680
680
+
{post.title}
681
681
+
</div>
682
682
+
<div className="text-sm text-gray-400">
683
683
+
by{" "}
684
684
+
<span className="font-medium text-gray-300">
685
685
+
{rootAuthorProfile?.handle
686
686
+
? `@${rootAuthorProfile.handle}`
687
687
+
: rootAuthorProfile?.did.slice(4, 12)}
688
688
+
</span>
689
689
+
, {getRelativeTimeString(post["$metadata.indexedAt"])}
690
690
+
</div>
691
691
+
</td>
692
692
+
<td className="px-4 py-3">
693
693
+
<div className="flex -space-x-2 justify-center">
694
694
+
{post.participants?.slice(0, 5).map((did) => {
695
695
+
const participant = profilesMap[did];
696
696
+
const avatarUrl =
697
697
+
participant?.profile?.avatar?.ref?.$link &&
698
698
+
participant?.pdsUrl
699
699
+
? `${participant.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${participant.profile.avatar.ref.$link}`
700
700
+
: undefined;
701
701
+
return avatarUrl ? (
608
702
<img
609
703
key={did}
610
704
src={avatarUrl}
611
705
alt={`@${participant?.handle || did.slice(0, 8)}`}
612
706
className="w-6 h-6 rounded-full border-2 border-gray-800 object-cover bg-gray-700"
613
707
title={`@${participant?.handle || did.slice(0, 8)}`}
614
614
-
/> : <div className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700" />
615
615
-
);
616
616
-
})}
617
617
-
</div>
618
618
-
</td>
619
619
-
<td className="px-4 py-3 text-center text-gray-100 font-medium">
620
620
-
{post.replyCount ?? 0}
621
621
-
</td>
622
622
-
<td className="px-4 py-3 text-center text-gray-300 font-medium">
623
623
-
idk
624
624
-
</td>
625
625
-
<td className="px-4 py-3 text-gray-400 text-right rounded-r-lg">
626
626
-
<div className="text-sm">
627
627
-
by{" "}
628
628
-
<span className="text-blue-400 hover:underline">
629
629
-
{postAuthors[post?.["$metadata.did"]]
630
630
-
? `@${postAuthors[post?.["$metadata.did"]]}`
631
631
-
: post?.["$metadata.did"].slice(4,12)}
632
632
-
</span>
633
633
-
</div>
634
634
-
<div className="text-xs">
635
635
-
{getRelativeTimeString(post?.["$metadata.indexedAt"])}
636
636
-
</div>
637
637
-
</td>
638
638
-
</tr>
639
639
-
))
708
708
+
/>
709
709
+
) : (
710
710
+
<div
711
711
+
key={did}
712
712
+
className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700"
713
713
+
title={`@${participant?.handle || did.slice(0, 8)}`}
714
714
+
/>
715
715
+
);
716
716
+
})}
717
717
+
</div>
718
718
+
</td>
719
719
+
<td className="px-4 py-3 text-center text-gray-100 font-medium">
720
720
+
{(post.replyCount ?? 0) < 1 ? "-" : post.replyCount}
721
721
+
</td>
722
722
+
<td className="px-4 py-3 text-center text-gray-300 font-medium">
723
723
+
{post.topReaction ? (
724
724
+
<div
725
725
+
className="flex items-center justify-center gap-1.5"
726
726
+
title={`${post.topReaction.count} reactions`}
727
727
+
>
728
728
+
<span>{post.topReaction.emoji}</span>
729
729
+
<span className="text-sm font-normal">
730
730
+
{post.topReaction.count}
731
731
+
</span>
732
732
+
</div>
733
733
+
) : (
734
734
+
"-"
735
735
+
)}
736
736
+
</td>
737
737
+
<td className="px-4 py-3 text-gray-400 text-right rounded-r-lg">
738
738
+
<div className="flex items-center justify-end gap-2">
739
739
+
<div className="text-right">
740
740
+
<div className="text-sm font-semibold text-gray-100 line-clamp-1">
741
741
+
{lastPostAuthorProfile?.profile?.displayName ||
742
742
+
(lastPostAuthorProfile?.handle
743
743
+
? `@${lastPostAuthorProfile.handle}`
744
744
+
: "...")}
745
745
+
</div>
746
746
+
<div className="text-xs">
747
747
+
{getRelativeTimeString(lastPostTimestamp)}
748
748
+
</div>
749
749
+
</div>
750
750
+
{lastPostAuthorAvatar ? (
751
751
+
<img
752
752
+
src={lastPostAuthorAvatar}
753
753
+
alt={lastPostAuthorProfile?.profile?.displayName}
754
754
+
className="w-8 h-8 rounded-full object-cover bg-gray-700 shrink-0"
755
755
+
/>
756
756
+
) : (
757
757
+
<div className="w-8 h-8 rounded-full bg-gray-700 shrink-0" />
758
758
+
)}
759
759
+
</div>
760
760
+
</td>
761
761
+
</tr>
762
762
+
);
763
763
+
})
640
764
) : (
641
765
<tr>
642
766
<td colSpan={5} className="text-center text-gray-500 py-10">
···
649
773
</div>
650
774
</div>
651
775
);
652
652
-
}
776
776
+
}
+223
-202
src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx
···
1
1
-
import {
2
2
-
createFileRoute,
3
3
-
useLoaderData,
4
4
-
Link,
5
5
-
} from "@tanstack/react-router";
6
6
-
import { useEffect, useMemo, useState, useCallback } from "react";
1
1
+
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
2
2
+
import { useMemo, useState } from "react";
7
3
import { useAuth } from "@/providers/PassAuthProvider";
8
8
-
import { usePersistentStore } from "@/providers/PersistentStoreProvider";
9
4
import { esavQuery } from "@/helpers/esquery";
10
5
import {
11
11
-
cachedResolveIdentity,
6
6
+
resolveIdentity,
12
7
type ResolvedIdentity,
13
8
} from "@/helpers/cachedidentityresolver";
14
9
import AtpAgent from "@atproto/api";
···
20
15
Cross2Icon,
21
16
} from "@radix-ui/react-icons";
22
17
import * as Popover from "@radix-ui/react-popover";
18
18
+
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
23
19
24
20
type PostDoc = {
25
25
-
$type: "com.example.ft.topic.post";
26
26
-
$metadata: {
27
27
-
uri: string;
28
28
-
cid: string;
29
29
-
did: string;
30
30
-
rkey: string;
31
31
-
indexedAt: string;
32
32
-
};
21
21
+
"$metadata.uri": string;
22
22
+
"$metadata.cid": string;
23
23
+
"$metadata.did": string;
24
24
+
"$metadata.collection": string;
25
25
+
"$metadata.rkey": string;
26
26
+
"$metadata.indexedAt": string;
33
27
forum: string;
34
28
text: string;
35
29
title?: string;
···
41
35
};
42
36
43
37
type ReactionDoc = {
44
44
-
$type: "com.example.ft.topic.reaction";
38
38
+
"$metadata.uri": string;
39
39
+
"$metadata.cid": string;
40
40
+
"$metadata.did": string;
41
41
+
"$metadata.rkey": string;
42
42
+
"$metadata.indexedAt": string;
45
43
reactionEmoji: string;
46
44
reactionSubject: string;
47
45
};
···
52
50
footer?: string;
53
51
};
54
52
53
53
+
type TopicData = {
54
54
+
posts: PostDoc[];
55
55
+
authors: Record<string, AuthorInfo>;
56
56
+
reactions: Record<string, ReactionDoc[]>;
57
57
+
};
58
58
+
55
59
const EMOJI_SELECTION = ["👍", "❤️", "😂", "🔥", "🤔", "🎉", "🙏", "🤯"];
56
60
61
61
+
const topicQueryOptions = (
62
62
+
queryClient: QueryClient,
63
63
+
userHandle: string,
64
64
+
topicRKey: string
65
65
+
) => ({
66
66
+
queryKey: ["topic", userHandle, topicRKey],
67
67
+
queryFn: async (): Promise<TopicData> => {
68
68
+
const authorIdentity = await queryClient.fetchQuery({
69
69
+
queryKey: ["identity", userHandle],
70
70
+
queryFn: () => resolveIdentity({ didOrHandle: userHandle }),
71
71
+
staleTime: 1000 * 60 * 60 * 24,
72
72
+
});
73
73
+
if (!authorIdentity) throw new Error("Could not find topic author.");
74
74
+
75
75
+
const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`;
76
76
+
77
77
+
const [postRes, repliesRes] = await Promise.all([
78
78
+
esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
79
79
+
query: { term: { "$metadata.uri": topicUri } },
80
80
+
size: 1,
81
81
+
}),
82
82
+
esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
83
83
+
query: { term: { root: topicUri } },
84
84
+
sort: [{ "$metadata.indexedAt": "asc" }],
85
85
+
size: 100,
86
86
+
}),
87
87
+
]);
88
88
+
89
89
+
if (postRes.hits.hits.length === 0) throw new Error("Topic not found.");
90
90
+
const mainPost = postRes.hits.hits[0]._source;
91
91
+
const fetchedReplies = repliesRes.hits.hits.map((h) => h._source);
92
92
+
const allPosts = [mainPost, ...fetchedReplies];
93
93
+
94
94
+
const postUris = allPosts.map((p) => p["$metadata.uri"]);
95
95
+
const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))];
96
96
+
97
97
+
const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([
98
98
+
esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({
99
99
+
query: {
100
100
+
bool: {
101
101
+
must: [
102
102
+
{
103
103
+
term: {
104
104
+
"$metadata.collection": "com.example.ft.topic.reaction",
105
105
+
},
106
106
+
},
107
107
+
{ terms: { reactionSubject: postUris } },
108
108
+
],
109
109
+
},
110
110
+
},
111
111
+
_source: ["reactionSubject", "reactionEmoji"],
112
112
+
size: 1000,
113
113
+
}),
114
114
+
esavQuery<{
115
115
+
hits: {
116
116
+
hits: { _source: { "$metadata.did": string; footer: string } }[];
117
117
+
};
118
118
+
}>({
119
119
+
query: {
120
120
+
bool: {
121
121
+
must: [
122
122
+
{ term: { $type: "com.example.ft.user.profile" } },
123
123
+
{ terms: { "$metadata.did": authorDids } },
124
124
+
],
125
125
+
},
126
126
+
},
127
127
+
_source: ["$metadata.did", "footer"],
128
128
+
size: authorDids.length,
129
129
+
}),
130
130
+
Promise.all(
131
131
+
authorDids.map(async (did) => {
132
132
+
try {
133
133
+
const identity = await queryClient.fetchQuery({
134
134
+
queryKey: ["identity", did],
135
135
+
queryFn: () => resolveIdentity({ didOrHandle: did }),
136
136
+
staleTime: 1000 * 60 * 60 * 24,
137
137
+
});
138
138
+
139
139
+
if (!identity?.pdsUrl) {
140
140
+
console.warn(
141
141
+
`Could not resolve PDS for ${did}, cannot fetch profile.`
142
142
+
);
143
143
+
return { did, profile: null };
144
144
+
}
145
145
+
146
146
+
const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`;
147
147
+
const profileRes = await fetch(profileUrl);
148
148
+
149
149
+
if (!profileRes.ok) {
150
150
+
console.warn(
151
151
+
`Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}`
152
152
+
);
153
153
+
return { did, profile: null };
154
154
+
}
155
155
+
156
156
+
const profileData = await profileRes.json();
157
157
+
return { did, profile: profileData.value };
158
158
+
} catch (e) {
159
159
+
console.error(
160
160
+
`Error during decentralized profile fetch for ${did}:`,
161
161
+
e
162
162
+
);
163
163
+
return { did, profile: null };
164
164
+
}
165
165
+
})
166
166
+
),
167
167
+
]);
168
168
+
169
169
+
const reactionsByPostUri = reactionsRes.hits.hits.reduce(
170
170
+
(acc, hit) => {
171
171
+
const reaction = hit._source;
172
172
+
(acc[reaction.reactionSubject] =
173
173
+
acc[reaction.reactionSubject] || []).push(reaction);
174
174
+
return acc;
175
175
+
},
176
176
+
{} as Record<string, ReactionDoc[]>
177
177
+
);
178
178
+
179
179
+
const footersByDid = footersRes.hits.hits.reduce(
180
180
+
(acc, hit) => {
181
181
+
acc[hit._source["$metadata.did"]] = hit._source.footer;
182
182
+
return acc;
183
183
+
},
184
184
+
{} as Record<string, string>
185
185
+
);
186
186
+
187
187
+
const authors: Record<string, AuthorInfo> = {};
188
188
+
await Promise.all(
189
189
+
authorDids.map(async (did) => {
190
190
+
const identity = await queryClient.fetchQuery({
191
191
+
queryKey: ["identity", did],
192
192
+
queryFn: () => resolveIdentity({ didOrHandle: did }),
193
193
+
staleTime: 1000 * 60 * 60 * 24,
194
194
+
});
195
195
+
if (!identity) return;
196
196
+
const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile;
197
197
+
authors[did] = {
198
198
+
...identity,
199
199
+
displayName: pdsProfile?.displayName,
200
200
+
avatarCid: pdsProfile?.avatar?.ref?.["$link"],
201
201
+
footer: footersByDid[did],
202
202
+
};
203
203
+
})
204
204
+
);
205
205
+
206
206
+
return { posts: allPosts, authors, reactions: reactionsByPostUri };
207
207
+
},
208
208
+
});
57
209
export const Route = createFileRoute(
58
210
"/f/$forumHandle/t/$userHandle/$topicRKey"
59
211
)({
60
60
-
loader: ({ params }) => {
61
61
-
return {
62
62
-
forumHandle: decodeURIComponent(params.forumHandle),
63
63
-
userHandle: decodeURIComponent(params.userHandle),
64
64
-
topicRKey: params.topicRKey,
65
65
-
};
66
66
-
},
212
212
+
loader: ({ context: { queryClient }, params }) =>
213
213
+
queryClient.ensureQueryData(
214
214
+
topicQueryOptions(
215
215
+
queryClient,
216
216
+
decodeURIComponent(params.userHandle),
217
217
+
params.topicRKey
218
218
+
)
219
219
+
),
67
220
component: ForumTopic,
221
221
+
pendingComponent: TopicPageSkeleton,
222
222
+
errorComponent: ({ error }) => (
223
223
+
<div className="text-center text-red-500 pt-20 text-lg">
224
224
+
Error: {(error as Error).message}
225
225
+
</div>
226
226
+
),
68
227
});
69
228
70
229
export function PostCardSkeleton() {
···
279
438
}
280
439
281
440
export function ForumTopic() {
282
282
-
const { forumHandle, userHandle, topicRKey } = useLoaderData({
441
441
+
const { forumHandle, userHandle, topicRKey } = useParams({
283
442
from: "/f/$forumHandle/t/$userHandle/$topicRKey",
284
443
});
444
444
+
const { agent, loading: authLoading } = useAuth();
445
445
+
const queryClient = useQueryClient();
446
446
+
const initialData = Route.useLoaderData();
285
447
286
286
-
const { agent, loading: authLoading } = useAuth();
287
287
-
const { get, set } = usePersistentStore();
448
448
+
const { data, isError, error } = useQuery({
449
449
+
...topicQueryOptions(queryClient, userHandle, topicRKey),
450
450
+
initialData,
451
451
+
refetchInterval: 30 * 1000, // refresh every half minute
452
452
+
});
288
453
289
289
-
const [posts, setPosts] = useState<PostDoc[]>([]);
290
290
-
const [authors, setAuthors] = useState<Record<string, AuthorInfo>>({});
291
291
-
const [reactions, setReactions] = useState<Record<string, ReactionDoc[]>>({});
292
292
-
const [error, setError] = useState<string | null>(null);
293
293
-
const [isLoading, setIsLoading] = useState(true);
454
454
+
const { posts, authors, reactions } = data;
294
455
295
456
const [replyText, setReplyText] = useState("");
296
457
const [isSubmitting, setIsSubmitting] = useState(false);
297
458
const [replyingTo, setReplyingTo] = useState<PostDoc | null>(null);
298
459
const [isCreatingReaction, setIsCreatingReaction] = useState(false);
299
299
-
300
300
-
const loadTopic = useCallback(async () => {
301
301
-
setError(null);
302
302
-
try {
303
303
-
const authorIdentity = await cachedResolveIdentity({
304
304
-
didOrHandle: userHandle,
305
305
-
get,
306
306
-
set,
307
307
-
});
308
308
-
if (!authorIdentity) throw new Error("Could not find topic author.");
309
309
-
const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`;
310
310
-
311
311
-
const [postRes, repliesRes] = await Promise.all([
312
312
-
esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
313
313
-
query: { term: { "$metadata.uri": topicUri } },
314
314
-
size: 1,
315
315
-
}),
316
316
-
esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
317
317
-
query: { term: { root: topicUri } },
318
318
-
sort: [{ "$metadata.indexedAt": "asc" }],
319
319
-
size: 100,
320
320
-
}),
321
321
-
]);
322
322
-
323
323
-
if (postRes.hits.hits.length === 0) throw new Error("Topic not found.");
324
324
-
const mainPost = postRes.hits.hits[0]._source;
325
325
-
const fetchedReplies = repliesRes.hits.hits.map((h) => h._source);
326
326
-
const allPosts = [mainPost, ...fetchedReplies];
327
327
-
setPosts(allPosts);
328
328
-
329
329
-
const postUris = allPosts.map((p) => p["$metadata.uri"]);
330
330
-
const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))];
331
331
-
332
332
-
const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([
333
333
-
esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({
334
334
-
query: {
335
335
-
bool: {
336
336
-
must: [
337
337
-
{
338
338
-
term: {
339
339
-
"$metadata.collection": "com.example.ft.topic.reaction",
340
340
-
},
341
341
-
},
342
342
-
{ terms: { reactionSubject: postUris } },
343
343
-
],
344
344
-
},
345
345
-
},
346
346
-
_source: ["reactionSubject", "reactionEmoji"],
347
347
-
size: 1000,
348
348
-
}),
349
349
-
350
350
-
esavQuery<{
351
351
-
hits: {
352
352
-
hits: { _source: { "$metadata.did": string; footer: string } }[];
353
353
-
};
354
354
-
}>({
355
355
-
query: {
356
356
-
bool: {
357
357
-
must: [
358
358
-
{ term: { $type: "com.example.ft.user.profile" } },
359
359
-
{ terms: { "$metadata.did": authorDids } },
360
360
-
],
361
361
-
},
362
362
-
},
363
363
-
_source: ["$metadata.did", "footer"],
364
364
-
size: authorDids.length,
365
365
-
}),
366
366
-
367
367
-
Promise.all(
368
368
-
authorDids.map(async (did) => {
369
369
-
if (!agent) return { did, profile: null };
370
370
-
try {
371
371
-
const res = await agent.com.atproto.repo.getRecord({
372
372
-
repo: did,
373
373
-
collection: "app.bsky.actor.profile",
374
374
-
rkey: "self",
375
375
-
});
376
376
-
return {
377
377
-
did,
378
378
-
profile: JSON.parse(JSON.stringify(res.data.value)),
379
379
-
};
380
380
-
} catch (e) {
381
381
-
return { did, profile: null };
382
382
-
}
383
383
-
})
384
384
-
),
385
385
-
]);
386
386
-
387
387
-
const reactionsByPostUri = reactionsRes.hits.hits.reduce(
388
388
-
(acc, hit) => {
389
389
-
const reaction = hit._source;
390
390
-
(acc[reaction.reactionSubject] = acc[reaction.reactionSubject] || []).push(reaction);
391
391
-
return acc;
392
392
-
},
393
393
-
{} as Record<string, ReactionDoc[]>
394
394
-
);
395
395
-
setReactions(reactionsByPostUri);
396
396
-
397
397
-
const footersByDid = footersRes.hits.hits.reduce(
398
398
-
(acc, hit) => {
399
399
-
acc[hit._source["$metadata.did"]] = hit._source.footer;
400
400
-
return acc;
401
401
-
},
402
402
-
{} as Record<string, string>
403
403
-
);
404
404
-
405
405
-
const newAuthors: Record<string, AuthorInfo> = {};
406
406
-
await Promise.all(
407
407
-
authorDids.map(async (did) => {
408
408
-
const identity = await cachedResolveIdentity({
409
409
-
didOrHandle: did,
410
410
-
get,
411
411
-
set,
412
412
-
});
413
413
-
if (!identity) return;
414
414
-
const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile;
415
415
-
newAuthors[did] = {
416
416
-
...identity,
417
417
-
displayName: pdsProfile?.displayName,
418
418
-
avatarCid: pdsProfile?.avatar?.ref?.["$link"],
419
419
-
footer: footersByDid[did],
420
420
-
};
421
421
-
})
422
422
-
);
423
423
-
setAuthors(newAuthors);
424
424
-
} catch (e) {
425
425
-
setError((e as Error).message);
426
426
-
}
427
427
-
}, [topicRKey, userHandle, get, set, agent]);
428
428
-
429
429
-
useEffect(() => {
430
430
-
if (!authLoading) {
431
431
-
setIsLoading(true);
432
432
-
loadTopic().finally(() => setIsLoading(false));
433
433
-
}
434
434
-
}, [authLoading, loadTopic]);
460
460
+
const [mutationError, setMutationError] = useState<string | null>(null);
435
461
436
462
const handleSetReplyParent = (post: PostDoc) => {
437
463
setReplyingTo(post);
438
464
document.getElementById("reply-box")?.focus();
439
465
};
440
466
467
467
+
const invalidateTopicQuery = () => {
468
468
+
queryClient.invalidateQueries({
469
469
+
queryKey: ["topic", userHandle, topicRKey],
470
470
+
});
471
471
+
};
472
472
+
441
473
const handleCreateReaction = async (post: PostDoc, emoji: string) => {
442
474
if (!agent?.did || isCreatingReaction) return;
443
475
setIsCreatingReaction(true);
444
444
-
const postUri = post["$metadata.uri"];
476
476
+
setMutationError(null);
445
477
try {
446
478
await agent.com.atproto.repo.createRecord({
447
479
repo: agent.did,
···
449
481
record: {
450
482
$type: "com.example.ft.topic.reaction",
451
483
reactionEmoji: emoji,
452
452
-
subject: postUri,
484
484
+
subject: post["$metadata.uri"],
453
485
createdAt: new Date().toISOString(),
454
486
},
455
487
});
456
456
-
const newReaction: ReactionDoc = {
457
457
-
$type: "com.example.ft.topic.reaction",
458
458
-
reactionEmoji: emoji,
459
459
-
reactionSubject: postUri,
460
460
-
};
461
461
-
setReactions((prev) => ({
462
462
-
...prev,
463
463
-
[postUri]: [...(prev[postUri] || []), newReaction],
464
464
-
}));
488
488
+
invalidateTopicQuery();
465
489
} catch (e) {
466
490
console.error("Failed to create reaction", e);
467
467
-
setError("Failed to post reaction. Please try again.");
491
491
+
setMutationError("Failed to post reaction. Please try again.");
468
492
} finally {
469
493
setIsCreatingReaction(false);
470
494
}
···
474
498
if (!agent?.did || isSubmitting || !replyText.trim() || posts.length === 0)
475
499
return;
476
500
setIsSubmitting(true);
477
477
-
setError(null);
501
501
+
setMutationError(null);
478
502
try {
479
503
const rootPost = posts[0];
480
480
-
const rootRef = {
481
481
-
uri: rootPost["$metadata.uri"],
482
482
-
cid: rootPost["$metadata.cid"],
483
483
-
};
484
504
const parentPost = replyingTo || rootPost;
485
485
-
const parentRef = {
486
486
-
uri: parentPost["$metadata.uri"],
487
487
-
cid: parentPost["$metadata.cid"],
488
488
-
};
489
505
await agent.com.atproto.repo.createRecord({
490
506
repo: agent.did,
491
507
collection: "com.example.ft.topic.post",
···
493
509
$type: "com.example.ft.topic.post",
494
510
text: replyText,
495
511
forum: forumHandle,
496
496
-
reply: { root: rootRef, parent: parentRef },
512
512
+
reply: {
513
513
+
root: {
514
514
+
uri: rootPost["$metadata.uri"],
515
515
+
cid: rootPost["$metadata.cid"],
516
516
+
},
517
517
+
parent: {
518
518
+
uri: parentPost["$metadata.uri"],
519
519
+
cid: parentPost["$metadata.cid"],
520
520
+
},
521
521
+
},
497
522
createdAt: new Date().toISOString(),
498
523
},
499
524
});
500
525
setReplyText("");
501
526
setReplyingTo(null);
502
502
-
await loadTopic();
527
527
+
invalidateTopicQuery();
503
528
} catch (e) {
504
504
-
setError(`Failed to post reply: ${(e as Error).message}`);
529
529
+
setMutationError(`Failed to post reply: ${(e as Error).message}`);
505
530
} finally {
506
531
setIsSubmitting(false);
507
532
}
508
533
};
509
534
510
510
-
if (isLoading) return <TopicPageSkeleton />;
511
511
-
if (error)
535
535
+
if (isError)
512
536
return (
513
513
-
<div className="text-center text-red-500 pt-20 text-lg">
514
514
-
Error: {error}
515
515
-
</div>
516
516
-
);
517
517
-
if (posts.length === 0)
518
518
-
return (
519
519
-
<div className="text-center text-gray-400 pt-20 text-lg">
520
520
-
Topic not found.
537
537
+
<div className="text-red-500 p-8 text-center">
538
538
+
Error: {(error as Error).message}
521
539
</div>
522
540
);
523
541
···
580
598
</div>
581
599
)}
582
600
</div>
601
601
+
{mutationError && (
602
602
+
<p className="text-red-400 text-sm mb-2">{mutationError}</p>
603
603
+
)}
583
604
<textarea
584
605
id="reply-box"
585
606
value={replyText}
+153
-125
src/routes/index.tsx
···
1
1
import { createFileRoute, Link } from "@tanstack/react-router";
2
2
-
import { useEffect, useState } from "react";
2
2
+
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
3
3
import "../App.css";
4
4
import { esavQuery } from "@/helpers/esquery";
5
5
-
import { usePersistentStore } from "@/providers/PersistentStoreProvider";
6
6
-
import { cachedResolveIdentity } from "@/helpers/cachedidentityresolver";
5
5
+
import { resolveIdentity } from "@/helpers/cachedidentityresolver";
7
6
8
7
type ForumDoc = {
9
9
-
$type: "com.example.ft.forum.definition";
10
10
-
$metadata: {
11
11
-
collection: string;
12
12
-
uri: string;
13
13
-
did: string;
14
14
-
};
8
8
+
"$metadata.uri": string;
9
9
+
"$metadata.cid": string;
10
10
+
"$metadata.did": string;
11
11
+
"$metadata.collection": string;
12
12
+
"$metadata.rkey": string;
13
13
+
"$metadata.indexedAt": string;
15
14
displayName?: string;
16
15
description?: string;
17
16
$raw?: {
···
32
31
};
33
32
};
34
33
34
34
+
const forumsQueryOptions = (queryClient: QueryClient) => ({
35
35
+
queryKey: ["forums", "list"],
36
36
+
queryFn: async (): Promise<ResolvedForum[]> => {
37
37
+
const res = await esavQuery<{
38
38
+
hits: { hits: { _source: ForumDoc }[] };
39
39
+
}>({
40
40
+
query: {
41
41
+
bool: {
42
42
+
must: [
43
43
+
{
44
44
+
term: {
45
45
+
"$metadata.collection": "com.example.ft.forum.definition",
46
46
+
},
47
47
+
},
48
48
+
{ term: { "$metadata.rkey": "self" } },
49
49
+
],
50
50
+
},
51
51
+
},
52
52
+
size: 50,
53
53
+
});
54
54
+
const rawForums = res.hits.hits.map((hit) => hit._source);
55
55
+
56
56
+
const resolvedForums = (
57
57
+
await Promise.all(
58
58
+
rawForums.map(async (forum) => {
59
59
+
const did = forum?.["$metadata.did"];
60
60
+
if (!did) return null;
61
61
+
62
62
+
try {
63
63
+
const identity = await queryClient.fetchQuery({
64
64
+
queryKey: ["identity", did],
65
65
+
queryFn: () => resolveIdentity({ didOrHandle: did }),
66
66
+
staleTime: 1000 * 60 * 60 * 24,
67
67
+
});
68
68
+
return identity
69
69
+
? {
70
70
+
...forum,
71
71
+
resolvedIdentity: {
72
72
+
handle: identity.handle,
73
73
+
pdsUrl: identity.pdsUrl,
74
74
+
},
75
75
+
}
76
76
+
: null;
77
77
+
} catch (e) {
78
78
+
console.warn(`Failed to resolve identity for ${did}`, e);
79
79
+
return null;
80
80
+
}
81
81
+
})
82
82
+
)
83
83
+
).filter(Boolean) as ResolvedForum[];
84
84
+
85
85
+
return resolvedForums;
86
86
+
},
87
87
+
});
88
88
+
35
89
export const Route = createFileRoute("/")({
90
90
+
loader: ({ context: { queryClient } }) =>
91
91
+
queryClient.ensureQueryData(forumsQueryOptions(queryClient)),
36
92
component: Home,
93
93
+
pendingComponent: ForumGridSkeleton,
94
94
+
errorComponent: ({ error }) => (
95
95
+
<div className="text-red-500 p-4">Error: {(error as Error).message}</div>
96
96
+
),
37
97
});
38
98
99
99
+
function ForumGridSkeleton() {
100
100
+
return (
101
101
+
<div className="w-full flex flex-col items-center">
102
102
+
<div className="w-full max-w-7xl flex items-center flex-col">
103
103
+
<div className="w-full max-w-5xl mt-4 px-4 sm:px-0">
104
104
+
<div>
105
105
+
<span className="text-gray-50 font-bold text-2xl">Forums</span>
106
106
+
</div>
107
107
+
<div className="mt-4 w-full forum-grid">
108
108
+
{Array.from({ length: 6 }).map((_, i) => (
109
109
+
<ForumCardSkeleton key={i} />
110
110
+
))}
111
111
+
</div>
112
112
+
</div>
113
113
+
</div>
114
114
+
</div>
115
115
+
);
116
116
+
}
117
117
+
39
118
function ForumCardSkeleton() {
40
119
return (
41
120
<div className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800 shadow-sm aspect-video animate-pulse">
42
121
<div className="absolute inset-0 bg-black/60" />
43
43
-
44
122
<div className="relative z-10 flex flex-col justify-between h-full p-5">
45
123
<div className="flex justify-between items-start gap-4">
46
124
<div className="flex flex-col">
47
47
-
<div className="h-5 w-40 bg-gray-700 rounded-md mb-2" />
125
125
+
<div className="h-5 w-40 bg-gray-700 rounded-md mb-2" />
48
126
<div className="h-7 w-56 bg-gray-700 rounded-md" />
49
127
</div>
50
128
<div className="w-12 h-12 rounded-full bg-gray-700 flex-shrink-0" />
51
129
</div>
52
52
-
53
130
<div className="flex flex-col gap-2 mt-4">
54
54
-
<div className="h-4 w-full bg-gray-600 rounded-md" />
131
131
+
<div className="h-4 w-full bg-gray-600 rounded-md" />
55
132
<div className="h-4 w-3/4 bg-gray-600 rounded-md" />
56
133
<div className="h-3 w-1/2 bg-gray-700 rounded-md mt-2" />
57
134
</div>
···
61
138
}
62
139
63
140
function Home() {
64
64
-
const [forums, setForums] = useState<ResolvedForum[]>([]);
65
65
-
const [loading, setLoading] = useState(true);
66
66
-
const [error, setError] = useState<string | null>(null);
67
67
-
const { get, set } = usePersistentStore();
68
68
-
69
69
-
useEffect(() => {
70
70
-
async function fetchForums() {
71
71
-
try {
72
72
-
const res = await esavQuery<{
73
73
-
hits: { hits: { _source: ForumDoc }[] };
74
74
-
}>({
75
75
-
query: {
76
76
-
bool: {
77
77
-
must: [
78
78
-
{ term: { "$metadata.collection": "com.example.ft.forum.definition" } },
79
79
-
{ term: { "$metadata.rkey": "self" } },
80
80
-
],
81
81
-
},
82
82
-
},
83
83
-
size: 50,
84
84
-
});
85
85
-
86
86
-
const rawForums = res.hits.hits.map((hit) => hit._source);
87
87
-
88
88
-
const resolvedForums = (
89
89
-
await Promise.all(
90
90
-
rawForums.map(async (forum) => {
91
91
-
const did = forum?.["$metadata.did"];
92
92
-
if (!did) return null;
93
93
-
try {
94
94
-
const identity = await cachedResolveIdentity({ didOrHandle: did, get, set });
95
95
-
return identity ? { ...forum, resolvedIdentity: { handle: identity.handle, pdsUrl: identity.pdsUrl } } : null;
96
96
-
} catch (e) {
97
97
-
console.warn(`Failed to resolve identity for ${did}`, e);
98
98
-
return null;
99
99
-
}
100
100
-
})
101
101
-
)
102
102
-
).filter(Boolean) as ResolvedForum[];
141
141
+
const initialData = Route.useLoaderData();
142
142
+
const queryClient = useQueryClient();
103
143
104
104
-
setForums(resolvedForums);
105
105
-
} catch (err) {
106
106
-
setError((err as Error).message);
107
107
-
} finally {
108
108
-
setLoading(false);
109
109
-
}
110
110
-
}
111
111
-
112
112
-
fetchForums();
113
113
-
}, [get, set]);
144
144
+
const { data: forums }: { data: ResolvedForum[] } = useQuery({
145
145
+
...forumsQueryOptions(queryClient),
146
146
+
initialData,
147
147
+
});
114
148
115
149
return (
116
150
<div className="w-full flex flex-col items-center">
···
121
155
</div>
122
156
123
157
<div className="mt-4 w-full forum-grid">
124
124
-
{loading ? (
125
125
-
Array.from({ length: 6 }).map((_, i) => <ForumCardSkeleton key={i} />)
126
126
-
) : error ? (
127
127
-
<div className="text-red-500 col-span-full text-center py-10">Error: {error}</div>
128
128
-
) : (
129
129
-
forums.map((forum) => {
130
130
-
const did = forum?.["$metadata.did"];
131
131
-
const { resolvedIdentity } = forum;
132
132
-
if (!resolvedIdentity) return null;
158
158
+
{forums.map((forum) => {
159
159
+
const did = forum?.["$metadata.did"];
160
160
+
const { resolvedIdentity } = forum;
161
161
+
if (!resolvedIdentity) return null;
133
162
134
134
-
const cidBanner = forum?.$raw?.banner?.ref?.$link;
135
135
-
const cidAvatar = forum?.$raw?.avatar?.ref?.$link;
163
163
+
const cidBanner = forum?.$raw?.banner?.ref?.$link;
164
164
+
const cidAvatar = forum?.$raw?.avatar?.ref?.$link;
136
165
137
137
-
const bannerUrl =
138
138
-
cidBanner && resolvedIdentity
139
139
-
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}`
140
140
-
: null;
166
166
+
const bannerUrl =
167
167
+
cidBanner && resolvedIdentity
168
168
+
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}`
169
169
+
: null;
141
170
142
142
-
const avatarUrl =
143
143
-
cidAvatar && resolvedIdentity
144
144
-
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}`
145
145
-
: null;
171
171
+
const avatarUrl =
172
172
+
cidAvatar && resolvedIdentity
173
173
+
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}`
174
174
+
: null;
146
175
147
147
-
return (
148
148
-
<Link
176
176
+
return (
177
177
+
<Link
149
178
// @ts-ignore
150
150
-
to={`/f/@${resolvedIdentity.handle}`}
151
151
-
className="block"
179
179
+
to={`/f/@${resolvedIdentity.handle}`}
180
180
+
className="block"
181
181
+
key={forum?.$metadata?.uri}
182
182
+
>
183
183
+
<div
152
184
key={forum?.$metadata?.uri}
185
185
+
className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800 shadow-sm aspect-video hover:border-blue-500/50 transition-all duration-200"
153
186
>
154
154
-
<div
155
155
-
key={forum?.$metadata?.uri}
156
156
-
className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800 shadow-sm aspect-video hover:border-blue-500/50 transition-all duration-200"
157
157
-
>
158
158
-
{bannerUrl && (
159
159
-
<div
160
160
-
className="absolute inset-0 bg-cover bg-center"
161
161
-
style={{ backgroundImage: `url(${bannerUrl})` }}
162
162
-
/>
163
163
-
)}
164
164
-
<div className="absolute inset-0 bg-black/60" />
165
165
-
<div className="relative z-10 flex flex-col justify-between h-full p-5">
166
166
-
<div className="flex justify-between items-start gap-4">
167
167
-
<div className="flex flex-col">
168
168
-
{resolvedIdentity?.handle && (
169
169
-
<div className="text-blue-300 text-base font-mono mb-1">
170
170
-
/f/@{resolvedIdentity.handle}
171
171
-
</div>
172
172
-
)}
173
173
-
<div className="text-white text-2xl font-bold leading-tight">
174
174
-
{forum.displayName || 'Unnamed Forum'}
187
187
+
{bannerUrl && (
188
188
+
<div
189
189
+
className="absolute inset-0 bg-cover bg-center"
190
190
+
style={{ backgroundImage: `url(${bannerUrl})` }}
191
191
+
/>
192
192
+
)}
193
193
+
<div className="absolute inset-0 bg-black/60" />
194
194
+
<div className="relative z-10 flex flex-col justify-between h-full p-5">
195
195
+
<div className="flex justify-between items-start gap-4">
196
196
+
<div className="flex flex-col">
197
197
+
{resolvedIdentity?.handle && (
198
198
+
<div className="text-blue-300 text-base font-mono mb-1">
199
199
+
/f/@{resolvedIdentity.handle}
175
200
</div>
201
201
+
)}
202
202
+
<div className="text-white text-2xl font-bold leading-tight">
203
203
+
{forum.displayName || "Unnamed Forum"}
176
204
</div>
177
177
-
{avatarUrl && (
178
178
-
<img
179
179
-
src={avatarUrl}
180
180
-
alt="Avatar"
181
181
-
className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0"
182
182
-
/>
183
183
-
)}
205
205
+
</div>
206
206
+
{avatarUrl && (
207
207
+
<img
208
208
+
src={avatarUrl}
209
209
+
alt="Avatar"
210
210
+
className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0"
211
211
+
/>
212
212
+
)}
213
213
+
</div>
214
214
+
<div className="flex flex-col gap-2 mt-4">
215
215
+
<div className="text-sm text-gray-200 line-clamp-2">
216
216
+
{forum.description || "No description available."}
184
217
</div>
185
185
-
<div className="flex flex-col gap-2 mt-4">
186
186
-
<div className="text-sm text-gray-200 line-clamp-2">
187
187
-
{forum.description || 'No description available.'}
188
188
-
</div>
189
189
-
<div className="text-xs text-gray-400 font-medium">
190
190
-
0 members · ~0 topics · Active a while ago
191
191
-
</div>
218
218
+
<div className="text-xs text-gray-400 font-medium">
219
219
+
0 members · ~0 topics · Active a while ago
192
220
</div>
193
221
</div>
194
222
</div>
195
195
-
</Link>
196
196
-
);
197
197
-
})
198
198
-
)}
223
223
+
</div>
224
224
+
</Link>
225
225
+
);
226
226
+
})}
199
227
</div>
200
228
</div>
201
229
</div>
202
230
</div>
203
231
);
204
204
-
}
232
232
+
}
+280
-97
src/routes/search.tsx
···
14
14
} from "@/helpers/cachedidentityresolver";
15
15
import AtpAgent, { AtUri } from "@atproto/api";
16
16
import { ArrowRightIcon } from "@radix-ui/react-icons";
17
17
-
import { PostCard, PostCardSkeleton } from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey"; // Adjust path as needed
17
17
+
import {
18
18
+
PostCard,
19
19
+
PostCardSkeleton,
20
20
+
} from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey";
18
21
19
22
type PostDoc = {
20
20
-
$type: "com.example.ft.topic.post";
21
21
-
$metadata: { uri: string; cid: string; did: string; rkey: string; indexedAt: string; };
23
23
+
"$metadata.uri": string;
24
24
+
"$metadata.cid": string;
25
25
+
"$metadata.did": string;
26
26
+
"$metadata.collection": string;
27
27
+
"$metadata.rkey": string;
28
28
+
"$metadata.indexedAt": string;
22
29
forum: string;
23
30
text: string;
24
31
title?: string;
25
25
-
reply?: { root: { uri:string; cid:string; }; parent: { uri:string; cid:string; }; };
32
32
+
reply?: {
33
33
+
root: { uri: string; cid: string };
34
34
+
parent: { uri: string; cid: string };
35
35
+
};
26
36
[key: string]: any;
27
37
};
28
38
type ReactionDoc = {
29
29
-
$type: "com.example.ft.topic.reaction";
39
39
+
"$metadata.uri": string;
40
40
+
"$metadata.cid": string;
41
41
+
"$metadata.did": string;
42
42
+
"$metadata.collection": string;
43
43
+
"$metadata.rkey": string;
44
44
+
"$metadata.indexedAt": string;
30
45
reactionEmoji: string;
31
46
reactionSubject: string;
32
47
};
···
37
52
};
38
53
39
54
export const Route = createFileRoute("/search")({
40
40
-
validateSearch: (search: Record<string, unknown>) => ({ q: typeof search.q === "string" ? search.q : "" }),
55
55
+
validateSearch: (search: Record<string, unknown>) => ({
56
56
+
q: typeof search.q === "string" ? search.q : "",
57
57
+
}),
41
58
component: SearchPage,
42
59
});
43
60
···
53
70
54
71
function SearchResultCard({ post, ...rest }: SearchResultCardProps) {
55
72
const navigate = useNavigate();
56
56
-
const [forumHandle, setForumHandle] = useState<string | undefined>(undefined)
73
73
+
const [forumHandle, setForumHandle] = useState<string | undefined>(undefined);
57
74
const { get, set } = usePersistentStore();
58
75
59
76
const rootUri = useMemo(() => post.root || post["$metadata.uri"], [post]);
60
77
const postUri = post["$metadata.uri"];
61
78
62
62
-
const [threadLink, setThreadLink] = useState<{ to: string, hash: string } | null>(null);
79
79
+
const [threadLink, setThreadLink] = useState<{
80
80
+
to: string;
81
81
+
hash: string;
82
82
+
} | null>(null);
63
83
64
84
useEffect(() => {
65
85
let isCancelled = false;
66
86
const buildLink = async () => {
67
87
try {
68
88
const rootAtUri = new AtUri(rootUri);
69
69
-
const authorIdentity = await cachedResolveIdentity({ didOrHandle: rootAtUri.hostname, get, set });
70
70
-
setForumHandle("@"+authorIdentity?.handle)
89
89
+
const authorIdentity = await cachedResolveIdentity({
90
90
+
didOrHandle: rootAtUri.hostname,
91
91
+
get,
92
92
+
set,
93
93
+
});
94
94
+
setForumHandle("@" + authorIdentity?.handle);
71
95
if (!isCancelled && authorIdentity?.handle) {
72
96
setThreadLink({
73
73
-
to: '/f/$forumHandle/t/$userHandle/$topicRKey',
97
97
+
to: "/f/$forumHandle/t/$userHandle/$topicRKey",
74
98
hash: postUri,
75
99
});
76
100
}
77
77
-
} catch(e) {
78
78
-
console.error("Failed to build thread link for search result", e)
101
101
+
} catch (e) {
102
102
+
console.error("Failed to build thread link for search result", e);
79
103
}
80
104
};
81
105
buildLink();
82
82
-
return () => { isCancelled = true; };
106
106
+
return () => {
107
107
+
isCancelled = true;
108
108
+
};
83
109
}, [rootUri, postUri, get, set]);
84
84
-
110
110
+
85
111
const handleNavigateToPost = () => {
86
112
if (!threadLink) return;
87
113
const rootAtUri = new AtUri(rootUri);
···
89
115
if (!authorIdentity?.handle) return;
90
116
91
117
navigate({
92
92
-
to: threadLink.to,
93
93
-
params: {
94
94
-
forumHandle: post.forum,
95
95
-
userHandle: authorIdentity.handle,
96
96
-
topicRKey: rootAtUri.rkey,
97
97
-
},
98
98
-
hash: threadLink.hash
118
118
+
to: threadLink.to,
119
119
+
params: {
120
120
+
forumHandle: post.forum,
121
121
+
userHandle: authorIdentity.handle,
122
122
+
topicRKey: rootAtUri.rkey,
123
123
+
},
124
124
+
hash: threadLink.hash,
99
125
});
100
126
};
101
127
···
104
130
<div className="flex justify-between items-center px-4 py-2.5 border-b border-gray-700/50">
105
131
<span className="text-sm text-gray-400">
106
132
From forum:{" "}
107
107
-
<Link
133
133
+
<Link
108
134
to="/f/$forumHandle"
109
135
params={{ forumHandle: post.forum }}
110
136
className="font-semibold text-blue-300 hover:underline"
···
113
139
</Link>
114
140
</span>
115
141
{threadLink ? (
116
116
-
<Link
117
117
-
to={threadLink.to}
118
118
-
params={{
119
119
-
forumHandle: post.forum,
120
120
-
userHandle: authors[new AtUri(rootUri).hostname]?.handle || '', // Needs pre-fetched author handle
121
121
-
topicRKey: new AtUri(rootUri).rkey
122
122
-
}}
123
123
-
hash={threadLink.hash}
124
124
-
className="flex items-center gap-2 text-sm font-semibold text-blue-300 hover:text-white transition-colors"
125
125
-
>
126
126
-
View Full Thread <ArrowRightIcon />
127
127
-
</Link>
142
142
+
<Link
143
143
+
to={threadLink.to}
144
144
+
params={{
145
145
+
forumHandle: post.forum,
146
146
+
userHandle: authors[new AtUri(rootUri).hostname]?.handle || "",
147
147
+
topicRKey: new AtUri(rootUri).rkey,
148
148
+
}}
149
149
+
hash={threadLink.hash}
150
150
+
className="flex items-center gap-2 text-sm font-semibold text-blue-300 hover:text-white transition-colors"
151
151
+
>
152
152
+
View Full Thread <ArrowRightIcon />
153
153
+
</Link>
128
154
) : (
129
129
-
<span className="flex items-center gap-2 text-sm font-semibold text-gray-500">
130
130
-
View Full Thread <ArrowRightIcon />
131
131
-
</span>
155
155
+
<span className="flex items-center gap-2 text-sm font-semibold text-gray-500">
156
156
+
View Full Thread <ArrowRightIcon />
157
157
+
</span>
132
158
)}
133
159
</div>
134
160
135
135
-
<PostCard
136
136
-
{...rest}
137
137
-
post={post}
138
138
-
onSetReplyParent={handleNavigateToPost}
139
139
-
/>
161
161
+
<PostCard {...rest} post={post} onSetReplyParent={handleNavigateToPost} />
140
162
</div>
141
163
);
142
164
}
···
148
170
149
171
const { agent, loading: authLoading } = useAuth();
150
172
const { get, set } = usePersistentStore();
151
151
-
173
173
+
152
174
const [results, setResults] = useState<PostDoc[]>([]);
153
175
const [reactions, setReactions] = useState<Record<string, ReactionDoc[]>>({});
154
176
const [_authors, setAuthors] = useState<Record<string, AuthorInfo>>({});
155
177
const [error, setError] = useState<string | null>(null);
156
178
const [isLoading, setIsLoading] = useState(false);
157
179
const [isCreatingReaction, setIsCreatingReaction] = useState(false);
158
158
-
180
180
+
159
181
useEffect(() => {
160
182
authors = _authors;
161
183
}, [_authors]);
162
184
163
163
-
const performSearch = useCallback(async (query: string) => {
164
164
-
if (!query.trim()) { setResults([]); return; }
165
165
-
setIsLoading(true);
166
166
-
setError(null);
167
167
-
setResults([]);
168
168
-
setAuthors({});
169
169
-
setReactions({});
170
170
-
try {
171
171
-
const searchRes = await esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
172
172
-
query: { bool: { must: { multi_match: { query: query, fields: ["text", "title^2"], }, }, filter: [ { term: { "$metadata.collection": "com.example.ft.topic.post" } }, ], }, },
173
173
-
sort: [ { _score: "desc" }, { "$metadata.indexedAt": "desc" } ],
174
174
-
size: 25,
175
175
-
});
176
176
-
const foundPosts = searchRes.hits.hits.map((hit) => hit._source);
177
177
-
if (foundPosts.length === 0) { setIsLoading(false); return; }
178
178
-
setResults(foundPosts);
179
179
-
180
180
-
const allUris = foundPosts.flatMap(p => [p["$metadata.uri"], p.reply?.root.uri]).filter(Boolean) as string[];
181
181
-
const uniqueUris = [...new Set(allUris)];
182
182
-
const allDids = [...new Set(uniqueUris.map(uri => new AtUri(uri).hostname))];
183
183
-
184
184
-
const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([
185
185
-
esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ query: { bool: { must: [{ term: { "$metadata.collection": "com.example.ft.topic.reaction" } }], filter: [{ terms: { reactionSubject: allUris.filter(u => u.includes('post')) } }], }, }, _source: ["reactionSubject", "reactionEmoji"], size: 1000, }),
186
186
-
esavQuery<{ hits: { hits: { _source: { "$metadata.did": string; footer: string } }[] } }>({ query: { bool: { must: [{ term: { $type: "com.example.ft.user.profile" } }], filter: [{ terms: { "$metadata.did": allDids } }], }, }, _source: ["$metadata.did", "footer"], size: allDids.length, }),
187
187
-
Promise.all(allDids.map(async (did) => { if (!agent) return { did, profile: null }; try { const res = await agent.com.atproto.repo.getRecord({ repo: did, collection: "app.bsky.actor.profile", rkey: "self", }); return { did, profile: JSON.parse(JSON.stringify(res.data.value)) }; } catch (e) { return { did, profile: null }; } })),
188
188
-
]);
189
189
-
190
190
-
const reactionsByPostUri = reactionsRes.hits.hits.reduce((acc, hit) => { const r = hit._source; (acc[r.reactionSubject] = acc[r.reactionSubject] || []).push(r); return acc; }, {} as Record<string, ReactionDoc[]>);
191
191
-
setReactions(reactionsByPostUri);
185
185
+
const performSearch = useCallback(
186
186
+
async (query: string) => {
187
187
+
if (!query.trim()) {
188
188
+
setResults([]);
189
189
+
return;
190
190
+
}
191
191
+
setIsLoading(true);
192
192
+
setError(null);
193
193
+
setResults([]);
194
194
+
setAuthors({});
195
195
+
setReactions({});
196
196
+
try {
197
197
+
const searchRes = await esavQuery<{
198
198
+
hits: { hits: { _source: PostDoc }[] };
199
199
+
}>({
200
200
+
query: {
201
201
+
bool: {
202
202
+
must: {
203
203
+
multi_match: { query: query, fields: ["text", "title^2"] },
204
204
+
},
205
205
+
filter: [
206
206
+
{
207
207
+
term: { "$metadata.collection": "com.example.ft.topic.post" },
208
208
+
},
209
209
+
],
210
210
+
},
211
211
+
},
212
212
+
sort: [{ _score: "desc" }, { "$metadata.indexedAt": "desc" }],
213
213
+
size: 25,
214
214
+
});
215
215
+
const foundPosts = searchRes.hits.hits.map((hit) => hit._source);
216
216
+
if (foundPosts.length === 0) {
217
217
+
setIsLoading(false);
218
218
+
return;
219
219
+
}
220
220
+
setResults(foundPosts);
221
221
+
222
222
+
const allUris = foundPosts
223
223
+
.flatMap((p) => [p["$metadata.uri"], p.reply?.root.uri])
224
224
+
.filter(Boolean) as string[];
225
225
+
const uniqueUris = [...new Set(allUris)];
226
226
+
const allDids = [
227
227
+
...new Set(uniqueUris.map((uri) => new AtUri(uri).hostname)),
228
228
+
];
229
229
+
230
230
+
const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([
231
231
+
esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({
232
232
+
query: {
233
233
+
bool: {
234
234
+
must: [
235
235
+
{
236
236
+
term: {
237
237
+
"$metadata.collection": "com.example.ft.topic.reaction",
238
238
+
},
239
239
+
},
240
240
+
],
241
241
+
filter: [
242
242
+
{
243
243
+
terms: {
244
244
+
reactionSubject: allUris.filter((u) =>
245
245
+
u.includes("post")
246
246
+
),
247
247
+
},
248
248
+
},
249
249
+
],
250
250
+
},
251
251
+
},
252
252
+
_source: ["reactionSubject", "reactionEmoji"],
253
253
+
size: 1000,
254
254
+
}),
255
255
+
esavQuery<{
256
256
+
hits: {
257
257
+
hits: { _source: { "$metadata.did": string; footer: string } }[];
258
258
+
};
259
259
+
}>({
260
260
+
query: {
261
261
+
bool: {
262
262
+
must: [{ term: { $type: "com.example.ft.user.profile" } }],
263
263
+
filter: [{ terms: { "$metadata.did": allDids } }],
264
264
+
},
265
265
+
},
266
266
+
_source: ["$metadata.did", "footer"],
267
267
+
size: allDids.length,
268
268
+
}),
269
269
+
Promise.all(
270
270
+
allDids.map(async (did) => {
271
271
+
if (!agent) return { did, profile: null };
272
272
+
try {
273
273
+
const res = await agent.com.atproto.repo.getRecord({
274
274
+
repo: did,
275
275
+
collection: "app.bsky.actor.profile",
276
276
+
rkey: "self",
277
277
+
});
278
278
+
return {
279
279
+
did,
280
280
+
profile: JSON.parse(JSON.stringify(res.data.value)),
281
281
+
};
282
282
+
} catch (e) {
283
283
+
return { did, profile: null };
284
284
+
}
285
285
+
})
286
286
+
),
287
287
+
]);
192
288
193
193
-
const footersByDid = footersRes.hits.hits.reduce((acc, hit) => { acc[hit._source["$metadata.did"]] = hit._source.footer; return acc; }, {} as Record<string, string>);
289
289
+
const reactionsByPostUri = reactionsRes.hits.hits.reduce(
290
290
+
(acc, hit) => {
291
291
+
const r = hit._source;
292
292
+
(acc[r.reactionSubject] = acc[r.reactionSubject] || []).push(r);
293
293
+
return acc;
294
294
+
},
295
295
+
{} as Record<string, ReactionDoc[]>
296
296
+
);
297
297
+
setReactions(reactionsByPostUri);
194
298
195
195
-
const newAuthors: Record<string, AuthorInfo> = {};
196
196
-
await Promise.all(allDids.map(async (did) => {
197
197
-
const identity = await cachedResolveIdentity({ didOrHandle: did, get, set });
198
198
-
if (!identity) return;
199
199
-
const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile;
200
200
-
newAuthors[did] = { ...identity, displayName: pdsProfile?.displayName, avatarCid: pdsProfile?.avatar?.ref?.["$link"], footer: footersByDid[did], };
201
201
-
}));
202
202
-
setAuthors(newAuthors);
203
203
-
} catch (e) { console.error("Search failed:", e); setError("An error occurred during the search."); } finally { setIsLoading(false); }
204
204
-
}, [agent, get, set]);
299
299
+
const footersByDid = footersRes.hits.hits.reduce(
300
300
+
(acc, hit) => {
301
301
+
acc[hit._source["$metadata.did"]] = hit._source.footer;
302
302
+
return acc;
303
303
+
},
304
304
+
{} as Record<string, string>
305
305
+
);
306
306
+
307
307
+
const newAuthors: Record<string, AuthorInfo> = {};
308
308
+
await Promise.all(
309
309
+
allDids.map(async (did) => {
310
310
+
const identity = await cachedResolveIdentity({
311
311
+
didOrHandle: did,
312
312
+
get,
313
313
+
set,
314
314
+
});
315
315
+
if (!identity) return;
316
316
+
const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile;
317
317
+
newAuthors[did] = {
318
318
+
...identity,
319
319
+
displayName: pdsProfile?.displayName,
320
320
+
avatarCid: pdsProfile?.avatar?.ref?.["$link"],
321
321
+
footer: footersByDid[did],
322
322
+
};
323
323
+
})
324
324
+
);
325
325
+
setAuthors(newAuthors);
326
326
+
} catch (e) {
327
327
+
console.error("Search failed:", e);
328
328
+
setError("An error occurred during the search.");
329
329
+
} finally {
330
330
+
setIsLoading(false);
331
331
+
}
332
332
+
},
333
333
+
[agent, get, set]
334
334
+
);
205
335
206
336
useEffect(() => {
207
337
if (!authLoading) performSearch(q);
···
212
342
setIsCreatingReaction(true);
213
343
const postUri = post["$metadata.uri"];
214
344
try {
215
215
-
await agent.com.atproto.repo.createRecord({ repo: agent.did, collection: "com.example.ft.topic.reaction", record: { $type: "com.example.ft.topic.reaction", reactionEmoji: emoji, subject: postUri, createdAt: new Date().toISOString(), }, });
216
216
-
const newReaction: ReactionDoc = { $type: "com.example.ft.topic.reaction", reactionEmoji: emoji, reactionSubject: postUri };
217
217
-
setReactions((prev) => ({ ...prev, [postUri]: [...(prev[postUri] || []), newReaction] }));
218
218
-
} catch (e) { console.error("Failed to create reaction", e); setError("Failed to post reaction."); } finally { setIsCreatingReaction(false); }
345
345
+
const date = new Date().toISOString();
346
346
+
const response = await agent.com.atproto.repo.createRecord({
347
347
+
repo: agent.did,
348
348
+
collection: "com.example.ft.topic.reaction",
349
349
+
record: {
350
350
+
$type: "com.example.ft.topic.reaction",
351
351
+
reactionEmoji: emoji,
352
352
+
subject: postUri,
353
353
+
createdAt: date,
354
354
+
},
355
355
+
});
356
356
+
const uri = new AtUri(response.data.uri)
357
357
+
const newReaction: ReactionDoc = {
358
358
+
"$metadata.collection": "com.example.ft.topic.reaction",
359
359
+
"$metadata.uri": response.data.uri,
360
360
+
"$metadata.cid": response.data.cid,
361
361
+
"$metadata.did": agent.did,
362
362
+
"$metadata.rkey": uri.rkey,
363
363
+
"$metadata.indexedAt": date,
364
364
+
reactionEmoji: emoji,
365
365
+
reactionSubject: postUri,
366
366
+
};
367
367
+
setReactions((prev) => ({
368
368
+
...prev,
369
369
+
[postUri]: [...(prev[postUri] || []), newReaction],
370
370
+
}));
371
371
+
} catch (e) {
372
372
+
console.error("Failed to create reaction", e);
373
373
+
setError("Failed to post reaction.");
374
374
+
} finally {
375
375
+
setIsCreatingReaction(false);
376
376
+
}
219
377
};
220
378
221
379
const renderContent = () => {
222
222
-
if (isLoading) return <div className="space-y-4">{Array.from({ length: 3 }).map((_, i) => <PostCardSkeleton key={i} />)}</div>;
223
223
-
if (error) return <div className="text-center text-red-400 p-8">{error}</div>;
224
224
-
if (!q.trim()) return <div className="text-center text-gray-400 p-8">Enter a search term to begin.</div>;
225
225
-
if (results.length === 0) return <div className="text-center text-gray-400 p-8">No results found for "{q}".</div>;
380
380
+
if (isLoading)
381
381
+
return (
382
382
+
<div className="space-y-4">
383
383
+
{Array.from({ length: 3 }).map((_, i) => (
384
384
+
<PostCardSkeleton key={i} />
385
385
+
))}
386
386
+
</div>
387
387
+
);
388
388
+
if (error)
389
389
+
return <div className="text-center text-red-400 p-8">{error}</div>;
390
390
+
if (!q.trim())
391
391
+
return (
392
392
+
<div className="text-center text-gray-400 p-8">
393
393
+
Enter a search term to begin.
394
394
+
</div>
395
395
+
);
396
396
+
if (results.length === 0)
397
397
+
return (
398
398
+
<div className="text-center text-gray-400 p-8">
399
399
+
No results found for "{q}".
400
400
+
</div>
401
401
+
);
226
402
return (
227
403
<div className="space-y-4">
228
404
{results.map((post, index) => (
···
240
416
</div>
241
417
);
242
418
};
243
243
-
419
419
+
244
420
return (
245
421
<div className="w-full flex flex-col items-center pt-6 px-4 pb-12">
246
422
<div className="w-full max-w-5xl space-y-4">
247
247
-
<h1 className="text-2xl font-bold text-gray-100 mb-2">Search Results</h1>
248
248
-
{q && <p className="text-gray-400">Showing results for: <span className="font-semibold text-gray-200">"{q}"</span></p>}
423
423
+
<h1 className="text-2xl font-bold text-gray-100 mb-2">
424
424
+
Search Results
425
425
+
</h1>
426
426
+
{q && (
427
427
+
<p className="text-gray-400">
428
428
+
Showing results for:{" "}
429
429
+
<span className="font-semibold text-gray-200">"{q}"</span>
430
430
+
</p>
431
431
+
)}
249
432
<div className="mt-6">{renderContent()}</div>
250
433
</div>
251
434
</div>
252
435
);
253
253
-
}
436
436
+
}