+11
-11
README.md
+11
-11
README.md
···
12
custom record types:
13
```json
14
"record_types": [
15
-
"com.example.ft.topic.post",
16
-
"com.example.ft.topic.reaction",
17
-
"com.example.ft.topic.moderation",
18
-
"com.example.ft.forum.definition",
19
-
"com.example.ft.forum.layout",
20
-
"com.example.ft.forum.request",
21
-
"com.example.ft.forum.accept",
22
-
"com.example.ft.forum.category"
23
],
24
```
25
26
custom indexes:
27
```json
28
"index_fields": {
29
-
"com.example.ft.topic.reaction": {
30
"subject": {
31
"id": "reactionSubject",
32
"type": "keyword"
···
36
"type": "keyword"
37
}
38
},
39
-
"com.example.ft.topic.post": {
40
"text": {
41
"id": "text",
42
"type": "text"
···
58
"type": "keyword"
59
}
60
},
61
-
"com.example.ft.forum.definition": {
62
"description": {
63
"id": "description",
64
"type": "text"
···
12
custom record types:
13
```json
14
"record_types": [
15
+
"party.whey.ft.topic.post",
16
+
"party.whey.ft.topic.reaction",
17
+
"party.whey.ft.topic.moderation",
18
+
"party.whey.ft.forum.definition",
19
+
"party.whey.ft.forum.layout",
20
+
"party.whey.ft.forum.request",
21
+
"party.whey.ft.forum.accept",
22
+
"party.whey.ft.forum.category"
23
],
24
```
25
26
custom indexes:
27
```json
28
"index_fields": {
29
+
"party.whey.ft.topic.reaction": {
30
"subject": {
31
"id": "reactionSubject",
32
"type": "keyword"
···
36
"type": "keyword"
37
}
38
},
39
+
"party.whey.ft.topic.post": {
40
"text": {
41
"id": "text",
42
"type": "text"
···
58
"type": "keyword"
59
}
60
},
61
+
"party.whey.ft.forum.definition": {
62
"description": {
63
"id": "description",
64
"type": "text"
+30
package-lock.json
+30
package-lock.json
···
18
"@tanstack/react-router-devtools": "^1.130.2",
19
"@tanstack/router-plugin": "^1.121.2",
20
"idb-keyval": "^6.2.2",
21
"react": "^19.0.0",
22
"react-dom": "^19.0.0",
23
"tailwindcss": "^4.1.11"
···
3647
"license": "MIT",
3648
"bin": {
3649
"jiti": "lib/jiti-cli.mjs"
3650
}
3651
},
3652
"node_modules/js-tokens": {
···
18
"@tanstack/react-router-devtools": "^1.130.2",
19
"@tanstack/router-plugin": "^1.121.2",
20
"idb-keyval": "^6.2.2",
21
+
"jotai": "^2.13.0",
22
"react": "^19.0.0",
23
"react-dom": "^19.0.0",
24
"tailwindcss": "^4.1.11"
···
3648
"license": "MIT",
3649
"bin": {
3650
"jiti": "lib/jiti-cli.mjs"
3651
+
}
3652
+
},
3653
+
"node_modules/jotai": {
3654
+
"version": "2.13.0",
3655
+
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.0.tgz",
3656
+
"integrity": "sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==",
3657
+
"license": "MIT",
3658
+
"engines": {
3659
+
"node": ">=12.20.0"
3660
+
},
3661
+
"peerDependencies": {
3662
+
"@babel/core": ">=7.0.0",
3663
+
"@babel/template": ">=7.0.0",
3664
+
"@types/react": ">=17.0.0",
3665
+
"react": ">=17.0.0"
3666
+
},
3667
+
"peerDependenciesMeta": {
3668
+
"@babel/core": {
3669
+
"optional": true
3670
+
},
3671
+
"@babel/template": {
3672
+
"optional": true
3673
+
},
3674
+
"@types/react": {
3675
+
"optional": true
3676
+
},
3677
+
"react": {
3678
+
"optional": true
3679
+
}
3680
}
3681
},
3682
"node_modules/js-tokens": {
+1
package.json
+1
package.json
+111
src/esav/ESAVLiveProvider.tsx
+111
src/esav/ESAVLiveProvider.tsx
···
···
1
+
import { useSetAtom, useStore } from "jotai";
2
+
import { useEffect, useRef, type PropsWithChildren } from "react";
3
+
import { addLogEntryAtom, documentsAtom, queryStateFamily, websocketAtom, websocketStatusAtom } from './atoms';
4
+
import type { QueryDeltaMessage } from "./types";
5
+
6
+
export function ESAVLiveProvider({
7
+
children,
8
+
url,
9
+
}: PropsWithChildren<{ url: string }>) {
10
+
const store = useStore();
11
+
const setWebsocket = useSetAtom(websocketAtom);
12
+
const setWebsocketStatus = useSetAtom(websocketStatusAtom);
13
+
const addLog = useSetAtom(addLogEntryAtom);
14
+
15
+
const reconnectTimer = useRef<number | null>(null);
16
+
17
+
const isUnmounting = useRef(false);
18
+
19
+
useEffect(() => {
20
+
let reconnectAttempts = 0;
21
+
const connect = () => {
22
+
if (isUnmounting.current) return;
23
+
24
+
console.log(`[ESAV] Connecting (Attempt ${reconnectAttempts + 1})...`);
25
+
setWebsocketStatus("connecting");
26
+
const ws = new WebSocket(url);
27
+
28
+
ws.onopen = () => {
29
+
console.log("[ESAV] WebSocket connection opened");
30
+
setWebsocketStatus("open");
31
+
setWebsocket(ws);
32
+
reconnectAttempts = 0;
33
+
if (reconnectTimer.current) {
34
+
clearTimeout(reconnectTimer.current);
35
+
}
36
+
};
37
+
38
+
ws.onmessage = (event) => {
39
+
try {
40
+
const message = JSON.parse(event.data);
41
+
42
+
if (message.type === "query-delta") {
43
+
addLog({ type: 'incoming', payload: message });
44
+
const deltaMessage = message as QueryDeltaMessage;
45
+
const { documents, queries } = deltaMessage
46
+
47
+
if (documents) {
48
+
store.set(documentsAtom, (prev) => ({ ...prev, ...documents }));
49
+
}
50
+
51
+
if (queries) {
52
+
for (const queryId in queries) {
53
+
const targetQueryAtom = queryStateFamily(queryId);
54
+
store.set(targetQueryAtom, queries[queryId]);
55
+
}
56
+
}
57
+
} else if (message.type === "ping") {
58
+
ws.send(JSON.stringify({ type: "pong" }));
59
+
} else if (message.type === "error") {
60
+
addLog({ type: 'incoming', payload: message });
61
+
console.error("[ESAV] Received error from server:", message.error);
62
+
}
63
+
} catch (e) {
64
+
console.error("[ESAV] Failed to parse message from server", e);
65
+
}
66
+
};
67
+
ws.onclose = () => {
68
+
console.log("[ESAV] WebSocket connection closed");
69
+
setWebsocket(null);
70
+
if (isUnmounting.current) {
71
+
console.log("[ESAV] Unmounting, not reconnecting.");
72
+
return;
73
+
}
74
+
75
+
setWebsocketStatus("closed");
76
+
77
+
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000);
78
+
console.log(`[ESAV] Will attempt to reconnect in ${delay / 1000}s`);
79
+
reconnectAttempts++;
80
+
81
+
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
82
+
reconnectTimer.current = setTimeout(connect, delay);
83
+
};
84
+
85
+
ws.onerror = (err) => {
86
+
console.error("[ESAV] WebSocket error", err);
87
+
ws.close();
88
+
};
89
+
};
90
+
91
+
isUnmounting.current = false;
92
+
connect();
93
+
94
+
return () => {
95
+
isUnmounting.current = true;
96
+
console.log(
97
+
"[ESAV] Provider unmounting. Cleaning up timers and connection."
98
+
);
99
+
if (reconnectTimer.current) {
100
+
clearTimeout(reconnectTimer.current);
101
+
}
102
+
const currentWs = store.get(websocketAtom);
103
+
if (currentWs) {
104
+
currentWs.onclose = null;
105
+
currentWs.close();
106
+
}
107
+
};
108
+
}, [url, store, setWebsocket, setWebsocketStatus]);
109
+
110
+
return <>{children}</>;
111
+
}
+69
src/esav/atoms.ts
+69
src/esav/atoms.ts
···
···
1
+
import { atom } from 'jotai';
2
+
import { atomFamily } from 'jotai/utils';
3
+
import type { EsavDocument, QueryState, LogEntry } from './types';
4
+
const MAX_LOG_SIZE = 500;
5
+
6
+
/**
7
+
* Manages the WebSocket instance itself.
8
+
* Should only be written to by the provider.
9
+
*/
10
+
export const websocketAtom = atom<WebSocket | null>(null);
11
+
12
+
/**
13
+
* Tracks the current status of the WebSocket connection.
14
+
*/
15
+
export const websocketStatusAtom = atom<'connecting' | 'open' | 'closed'>('closed');
16
+
17
+
/**
18
+
* A global, normalized cache for all documents received from the server.
19
+
* Maps a document URI (at://...) to its full data.
20
+
* This prevents data duplication across multiple queries.
21
+
*/
22
+
export const documentsAtom = atom<Record<string, EsavDocument>>({});
23
+
24
+
/**
25
+
* A family of atoms to hold the state for each individual query.
26
+
* You get the state for a query by providing its unique queryId.
27
+
*/
28
+
export const queryStateFamily = atomFamily((_queryId: string) =>
29
+
atom<QueryState | null>(null)
30
+
);
31
+
32
+
/**
33
+
* Tracks active subscriptions and their component usage count.
34
+
* This is an internal atom used by our hooks to know when to
35
+
* send `subscribe` and `unsubscribe` messages.
36
+
*/
37
+
export const activeSubscriptionsAtom = atom<
38
+
Record<string, { count: number; esQuery: Record<string, any> }>
39
+
>({});
40
+
41
+
42
+
/**
43
+
* Holds the array of log entries for display.
44
+
*/
45
+
export const logEntriesAtom = atom<LogEntry[]>([]);
46
+
47
+
let logIdCounter = 0;
48
+
49
+
/**
50
+
* A "write-only" atom to add a new entry to the log.
51
+
* This encapsulates the logic for creating a new entry with an ID and timestamp.
52
+
* Any component can call this to add a log without needing to know the implementation details.
53
+
*/
54
+
export const addLogEntryAtom = atom(
55
+
null,
56
+
(get, set, newEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
57
+
const entry: LogEntry = {
58
+
id: logIdCounter++,
59
+
timestamp: new Date(),
60
+
...newEntry,
61
+
};
62
+
const currentLog = get(logEntriesAtom);
63
+
const newLog = [entry, ...currentLog];
64
+
if (newLog.length > MAX_LOG_SIZE) {
65
+
newLog.length = MAX_LOG_SIZE;
66
+
}
67
+
set(logEntriesAtom, newLog);
68
+
}
69
+
);
+167
src/esav/components.tsx
+167
src/esav/components.tsx
···
···
1
+
import { useAtomValue } from "jotai";
2
+
import { useState } from "react";
3
+
import { websocketStatusAtom, logEntriesAtom } from "./atoms";
4
+
import type { LogEntry } from "./types";
5
+
6
+
7
+
export function ReconnectingHeader() {
8
+
const status = useAtomValue(websocketStatusAtom);
9
+
10
+
if (status === "open") {
11
+
return null;
12
+
}
13
+
14
+
const message =
15
+
status === "connecting"
16
+
? "Connecting to ESAV Live..."
17
+
: "Connection lost. Attempting to reconnect...";
18
+
19
+
return (
20
+
<div
21
+
style={{
22
+
position: "sticky",
23
+
top: 0,
24
+
left: 0,
25
+
width: "100%",
26
+
padding: "8px",
27
+
backgroundColor: "#ffc107",
28
+
color: "#333",
29
+
textAlign: "center",
30
+
fontWeight: "bold",
31
+
zIndex: 1000,
32
+
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
33
+
}}
34
+
>
35
+
{message}
36
+
</div>
37
+
);
38
+
}
39
+
40
+
const LogEntryItem = ({ entry }: { entry: LogEntry }) => {
41
+
const { type, timestamp, payload } = entry;
42
+
43
+
const typeStyles = {
44
+
incoming: { icon: "⬇️", color: "#4caf50", name: "Incoming" },
45
+
outgoing: { icon: "⬆️", color: "#ffeb3b", name: "Outgoing" },
46
+
status: { icon: "ℹ️", color: "#2196f3", name: "Status" },
47
+
error: { icon: "❌", color: "#f44336", name: "Error" },
48
+
};
49
+
50
+
const { icon, color, name } = typeStyles[type];
51
+
52
+
return (
53
+
<div
54
+
style={{
55
+
borderBottom: "1px solid #444",
56
+
padding: "8px",
57
+
fontFamily: "monospace",
58
+
fontSize: "12px",
59
+
borderLeft: `4px solid ${color}`,
60
+
}}
61
+
>
62
+
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
63
+
<span style={{ marginRight: "8px" }}>{icon}</span>
64
+
{name}
65
+
<span style={{ float: "right", color: "#888" }}>
66
+
{timestamp.toLocaleTimeString()}
67
+
</span>
68
+
</div>
69
+
{typeof payload === "object" ? (
70
+
<pre
71
+
style={{
72
+
margin: 0,
73
+
padding: "8px",
74
+
backgroundColor: "rgba(0,0,0,0.2)",
75
+
borderRadius: "4px",
76
+
whiteSpace: "pre-wrap",
77
+
wordBreak: "break-all",
78
+
fontSize: "11px",
79
+
maxHeight: "200px",
80
+
overflowY: "auto",
81
+
}}
82
+
>
83
+
{JSON.stringify(payload, null, 2)}
84
+
</pre>
85
+
) : (
86
+
<code style={{ color: "#ccc" }}>{String(payload)}</code>
87
+
)}
88
+
</div>
89
+
);
90
+
};
91
+
92
+
export function DeltaLogViewer() {
93
+
const [open, setOpen] = useState(false);
94
+
const log = useAtomValue(logEntriesAtom);
95
+
96
+
return (
97
+
<div
98
+
style={{
99
+
position: "fixed",
100
+
bottom: "10px",
101
+
right: "10px",
102
+
width: open ? "min(850px,90dvw)" : "280px",
103
+
backgroundColor: "#2d2d2d",
104
+
color: "#f1f1f1",
105
+
border: "1px solid #555",
106
+
borderRadius: "8px",
107
+
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
108
+
zIndex: 2000,
109
+
overflow: "hidden",
110
+
display: "flex",
111
+
flexDirection: "column",
112
+
maxHeight: "600px",
113
+
transition: "width 0.1s ease",
114
+
}}
115
+
>
116
+
<div
117
+
style={{
118
+
display: "flex",
119
+
justifyContent: "space-between",
120
+
alignItems: "center",
121
+
padding: "8px 12px",
122
+
backgroundColor: "#3c3c3c",
123
+
borderBottom: "1px solid #555",
124
+
fontWeight: 700,
125
+
}}
126
+
>
127
+
<span>ESAV Live Log</span>
128
+
<button
129
+
onClick={() => setOpen(!open)}
130
+
style={{
131
+
background: "transparent",
132
+
border: "none",
133
+
color: "#ccc",
134
+
fontSize: "16px",
135
+
cursor: "pointer",
136
+
padding: "4px 8px",
137
+
borderRadius: "4px",
138
+
transition: "background 0.01s",
139
+
}}
140
+
onMouseEnter={(e) => (e.currentTarget.style.background = "#444")}
141
+
onMouseLeave={(e) =>
142
+
(e.currentTarget.style.background = "transparent")
143
+
}
144
+
title={open ? "Collapse log" : "Expand log"}
145
+
>
146
+
{open ? "close" : "open"}
147
+
</button>
148
+
</div>
149
+
<div
150
+
style={{
151
+
flex: 1,
152
+
overflowY: "auto",
153
+
display: open ? "flex" : "none",
154
+
flexDirection: "column",
155
+
}}
156
+
>
157
+
{log.length === 0 ? (
158
+
<div style={{ padding: "10px", color: "#888" }}>
159
+
Waiting for events...
160
+
</div>
161
+
) : (
162
+
log.map((entry) => <LogEntryItem key={entry.id} entry={entry} />)
163
+
)}
164
+
</div>
165
+
</div>
166
+
);
167
+
}
+313
src/esav/hooks.ts
+313
src/esav/hooks.ts
···
···
1
+
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
2
+
import { useEffect, useMemo, useRef, useState } from 'react';
3
+
import {
4
+
activeSubscriptionsAtom,
5
+
documentsAtom,
6
+
queryStateFamily,
7
+
websocketAtom,
8
+
websocketStatusAtom,
9
+
addLogEntryAtom
10
+
} from './atoms';
11
+
import type { EsavDocument, QueryDoc, SubscribeMessage, UnsubscribeMessage } from './types';
12
+
import { atomWithStorage } from 'jotai/utils';
13
+
14
+
interface UseEsavQueryOptions {
15
+
enabled?: boolean;
16
+
}
17
+
18
+
/**
19
+
* The primary hook for subscribing to a live query and getting its results.
20
+
* Manages sending subscribe/unsubscribe messages automatically.
21
+
*
22
+
* @param queryId A unique ID for this query.
23
+
* @param esQuery The full Elasticsearch query object.
24
+
* @param options Hook options, like `enabled`.
25
+
* @returns The hydrated query results and loading status.
26
+
*/
27
+
export function useEsavQuery(
28
+
queryId: string,
29
+
esQuery: Record<string, any>,
30
+
options: UseEsavQueryOptions = { enabled: true }
31
+
) {
32
+
// @ts-expect-error intended
33
+
const [activeSubscriptions, setActiveSubscriptions] = useAtom(activeSubscriptionsAtom);
34
+
const ws = useAtomValue(websocketAtom);
35
+
const addLog = useSetAtom(addLogEntryAtom);
36
+
const wsStatus = useAtomValue(websocketStatusAtom);
37
+
const queryState = useAtomValue(queryStateFamily(queryId));
38
+
const allDocuments = useAtomValue(documentsAtom);
39
+
40
+
const { enabled = true } = options;
41
+
const stringifiedEsQuery = useMemo(() => JSON.stringify(esQuery), [esQuery]);
42
+
43
+
const esQueryRef = useRef(esQuery);
44
+
const queryStateRef = useRef(queryState);
45
+
useEffect(() => {
46
+
esQueryRef.current = esQuery;
47
+
queryStateRef.current = queryState;
48
+
});
49
+
50
+
useEffect(() => {
51
+
if (!enabled || wsStatus !== 'open' || !ws) {
52
+
return;
53
+
}
54
+
55
+
const currentQuery = esQueryRef.current;
56
+
57
+
setActiveSubscriptions((prev) => {
58
+
const count = prev[queryId]?.count ?? 0;
59
+
if (count === 0) {
60
+
console.log(`[ESAV] Subscribing to ${queryId}`);
61
+
const message: SubscribeMessage = {
62
+
type: 'subscribe',
63
+
queryId,
64
+
esquery: currentQuery,
65
+
ecid: queryStateRef.current?.ecid,
66
+
};
67
+
addLog({ type: 'outgoing', payload: message });
68
+
ws.send(JSON.stringify(message));
69
+
}
70
+
return { ...prev, [queryId]: { count: count + 1, esQuery: currentQuery } };
71
+
});
72
+
73
+
return () => {
74
+
setActiveSubscriptions((prev) => {
75
+
const count = prev[queryId]?.count ?? 1;
76
+
if (count <= 1) {
77
+
console.log(`[ESAV] Unsubscribing from ${queryId}`);
78
+
if (ws.readyState === WebSocket.OPEN) {
79
+
const message: UnsubscribeMessage = { type: 'unsubscribe', queryId };
80
+
addLog({ type: 'outgoing', payload: message });
81
+
ws.send(JSON.stringify(message));
82
+
}
83
+
const { [queryId]: _, ...rest } = prev;
84
+
return rest;
85
+
} else {
86
+
return { ...prev, [queryId]: { ...prev[queryId], count: count - 1 } };
87
+
}
88
+
});
89
+
};
90
+
}, [queryId, stringifiedEsQuery, enabled, ws, wsStatus, setActiveSubscriptions]);
91
+
92
+
93
+
const hydratedData = useMemo(() => {
94
+
if (!queryState?.result) return [];
95
+
return queryState.result
96
+
.map((uri) => allDocuments[uri])
97
+
.filter(Boolean);
98
+
}, [queryState?.result, allDocuments]);
99
+
100
+
const isLoading = wsStatus !== 'open' || queryState === null;
101
+
102
+
return {
103
+
data: hydratedData,
104
+
uris: queryState?.result ?? [],
105
+
ecid: queryState?.ecid,
106
+
isLoading,
107
+
status: wsStatus,
108
+
};
109
+
}
110
+
111
+
type DocumentMap = Record<string, EsavDocument | undefined>;
112
+
113
+
/**
114
+
* A simple hook to get a single document from the global cache.
115
+
* @param uri The at:// URI of the document.
116
+
*/
117
+
export function useEsavDocument(uri: string): EsavDocument | undefined;
118
+
export function useEsavDocument(uri: string[]): DocumentMap;
119
+
export function useEsavDocument(uri: undefined): undefined;
120
+
export function useEsavDocument(uri: string | string[] | undefined): EsavDocument | undefined | DocumentMap {
121
+
const allDocuments = useAtomValue(documentsAtom);
122
+
123
+
if (typeof uri === 'string') {
124
+
return allDocuments[uri];
125
+
}
126
+
127
+
if (Array.isArray(uri)) {
128
+
return uri.reduce<DocumentMap>((acc, key) => {
129
+
acc[key] = allDocuments[key];
130
+
return acc;
131
+
}, {});
132
+
}
133
+
134
+
return undefined;
135
+
}
136
+
137
+
138
+
export interface Profile {
139
+
did: string;
140
+
handle: string;
141
+
pdsUrl: string;
142
+
profile: {
143
+
"$type": "app.bsky.actor.profile",
144
+
"avatar"?: {
145
+
"$type": "blob",
146
+
"ref": {
147
+
"$link": string
148
+
},
149
+
"mimeType": string,
150
+
"size": number
151
+
},
152
+
"banner"?: {
153
+
"$type": "blob",
154
+
"ref": {
155
+
"$link": string
156
+
},
157
+
"mimeType": string,
158
+
"size": number
159
+
},
160
+
"createdAt": string,
161
+
"description": string,
162
+
"displayName": string
163
+
};
164
+
}
165
+
166
+
/**
167
+
* A persistent atom to store the mapping from a user's handle to their DID.
168
+
* This avoids re-resolving handles we've already seen.
169
+
*
170
+
* Stored in localStorage under the key 'handleToDidCache'.
171
+
*/
172
+
const handleToDidAtom = atomWithStorage<Record<string, string>>(
173
+
'handleToDidCache',
174
+
{}
175
+
);
176
+
177
+
/**
178
+
* A persistent atom to store the full profile document, keyed by the user's DID.
179
+
* This is the primary cache for profile data.
180
+
*
181
+
* Stored in localStorage under the key 'didToProfileCache'.
182
+
*/
183
+
const didToProfileAtom = atomWithStorage<Record<string, Profile>>(
184
+
'didToProfileCache',
185
+
{}
186
+
);
187
+
188
+
/**
189
+
* Get a cached Profile document using Jotai persistent atoms.
190
+
* It will first check the cache, and if the profile is not found,
191
+
* it will fetch it from the network and update the cache.
192
+
*
193
+
* @param input The user's did or handle (with or without the @)
194
+
* @returns A tuple containing the Profile (or null) and a boolean indicating if it's loading.
195
+
*/
196
+
export const useCachedProfileJotai = (input?: string | null): [Profile | null, boolean] => {
197
+
const [handleToDidCache, setHandleToDidCache] = useAtom(handleToDidAtom);
198
+
const [didToProfileCache, setDidToProfileCache] = useAtom(didToProfileAtom);
199
+
200
+
const [profile, setProfile] = useState<Profile | null>(null);
201
+
const [isLoading, setIsLoading] = useState(false);
202
+
203
+
useEffect(() => {
204
+
const resolveAndFetchProfile = async () => {
205
+
if (!input) {
206
+
setProfile(null);
207
+
return;
208
+
}
209
+
210
+
setIsLoading(true);
211
+
212
+
const normalizedInput = normalizeHandle(input);
213
+
const type = classifyIdentifier(normalizedInput);
214
+
215
+
if (type === "unknown") {
216
+
console.error("Invalid identifier provided:", input);
217
+
setProfile(null);
218
+
setIsLoading(false);
219
+
return;
220
+
}
221
+
222
+
let didFromCache: string | undefined;
223
+
if (type === 'handle') {
224
+
didFromCache = handleToDidCache[normalizedInput];
225
+
} else {
226
+
didFromCache = normalizedInput;
227
+
}
228
+
229
+
if (didFromCache && didToProfileCache[didFromCache]) {
230
+
setProfile(didToProfileCache[didFromCache]);
231
+
setIsLoading(false);
232
+
return;
233
+
}
234
+
235
+
try {
236
+
const queryParam = type === "handle" ? "handle" : "did";
237
+
const res = await fetch(
238
+
`https://esav.whey.party/xrpc/party.whey.esav.resolveIdentity?${queryParam}=${normalizedInput}&includeBskyProfile=true`
239
+
);
240
+
241
+
if (!res.ok) {
242
+
throw new Error(`Failed to fetch profile for ${input}`);
243
+
}
244
+
245
+
const newProfile: Profile = await res.json();
246
+
247
+
setDidToProfileCache(prev => ({ ...prev, [newProfile.did]: newProfile }));
248
+
setHandleToDidCache(prev => ({ ...prev, [newProfile.handle]: newProfile.did }));
249
+
250
+
setProfile(newProfile);
251
+
252
+
} catch (error) {
253
+
console.error(error);
254
+
setProfile(null);
255
+
} finally {
256
+
setIsLoading(false);
257
+
}
258
+
};
259
+
260
+
resolveAndFetchProfile();
261
+
262
+
}, [input, handleToDidCache, didToProfileCache, setHandleToDidCache, setDidToProfileCache]);
263
+
264
+
return [profile, isLoading];
265
+
};
266
+
267
+
export type IdentifierType = "did" | "handle" | "unknown";
268
+
269
+
function classifyIdentifier(input: string | null | undefined): IdentifierType {
270
+
if (!input) return "unknown";
271
+
if (/^did:[a-z0-9]+:[\w.-]+$/i.test(input)) return "did";
272
+
if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(input)) return "handle";
273
+
return "unknown";
274
+
}
275
+
276
+
function normalizeHandle(input: string): string {
277
+
if (!input) return '';
278
+
return input.startsWith('@') ? input.slice(1) : input;
279
+
}
280
+
281
+
282
+
283
+
type AtUriParts = {
284
+
did: string;
285
+
collection: string;
286
+
rkey: string;
287
+
};
288
+
289
+
export function parseAtUri(uri: string): AtUriParts | null {
290
+
if (!uri.startsWith('at://')) return null;
291
+
292
+
const parts = uri.slice(5).split('/');
293
+
if (parts.length < 3) return null;
294
+
295
+
const [did, collection, ...rest] = parts;
296
+
const rkey = rest.join('/'); // in case rkey includes slashes (rare, but allowed)
297
+
298
+
return { did, collection, rkey };
299
+
}
300
+
/**
301
+
* use useEsavDocument instead its nicer
302
+
* @deprecated
303
+
* @param uris
304
+
* @returns
305
+
*/
306
+
export function useResolvedDocuments(uris: string[]) {
307
+
const allDocuments = useAtomValue(documentsAtom);
308
+
309
+
return uris.reduce<Record<string, QueryDoc | undefined>>((acc, uri) => {
310
+
acc[uri] = allDocuments[uri].doc;
311
+
return acc;
312
+
}, {});
313
+
}
+52
src/esav/types.ts
+52
src/esav/types.ts
···
···
1
+
// A document as stored in our global cache
2
+
export interface EsavDocument {
3
+
cid: string;
4
+
doc: QueryDoc;
5
+
}
6
+
7
+
export interface QueryDoc {
8
+
"$metadata.uri": string;
9
+
"$metadata.cid": string;
10
+
"$metadata.did": string;
11
+
"$metadata.collection": string;
12
+
"$metadata.rkey": string;
13
+
"$metadata.indexedAt": string;
14
+
$raw?: Record<string, unknown>;
15
+
[key: string]: unknown;
16
+
}
17
+
18
+
// The state for a single query subscription
19
+
export interface QueryState {
20
+
ecid: string;
21
+
result: string[]; // An ordered array of document URIs
22
+
}
23
+
24
+
// The server->client message we expect
25
+
export interface QueryDeltaMessage {
26
+
type: 'query-delta';
27
+
documents?: Record<string, EsavDocument>;
28
+
queries?: Record<string, QueryState>;
29
+
}
30
+
31
+
// The client->server message for subscribing
32
+
export interface SubscribeMessage {
33
+
type: 'subscribe';
34
+
queryId: string;
35
+
esquery: Record<string, any>;
36
+
ecid?: string; // Optional last known ECID
37
+
}
38
+
39
+
// The client->server message for unsubscribing
40
+
export interface UnsubscribeMessage {
41
+
type: 'unsubscribe';
42
+
queryId: string;
43
+
}
44
+
45
+
export type LogEntryType = 'incoming' | 'outgoing' | 'status' | 'error';
46
+
47
+
export interface LogEntry {
48
+
id: number;
49
+
timestamp: Date;
50
+
type: LogEntryType;
51
+
payload: any;
52
+
}
-1
src/helpers/cachedidentityresolver.ts
-1
src/helpers/cachedidentityresolver.ts
+15
-11
src/main.tsx
+15
-11
src/main.tsx
···
10
import { AuthProvider } from "./providers/PassAuthProvider.tsx";
11
import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx";
12
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13
14
const queryClient = new QueryClient();
15
16
// Create a new router instance
17
const router = createRouter({
···
37
if (rootElement && !rootElement.innerHTML) {
38
const root = ReactDOM.createRoot(rootElement);
39
root.render(
40
-
<StrictMode>
41
-
<PersistentStoreProvider>
42
-
<AuthProvider>
43
-
<QueryClientProvider client={queryClient}>
44
-
{/* Pass the router instance with the context to the provider */}
45
-
<RouterProvider router={router} />
46
-
</QueryClientProvider>
47
-
</AuthProvider>
48
-
</PersistentStoreProvider>
49
-
</StrictMode>
50
);
51
}
52
53
// If you want to start measuring performance in your app, pass a function
54
// to log results (for example: reportWebVitals(console.log))
55
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
56
-
reportWebVitals();
···
10
import { AuthProvider } from "./providers/PassAuthProvider.tsx";
11
import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx";
12
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13
+
import { ESAVLiveProvider } from "./esav/ESAVLiveProvider.tsx";
14
15
const queryClient = new QueryClient();
16
+
const ESAV_WEBSOCKET_URL = 'wss://esav.whey.party/xrpc/party.whey.esav.esSync';
17
18
// Create a new router instance
19
const router = createRouter({
···
39
if (rootElement && !rootElement.innerHTML) {
40
const root = ReactDOM.createRoot(rootElement);
41
root.render(
42
+
//<StrictMode>
43
+
<ESAVLiveProvider url={ESAV_WEBSOCKET_URL}>
44
+
<PersistentStoreProvider>
45
+
<AuthProvider>
46
+
<QueryClientProvider client={queryClient}>
47
+
{/* Pass the router instance with the context to the provider */}
48
+
<RouterProvider router={router} />
49
+
</QueryClientProvider>
50
+
</AuthProvider>
51
+
</PersistentStoreProvider>
52
+
</ESAVLiveProvider>
53
+
//</StrictMode>
54
);
55
}
56
57
// If you want to start measuring performance in your app, pass a function
58
// to log results (for example: reportWebVitals(console.log))
59
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
60
+
reportWebVitals();
+4
-1
src/routes/__root.tsx
+4
-1
src/routes/__root.tsx
···
7
} from "@tanstack/react-router";
8
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
9
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
10
11
export const Route = createRootRouteWithContext<{
12
queryClient: QueryClient;
···
14
component: () => (
15
<>
16
<Header />
17
<Outlet />
18
<TanStackRouterDevtools />
19
<ReactQueryDevtools />
20
</>
21
),
22
-
});
···
7
} from "@tanstack/react-router";
8
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
9
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
10
+
import { DeltaLogViewer, ReconnectingHeader } from "@/esav/components";
11
12
export const Route = createRootRouteWithContext<{
13
queryClient: QueryClient;
···
15
component: () => (
16
<>
17
<Header />
18
+
<ReconnectingHeader />
19
<Outlet />
20
<TanStackRouterDevtools />
21
<ReactQueryDevtools />
22
+
<DeltaLogViewer />
23
</>
24
),
25
+
});
+52
-146
src/routes/f/$forumHandle.tsx
+52
-146
src/routes/f/$forumHandle.tsx
···
5
import { esavQuery } from "@/helpers/esquery";
6
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
7
import { Outlet } from "@tanstack/react-router";
8
-
import { useState } from "react";
9
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
10
11
type ForumDoc = {
12
"$metadata.uri": string;
···
34
identity: ResolvedIdentity;
35
};
36
37
-
const forumQueryOptions = (queryClient: QueryClient, forumHandle: string) => ({
38
-
queryKey: ["forum", forumHandle],
39
-
queryFn: async (): Promise<ResolvedForumData> => {
40
-
if (!forumHandle) {
41
-
throw new Error("Forum handle is required.");
42
-
}
43
-
const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, "");
44
-
45
-
const identity = await queryClient.fetchQuery({
46
-
queryKey: ["identity", normalizedHandle],
47
-
queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }),
48
-
staleTime: 1000 * 60 * 60 * 24, // 24 hours
49
-
});
50
-
51
-
if (!identity) {
52
-
throw new Error(`Could not resolve forum handle: @${normalizedHandle}`);
53
-
}
54
-
55
-
const forumRes = await esavQuery<{
56
-
hits: { hits: { _source: ForumDoc }[] };
57
-
}>({
58
-
query: {
59
-
bool: {
60
-
must: [
61
-
{ term: { "$metadata.did": identity.did } },
62
-
{
63
-
term: {
64
-
"$metadata.collection": "com.example.ft.forum.definition",
65
-
},
66
-
},
67
-
{ term: { "$metadata.rkey": "self" } },
68
-
],
69
-
},
70
-
},
71
-
});
72
-
73
-
const forumDoc = forumRes.hits.hits[0]?._source;
74
-
if (!forumDoc) {
75
-
throw new Error("Forum definition not found.");
76
-
}
77
-
78
-
return { forumDoc, identity };
79
-
},
80
-
});
81
-
82
export const Route = createFileRoute("/f/$forumHandle")({
83
-
loader: async ({ context: { queryClient }, params }) => {
84
-
const normalizedHandle = decodeURIComponent(params.forumHandle).replace(/^@/, "");
85
-
86
-
const identity = await queryClient.fetchQuery({
87
-
queryKey: ["identity", normalizedHandle],
88
-
queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }),
89
-
staleTime: 1000 * 60 * 60 * 24,
90
-
});
91
-
92
-
if (!identity) {
93
-
throw new Error(`Could not resolve forum handle: @${normalizedHandle}`);
94
-
}
95
-
96
-
const forums = queryClient.getQueryData<ResolvedForum[]>(["forums", "list"]);
97
-
const forumFromList = forums?.find(f => f["$metadata.did"] === identity.did)
98
-
99
-
const initialData: ResolvedForumData | undefined = forumFromList
100
-
? {
101
-
forumDoc: forumFromList,
102
-
identity: {
103
-
handle: forumFromList.resolvedIdentity!.handle,
104
-
did: forumFromList["$metadata.did"],
105
-
pdsUrl: forumFromList.resolvedIdentity!.pdsUrl,
106
-
bskyPds: false,
107
-
},
108
-
}
109
-
: undefined
110
-
111
-
if (initialData) {
112
-
return initialData;
113
-
}
114
-
115
-
// Fallback to direct fetch
116
-
const forumRes = await esavQuery<{
117
-
hits: { hits: { _source: ForumDoc }[] };
118
-
}>({
119
-
query: {
120
-
bool: {
121
-
must: [
122
-
{ term: { "$metadata.did": identity.did } },
123
-
{
124
-
term: {
125
-
"$metadata.collection": "com.example.ft.forum.definition",
126
-
},
127
-
},
128
-
{ term: { "$metadata.rkey": "self" } },
129
-
],
130
-
},
131
-
},
132
-
});
133
-
134
-
const forumDoc = forumRes.hits.hits[0]?._source;
135
-
if (!forumDoc) {
136
-
throw new Error("Forum definition not found.");
137
-
}
138
-
139
-
return {
140
-
forumDoc,
141
-
identity,
142
-
};
143
-
},
144
component: ForumHeader,
145
-
pendingComponent: ForumHeaderContentSkeleton,
146
-
errorComponent: ({ error }) => (
147
-
<div className="text-red-500 text-center pt-10">
148
-
Error: {(error as Error).message}
149
-
</div>
150
-
),
151
});
152
153
function ForumHeaderContentSkeleton() {
···
186
</div>
187
</div>
188
</div>
189
-
<Outlet />
190
</>
191
);
192
}
···
227
</form>
228
);
229
}
230
-
function ForumHeaderContent({
231
-
forumDoc,
232
-
identity,
233
-
forumHandle,
234
-
}: {
235
-
forumDoc: ForumDoc;
236
-
identity: ResolvedIdentity;
237
-
forumHandle: string;
238
-
}) {
239
-
const did = identity?.did;
240
-
const bannerCid = forumDoc?.$raw?.banner?.ref?.$link;
241
-
const avatarCid = forumDoc?.$raw?.avatar?.ref?.$link;
242
const bannerUrl =
243
did && bannerCid
244
-
? `${identity?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${bannerCid}`
245
: null;
246
const avatarUrl =
247
did && avatarCid
248
-
? `${identity?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCid}`
249
: null;
250
251
return (
···
281
)}
282
<div>
283
<div className="text-white text-3xl font-bold">
284
-
{forumDoc.displayName || "Unnamed Forum"}
285
</div>
286
<div className="text-blue-300 font-mono">
287
/f/{decodeURIComponent(forumHandle || "")}
···
290
</Link>
291
</div>
292
<div className="ml-auto text-gray-300 text-base text-end max-w-1/2">
293
-
{forumDoc.description || "No description provided."}
294
</div>
295
</div>
296
</div>
···
325
}
326
327
function ForumHeader() {
328
-
const { forumHandle } = Route.useParams();
329
-
const initialData = Route.useLoaderData();
330
-
const queryClient = useQueryClient();
331
-
332
-
const { data } = useQuery({
333
-
...forumQueryOptions(queryClient, forumHandle),
334
-
initialData,
335
-
});
336
-
337
-
const { forumDoc, identity } = data;
338
-
339
return (
340
<>
341
-
<ForumHeaderContent
342
-
forumDoc={forumDoc}
343
-
identity={identity}
344
-
forumHandle={forumHandle}
345
-
/>
346
<Outlet />
347
</>
348
);
···
5
import { esavQuery } from "@/helpers/esquery";
6
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
7
import { Outlet } from "@tanstack/react-router";
8
+
import { useMemo, useState } from "react";
9
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
10
+
import { useCachedProfileJotai, useEsavDocument, useEsavQuery } from "@/esav/hooks";
11
12
type ForumDoc = {
13
"$metadata.uri": string;
···
35
identity: ResolvedIdentity;
36
};
37
38
export const Route = createFileRoute("/f/$forumHandle")({
39
component: ForumHeader,
40
});
41
42
function ForumHeaderContentSkeleton() {
···
75
</div>
76
</div>
77
</div>
78
</>
79
);
80
}
···
115
</form>
116
);
117
}
118
+
function ForumHeaderContent() {
119
+
const { forumHandle } = Route.useParams();
120
+
const [profile, isLoading] = useCachedProfileJotai(forumHandle);
121
+
122
+
const forumQuery = useMemo(() => {
123
+
if (!profile?.did) {
124
+
return null;
125
+
}
126
+
127
+
const query = {
128
+
query: {
129
+
bool: {
130
+
must: [
131
+
{ term: { "$metadata.did": profile.did } },
132
+
{
133
+
term: {
134
+
"$metadata.collection": "party.whey.ft.forum.definition",
135
+
},
136
+
},
137
+
{ term: { "$metadata.rkey": "self" } },
138
+
],
139
+
},
140
+
},
141
+
sort: [{ '$metadata.indexedAt': 'desc' }]
142
+
};
143
+
return query;
144
+
}, [profile?.did]);
145
+
146
+
const {
147
+
uris = [],
148
+
isLoading: isQueryLoading,
149
+
} = useEsavQuery(`forumtest/${profile?.did}`, forumQuery!, {
150
+
enabled: !!profile?.did && !!forumQuery,
151
+
});
152
+
153
+
const data = useEsavDocument(uris[0]);
154
+
155
+
if (!profile || isLoading || isQueryLoading || !data) {
156
+
return <ForumHeaderContentSkeleton />;
157
+
}
158
+
159
+
const did = profile.did;
160
+
const bannerCid = profile.profile?.banner?.ref?.$link;
161
+
const avatarCid = profile.profile?.avatar?.ref?.$link;
162
+
163
const bannerUrl =
164
did && bannerCid
165
+
? `${profile?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${bannerCid}`
166
: null;
167
const avatarUrl =
168
did && avatarCid
169
+
? `${profile?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCid}`
170
: null;
171
172
return (
···
202
)}
203
<div>
204
<div className="text-white text-3xl font-bold">
205
+
{profile.profile.displayName || "Unnamed Forum"}
206
</div>
207
<div className="text-blue-300 font-mono">
208
/f/{decodeURIComponent(forumHandle || "")}
···
211
</Link>
212
</div>
213
<div className="ml-auto text-gray-300 text-base text-end max-w-1/2">
214
+
{profile.profile.description || "No description provided."}
215
</div>
216
</div>
217
</div>
···
246
}
247
248
function ForumHeader() {
249
return (
250
<>
251
+
<ForumHeaderContent/>
252
<Outlet />
253
</>
254
);
+302
-341
src/routes/f/$forumHandle/index.tsx
+302
-341
src/routes/f/$forumHandle/index.tsx
···
4
Link,
5
useParams,
6
} from "@tanstack/react-router";
7
-
import { useEffect, useState } from "react";
8
import {
9
resolveIdentity,
10
type ResolvedIdentity,
···
16
import { useAuth } from "@/providers/PassAuthProvider";
17
import { AtUri, BskyAgent } from "@atproto/api";
18
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
19
20
type PostDoc = {
21
"$metadata.uri": string;
···
67
profilesMap: Record<string, ProfileData>;
68
};
69
70
-
const topicListQueryOptions = (
71
-
queryClient: QueryClient,
72
-
forumHandle: string
73
-
) => ({
74
-
queryKey: ["topics", forumHandle],
75
-
queryFn: async (): Promise<TopicListData> => {
76
-
const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, "");
77
-
78
-
const identity = await queryClient.fetchQuery({
79
-
queryKey: ["identity", normalizedHandle],
80
-
queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }),
81
-
staleTime: 1000 * 60 * 60 * 24, // 24 hours
82
-
});
83
-
84
-
if (!identity) {
85
-
throw new Error(`Could not resolve forum handle: @${normalizedHandle}`);
86
-
}
87
-
88
-
const postRes = await esavQuery<{
89
-
hits: { hits: { _source: PostDoc }[] };
90
-
}>({
91
-
query: {
92
-
bool: {
93
-
must: [
94
-
{ term: { forum: identity.did } },
95
-
{ term: { "$metadata.collection": "com.example.ft.topic.post" } },
96
-
{ bool: { must_not: [{ exists: { field: "root" } }] } },
97
-
],
98
-
},
99
-
},
100
-
sort: [{ "$metadata.indexedAt": { order: "desc" } }],
101
-
size: 100,
102
-
});
103
-
const initialPosts = postRes.hits.hits.map((h) => h._source);
104
-
105
-
const postsWithDetails = await Promise.all(
106
-
initialPosts.map(async (post) => {
107
-
const [repliesRes, latestReplyRes] = await Promise.all([
108
-
esavQuery<{
109
-
hits: { total: { value: number } };
110
-
aggregations: { unique_dids: { buckets: { key: string }[] } };
111
-
}>({
112
-
size: 0,
113
-
track_total_hits: true,
114
-
query: {
115
-
bool: { must: [{ term: { root: post["$metadata.uri"] } }] },
116
-
},
117
-
aggs: {
118
-
unique_dids: { terms: { field: "$metadata.did", size: 10000 } },
119
-
},
120
-
}),
121
-
esavQuery<{
122
-
hits: { hits: { _source: LatestReply }[] };
123
-
}>({
124
-
query: {
125
-
bool: { must: [{ term: { root: post["$metadata.uri"] } }] },
126
-
},
127
-
sort: [{ "$metadata.indexedAt": { order: "desc" } }],
128
-
size: 1,
129
-
_source: ["$metadata.did", "$metadata.indexedAt"],
130
-
}),
131
-
]);
132
-
133
-
const replyCount = repliesRes.hits.total.value;
134
-
const replyDids = repliesRes.aggregations.unique_dids.buckets.map(
135
-
(b) => b.key
136
-
);
137
-
const participants = Array.from(
138
-
new Set([post["$metadata.did"], ...replyDids])
139
-
);
140
-
const latestReply = latestReplyRes.hits.hits[0]?._source ?? null;
141
-
142
-
return { ...post, replyCount, participants, latestReply };
143
-
})
144
-
);
145
-
146
-
const postUris = postsWithDetails.map((p) => p["$metadata.uri"]);
147
-
const didsToResolve = new Set<string>();
148
-
postsWithDetails.forEach((p) => {
149
-
didsToResolve.add(p["$metadata.did"]);
150
-
p.participants?.forEach((did) => didsToResolve.add(did));
151
-
if (p.latestReply) {
152
-
didsToResolve.add(p.latestReply["$metadata.did"]);
153
-
}
154
-
});
155
-
const authorDids = Array.from(didsToResolve);
156
-
157
-
const [reactionsRes, pdsProfiles] = await Promise.all([
158
-
esavQuery<{
159
-
hits: {
160
-
hits: {
161
-
_source: { reactionSubject: string; reactionEmoji: string };
162
-
}[];
163
-
};
164
-
}>({
165
-
query: {
166
-
bool: {
167
-
must: [
168
-
{
169
-
term: {
170
-
"$metadata.collection": "com.example.ft.topic.reaction",
171
-
},
172
-
},
173
-
{ terms: { reactionSubject: postUris } },
174
-
],
175
-
},
176
-
},
177
-
_source: ["reactionSubject", "reactionEmoji"],
178
-
size: 10000,
179
-
}),
180
-
Promise.all(
181
-
authorDids.map(async (did) => {
182
-
try {
183
-
const identityRes = await queryClient.fetchQuery({
184
-
queryKey: ["identity", did],
185
-
queryFn: () => resolveIdentity({ didOrHandle: did }),
186
-
staleTime: 1000 * 60 * 60 * 24,
187
-
});
188
-
189
-
if (!identityRes?.pdsUrl) {
190
-
return {
191
-
did,
192
-
handle: identityRes?.handle ?? null,
193
-
pdsUrl: null,
194
-
profile: null,
195
-
};
196
-
}
197
-
198
-
const profileUrl = `${identityRes.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`;
199
-
const profileReq = await fetch(profileUrl);
200
-
201
-
if (!profileReq.ok) {
202
-
console.warn(
203
-
`Failed to fetch profile for ${did} from ${identityRes.pdsUrl}`
204
-
);
205
-
return {
206
-
did,
207
-
handle: identityRes.handle,
208
-
pdsUrl: identityRes.pdsUrl,
209
-
profile: null,
210
-
};
211
-
}
212
-
213
-
const profileData = await profileReq.json();
214
-
return {
215
-
did,
216
-
handle: identityRes.handle,
217
-
pdsUrl: identityRes.pdsUrl,
218
-
profile: profileData.value,
219
-
};
220
-
} catch (e) {
221
-
console.error(`Error resolving or fetching profile for ${did}`, e);
222
-
return { did, handle: null, pdsUrl: null, profile: null };
223
-
}
224
-
})
225
-
),
226
-
]);
227
-
228
-
const reactionsByPost: Record<string, Record<string, number>> = {};
229
-
for (const hit of reactionsRes.hits.hits) {
230
-
const { reactionSubject, reactionEmoji } = hit._source;
231
-
if (!reactionsByPost[reactionSubject])
232
-
reactionsByPost[reactionSubject] = {};
233
-
reactionsByPost[reactionSubject][reactionEmoji] =
234
-
(reactionsByPost[reactionSubject][reactionEmoji] || 0) + 1;
235
-
}
236
-
237
-
const topReactions: Record<string, TopReaction> = {};
238
-
for (const uri in reactionsByPost) {
239
-
const counts = reactionsByPost[uri];
240
-
const topEmoji = Object.entries(counts).reduce(
241
-
(a, b) => (b[1] > a[1] ? b : a),
242
-
["", 0]
243
-
);
244
-
if (topEmoji[0]) {
245
-
topReactions[uri] = { emoji: topEmoji[0], count: topEmoji[1] };
246
-
}
247
-
}
248
-
249
-
const profilesMap: Record<string, ProfileData> = {};
250
-
for (const p of pdsProfiles) {
251
-
profilesMap[p.did] = p;
252
-
}
253
-
254
-
const finalPosts = postsWithDetails.map((post) => ({
255
-
...post,
256
-
topReaction: topReactions[post["$metadata.uri"]] || null,
257
-
}));
258
-
259
-
return { posts: finalPosts, identity, profilesMap };
260
-
},
261
-
});
262
-
263
function getRelativeTimeString(input: string | Date): string {
264
const date = typeof input === "string" ? new Date(input) : input;
265
const now = new Date();
···
283
}
284
285
export const Route = createFileRoute("/f/$forumHandle/")({
286
-
loader: ({ context: { queryClient }, params }) =>
287
-
queryClient.ensureQueryData(
288
-
topicListQueryOptions(queryClient, params.forumHandle)
289
-
),
290
component: Forum,
291
-
pendingComponent: TopicListSkeleton,
292
-
errorComponent: ({ error }) => (
293
-
<div className="text-red-500 p-8 text-center">
294
-
Error: {(error as Error).message}
295
-
</div>
296
-
),
297
});
298
299
function ForumHeaderSkeleton() {
···
378
}
379
380
export function Forum() {
381
const navigate = useNavigate();
382
const { agent, loading: authLoading } = useAuth();
383
-
const { forumHandle } = useParams({ from: "/f/$forumHandle/" });
384
385
-
const initialData = Route.useLoaderData();
386
const queryClient = useQueryClient();
387
388
-
const { data } = useQuery({
389
-
...topicListQueryOptions(queryClient, forumHandle),
390
-
initialData,
391
-
refetchInterval: 1000 * 60, // refresh every minute
392
-
});
393
-
394
-
const { posts, identity, profilesMap } = data;
395
-
396
const [selectedCategory, setSelectedCategory] = useState("uncategorized");
397
const [sortOrder, setSortOrder] = useState("latest");
398
const [isModalOpen, setIsModalOpen] = useState(false);
···
402
const [formError, setFormError] = useState<string | null>(null);
403
404
const handleCreateTopic = async () => {
405
-
if (!agent || !agent.did || !identity) {
406
setFormError("You must be logged in to create a topic.");
407
return;
408
}
···
417
try {
418
const response = await agent.com.atproto.repo.createRecord({
419
repo: agent.did,
420
-
collection: "com.example.ft.topic.post",
421
record: {
422
-
$type: "com.example.ft.topic.post",
423
title: newTopicTitle,
424
text: newTopicText,
425
createdAt: new Date().toISOString(),
426
-
forum: identity.did,
427
},
428
});
429
···
446
}
447
};
448
449
return (
450
<div className="w-full flex flex-col items-center pt-6 px-4">
451
<div className="w-full max-w-5xl">
···
525
<Dialog.Trigger asChild>
526
<button
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
-
disabled={!identity}
529
-
title={!identity ? "Loading forum..." : "Create a new topic"}
530
>
531
+ New Topic
532
</button>
···
640
</tr>
641
</thead>
642
<tbody>
643
-
{posts.length > 0 ? (
644
-
posts.map((post) => {
645
-
const rootAuthorProfile = profilesMap[post["$metadata.did"]];
646
-
647
-
const lastPostAuthorDid = post.latestReply
648
-
? post.latestReply["$metadata.did"]
649
-
: post["$metadata.did"];
650
-
const lastPostTimestamp = post.latestReply
651
-
? post.latestReply["$metadata.indexedAt"]
652
-
: post["$metadata.indexedAt"];
653
-
const lastPostAuthorProfile = profilesMap[lastPostAuthorDid];
654
-
655
-
const lastPostAuthorAvatar =
656
-
lastPostAuthorProfile?.profile?.avatar?.ref?.$link &&
657
-
lastPostAuthorProfile.pdsUrl
658
-
? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}`
659
-
: undefined;
660
-
661
-
return (
662
-
<tr
663
-
onClick={() =>
664
-
navigate({
665
-
to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`,
666
-
})
667
-
}
668
-
key={post["$metadata.uri"]}
669
-
className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative"
670
-
>
671
-
<td className="px-4 py-3 text-white rounded-l-lg min-w-52">
672
-
<Link
673
-
// @ts-ignore
674
-
to={`/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`}
675
-
className="stretched-link"
676
-
>
677
-
<span className="sr-only">View topic:</span>
678
-
</Link>
679
-
<div className="font-semibold text-gray-50 line-clamp-1">
680
-
{post.title}
681
-
</div>
682
-
<div className="text-sm text-gray-400">
683
-
by{" "}
684
-
<span className="font-medium text-gray-300">
685
-
{rootAuthorProfile?.handle
686
-
? `@${rootAuthorProfile.handle}`
687
-
: rootAuthorProfile?.did.slice(4, 12)}
688
-
</span>
689
-
, {getRelativeTimeString(post["$metadata.indexedAt"])}
690
-
</div>
691
-
</td>
692
-
<td className="px-4 py-3">
693
-
<div className="flex -space-x-2 justify-center">
694
-
{post.participants?.slice(0, 5).map((did) => {
695
-
const participant = profilesMap[did];
696
-
const avatarUrl =
697
-
participant?.profile?.avatar?.ref?.$link &&
698
-
participant?.pdsUrl
699
-
? `${participant.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${participant.profile.avatar.ref.$link}`
700
-
: undefined;
701
-
return avatarUrl ? (
702
-
<img
703
-
key={did}
704
-
src={avatarUrl}
705
-
alt={`@${participant?.handle || did.slice(0, 8)}`}
706
-
className="w-6 h-6 rounded-full border-2 border-gray-800 object-cover bg-gray-700"
707
-
title={`@${participant?.handle || did.slice(0, 8)}`}
708
-
/>
709
-
) : (
710
-
<div
711
-
key={did}
712
-
className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700"
713
-
title={`@${participant?.handle || did.slice(0, 8)}`}
714
-
/>
715
-
);
716
-
})}
717
-
</div>
718
-
</td>
719
-
<td className="px-4 py-3 text-center text-gray-100 font-medium">
720
-
{(post.replyCount ?? 0) < 1 ? "-" : post.replyCount}
721
-
</td>
722
-
<td className="px-4 py-3 text-center text-gray-300 font-medium">
723
-
{post.topReaction ? (
724
-
<div
725
-
className="flex items-center justify-center gap-1.5"
726
-
title={`${post.topReaction.count} reactions`}
727
-
>
728
-
<span>{post.topReaction.emoji}</span>
729
-
<span className="text-sm font-normal">
730
-
{post.topReaction.count}
731
-
</span>
732
-
</div>
733
-
) : (
734
-
"-"
735
-
)}
736
-
</td>
737
-
<td className="px-4 py-3 text-gray-400 text-right rounded-r-lg">
738
-
<div className="flex items-center justify-end gap-2">
739
-
<div className="text-right">
740
-
<div className="text-sm font-semibold text-gray-100 line-clamp-1">
741
-
{lastPostAuthorProfile?.profile?.displayName ||
742
-
(lastPostAuthorProfile?.handle
743
-
? `@${lastPostAuthorProfile.handle}`
744
-
: "...")}
745
-
</div>
746
-
<div className="text-xs">
747
-
{getRelativeTimeString(lastPostTimestamp)}
748
-
</div>
749
-
</div>
750
-
{lastPostAuthorAvatar ? (
751
-
<img
752
-
src={lastPostAuthorAvatar}
753
-
alt={lastPostAuthorProfile?.profile?.displayName}
754
-
className="w-8 h-8 rounded-full object-cover bg-gray-700 shrink-0"
755
-
/>
756
-
) : (
757
-
<div className="w-8 h-8 rounded-full bg-gray-700 shrink-0" />
758
-
)}
759
-
</div>
760
-
</td>
761
-
</tr>
762
-
);
763
-
})
764
) : (
765
<tr>
766
<td colSpan={5} className="text-center text-gray-500 py-10">
···
774
</div>
775
);
776
}
···
4
Link,
5
useParams,
6
} from "@tanstack/react-router";
7
+
import { useEffect, useMemo, useState } from "react";
8
import {
9
resolveIdentity,
10
type ResolvedIdentity,
···
16
import { useAuth } from "@/providers/PassAuthProvider";
17
import { AtUri, BskyAgent } from "@atproto/api";
18
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
19
+
import {
20
+
useCachedProfileJotai,
21
+
useEsavQuery,
22
+
useEsavDocument,
23
+
parseAtUri,
24
+
type Profile,
25
+
useResolvedDocuments,
26
+
} from "@/esav/hooks";
27
28
type PostDoc = {
29
"$metadata.uri": string;
···
75
profilesMap: Record<string, ProfileData>;
76
};
77
78
function getRelativeTimeString(input: string | Date): string {
79
const date = typeof input === "string" ? new Date(input) : input;
80
const now = new Date();
···
98
}
99
100
export const Route = createFileRoute("/f/$forumHandle/")({
101
component: Forum,
102
});
103
104
function ForumHeaderSkeleton() {
···
183
}
184
185
export function Forum() {
186
+
const { forumHandle } = Route.useParams();
187
+
const [profile, isLoading] = useCachedProfileJotai(forumHandle);
188
+
189
+
const postsQuery = useMemo(() => {
190
+
if (!profile?.did) {
191
+
return null;
192
+
}
193
+
194
+
const query = {
195
+
query: {
196
+
bool: {
197
+
must: [
198
+
{ term: { forum: profile.did } },
199
+
{ term: { "$metadata.collection": "party.whey.ft.topic.post" } },
200
+
{ bool: { must_not: [{ exists: { field: "root" } }] } },
201
+
],
202
+
},
203
+
},
204
+
sort: [{ "$metadata.indexedAt": { order: "desc" } }]
205
+
};
206
+
return query;
207
+
}, [profile?.did]);
208
+
209
+
const { uris = [], isLoading: isQueryLoading } = useEsavQuery(
210
+
`forumtest/${profile?.did}/topics`,
211
+
postsQuery!,
212
+
{
213
+
enabled: !!profile?.did && !!postsQuery,
214
+
}
215
+
);
216
+
217
const navigate = useNavigate();
218
const { agent, loading: authLoading } = useAuth();
219
220
const queryClient = useQueryClient();
221
222
const [selectedCategory, setSelectedCategory] = useState("uncategorized");
223
const [sortOrder, setSortOrder] = useState("latest");
224
const [isModalOpen, setIsModalOpen] = useState(false);
···
228
const [formError, setFormError] = useState<string | null>(null);
229
230
const handleCreateTopic = async () => {
231
+
if (!agent || !agent.did) {
232
setFormError("You must be logged in to create a topic.");
233
return;
234
}
···
243
try {
244
const response = await agent.com.atproto.repo.createRecord({
245
repo: agent.did,
246
+
collection: "party.whey.ft.topic.post",
247
record: {
248
+
$type: "party.whey.ft.topic.post",
249
title: newTopicTitle,
250
text: newTopicText,
251
createdAt: new Date().toISOString(),
252
+
forum: profile?.did,
253
},
254
});
255
···
272
}
273
};
274
275
+
if (!profile || isLoading || isQueryLoading) {
276
+
return <TopicListSkeleton />;
277
+
}
278
+
279
return (
280
<div className="w-full flex flex-col items-center pt-6 px-4">
281
<div className="w-full max-w-5xl">
···
355
<Dialog.Trigger asChild>
356
<button
357
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"
358
+
disabled={!profile}
359
+
title={!profile ? "Loading forum..." : "Create a new topic"}
360
>
361
+ New Topic
362
</button>
···
470
</tr>
471
</thead>
472
<tbody>
473
+
{uris.length > 0 ? (
474
+
uris.map((uri) => (
475
+
<TopicRow
476
+
forumHandle={forumHandle}
477
+
key={uri}
478
+
profile={profile}
479
+
uri={uri}
480
+
/>
481
+
))
482
) : (
483
<tr>
484
<td colSpan={5} className="text-center text-gray-500 py-10">
···
492
</div>
493
);
494
}
495
+
496
+
function TopicRow({
497
+
forumHandle,
498
+
profile,
499
+
uri,
500
+
}: {
501
+
forumHandle: string;
502
+
profile: Profile;
503
+
uri: string;
504
+
}) {
505
+
const navigate = useNavigate();
506
+
const topic = useEsavDocument(uri);
507
+
const parsed = parseAtUri(uri);
508
+
509
+
const fullRepliesQuery = {
510
+
query: {
511
+
bool: { must: [{ term: { root: uri } }] },
512
+
},
513
+
sort: [{ "$metadata.indexedAt": { order: "asc" } }],
514
+
};
515
+
516
+
const { uris: repliesUris = [], isLoading: isQueryLoading } = useEsavQuery(
517
+
`forumtest/${profile.did}/${uri}/replies`,
518
+
fullRepliesQuery!,
519
+
{
520
+
enabled: !!fullRepliesQuery,
521
+
}
522
+
);
523
+
524
+
const topReactions = {
525
+
query: {
526
+
bool: {
527
+
must: [
528
+
{
529
+
term: {
530
+
"$metadata.collection": "party.whey.ft.topic.reaction",
531
+
},
532
+
},
533
+
{
534
+
terms: {
535
+
reactionSubject: [uri]
536
+
}
537
+
},
538
+
],
539
+
},
540
+
},
541
+
sort: [{ "$metadata.indexedAt": { order: "asc" } }],
542
+
};
543
+
544
+
const { uris: reactionUris = [], isLoading: isReactionsLoading } =
545
+
useEsavQuery(`forumtest/${profile.did}/${uri}/OPreply/reactions`, topReactions!, {
546
+
enabled: !!topReactions,
547
+
});
548
+
549
+
const lastReplyUri =
550
+
repliesUris.length > 0 ? repliesUris[repliesUris.length - 1] : uri;
551
+
552
+
const [op, isOpLoading] = useCachedProfileJotai(parsed?.did);
553
+
const [lastReplyAuthor, isLastReplyAuthorLoading] = useCachedProfileJotai(
554
+
lastReplyUri && parseAtUri(lastReplyUri)?.did
555
+
);
556
+
557
+
const lastReply = useEsavDocument(lastReplyUri);
558
+
559
+
const participants = Array.from(
560
+
new Set(
561
+
[
562
+
parsed?.did,
563
+
...repliesUris.map((i) => parseAtUri(i)?.did),
564
+
].filter((did): did is string => typeof did === "string")
565
+
)
566
+
);
567
+
568
+
569
+
if (
570
+
!topic ||
571
+
isQueryLoading ||
572
+
isOpLoading ||
573
+
isLastReplyAuthorLoading ||
574
+
!op ||
575
+
isReactionsLoading
576
+
) {
577
+
return <TopicRowSkeleton />;
578
+
}
579
+
580
+
const rootAuthorProfile = op.profile;
581
+
582
+
const lastPostAuthorDid = lastReply?.doc["$metadata.did"];
583
+
const lastPostTimestamp = lastReply?.doc["$metadata.indexedAt"];
584
+
const lastPostAuthorProfile = lastReplyAuthor;
585
+
586
+
const lastPostAuthorAvatar =
587
+
lastPostAuthorProfile?.profile?.avatar?.ref?.$link &&
588
+
lastPostAuthorProfile.pdsUrl
589
+
? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}`
590
+
: undefined;
591
+
592
+
const post = topic.doc as PostDoc;
593
+
594
+
return (
595
+
<tr
596
+
onClick={() =>
597
+
navigate({
598
+
to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`,
599
+
})
600
+
}
601
+
key={post["$metadata.uri"]}
602
+
className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative"
603
+
>
604
+
<td className="px-4 py-3 text-white rounded-l-lg min-w-52">
605
+
<Link
606
+
// @ts-ignore
607
+
to={`/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`}
608
+
className="stretched-link"
609
+
>
610
+
<span className="sr-only">View topic:</span>
611
+
</Link>
612
+
<div className="font-semibold text-gray-50 line-clamp-1">
613
+
{post.title}
614
+
</div>
615
+
<div className="text-sm text-gray-400">
616
+
by{" "}
617
+
<span className="font-medium text-gray-300">
618
+
{op.handle ? `@${op.handle}` : op?.did.slice(4, 12)}
619
+
</span>
620
+
, {getRelativeTimeString(post["$metadata.indexedAt"])}
621
+
</div>
622
+
</td>
623
+
<td className="px-4 py-3">
624
+
<div className="flex -space-x-2 justify-center">
625
+
{participants
626
+
.filter(Boolean)
627
+
.slice(0, 5)
628
+
.map((did) => (
629
+
<Participant key={did} did={did} />
630
+
))}
631
+
</div>
632
+
</td>
633
+
<td className="px-4 py-3 text-center text-gray-100 font-medium">
634
+
{(repliesUris.length ?? 0) < 1 ? "-" : repliesUris.length}
635
+
</td>
636
+
<td className="px-4 py-3 text-center text-gray-300 font-medium">
637
+
{reactionUris ? <TopReactionc uris={reactionUris} /> : "-"}
638
+
</td>
639
+
<td className="px-4 py-3 text-gray-400 text-right rounded-r-lg">
640
+
<div className="flex items-center justify-end gap-2">
641
+
<div className="text-right">
642
+
<div className="text-sm font-semibold text-gray-100 line-clamp-1">
643
+
{lastPostAuthorProfile?.profile?.displayName ||
644
+
(lastPostAuthorProfile?.handle
645
+
? `@${lastPostAuthorProfile.handle}`
646
+
: "...")}
647
+
</div>
648
+
<div className="text-xs">
649
+
{lastPostTimestamp && getRelativeTimeString(lastPostTimestamp)}
650
+
</div>
651
+
</div>
652
+
{lastPostAuthorAvatar ? (
653
+
<img
654
+
src={lastPostAuthorAvatar}
655
+
alt={lastPostAuthorProfile?.profile?.displayName}
656
+
className="w-8 h-8 rounded-full object-cover bg-gray-700 shrink-0"
657
+
/>
658
+
) : (
659
+
<div className="w-8 h-8 rounded-full bg-gray-700 shrink-0" />
660
+
)}
661
+
</div>
662
+
</td>
663
+
</tr>
664
+
);
665
+
}
666
+
667
+
function TopReactionc({ uris }: { uris: string[] }) {
668
+
const resolvedReactions = useResolvedDocuments(uris);
669
+
670
+
const didEmojiSet = new Map<string, Set<string>>();
671
+
const emojiCounts = new Map<string, number>();
672
+
673
+
Object.values(resolvedReactions).forEach((doc) => {
674
+
if (!doc) return;
675
+
676
+
const did = doc["$metadata.did"];
677
+
const emoji = doc.$raw?.reactionEmoji as string;
678
+
if (!emoji) return;
679
+
680
+
if (!didEmojiSet.has(did)) {
681
+
didEmojiSet.set(did, new Set());
682
+
}
683
+
684
+
const emojiSet = didEmojiSet.get(did)!;
685
+
if (!emojiSet.has(emoji)) {
686
+
emojiSet.add(emoji);
687
+
emojiCounts.set(emoji, (emojiCounts.get(emoji) || 0) + 1);
688
+
}
689
+
});
690
+
691
+
// Step 2: Find top emoji
692
+
let topEmoji: string | null = null;
693
+
let topCount = 0;
694
+
for (const [emoji, count] of emojiCounts) {
695
+
if (count > topCount) {
696
+
topEmoji = emoji;
697
+
topCount = count;
698
+
}
699
+
}
700
+
701
+
if (!topEmoji) return null; // No valid reactions
702
+
703
+
return (
704
+
<div
705
+
className="flex items-center justify-center gap-1.5"
706
+
title={`${topCount} reactions`}
707
+
>
708
+
<span>{topEmoji}</span>
709
+
<span className="text-sm font-normal">{topCount}</span>
710
+
</div>
711
+
);
712
+
}
713
+
714
+
function Participant({ did }: { did: string }) {
715
+
const [user, isloading] = useCachedProfileJotai(did);
716
+
if (isloading || !user) {
717
+
return (
718
+
<div
719
+
key={did}
720
+
className="w-6 h-6 rounded-full border-2 border-gray-800 bg-gray-700"
721
+
/>
722
+
);
723
+
}
724
+
const avatarUrl =
725
+
user.profile?.avatar?.ref?.$link && user.pdsUrl
726
+
? `${user.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${user.profile.avatar.ref.$link}`
727
+
: undefined;
728
+
return (
729
+
<img
730
+
key={did}
731
+
src={avatarUrl}
732
+
alt={`@${user?.handle || did.slice(0, 8)}`}
733
+
className="w-6 h-6 rounded-full border-2 border-gray-800 object-cover bg-gray-700"
734
+
title={`@${user?.handle || did.slice(0, 8)}`}
735
+
/>
736
+
);
737
+
}
+274
-192
src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx
+274
-192
src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx
···
16
} from "@radix-ui/react-icons";
17
import * as Popover from "@radix-ui/react-popover";
18
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
19
20
type PostDoc = {
21
"$metadata.uri": string;
···
58
59
const EMOJI_SELECTION = ["👍", "❤️", "😂", "🔥", "🤔", "🎉", "🙏", "🤯"];
60
61
-
const topicQueryOptions = (
62
-
queryClient: QueryClient,
63
-
userHandle: string,
64
-
topicRKey: string
65
-
) => ({
66
-
queryKey: ["topic", userHandle, topicRKey],
67
-
queryFn: async (): Promise<TopicData> => {
68
-
const authorIdentity = await queryClient.fetchQuery({
69
-
queryKey: ["identity", userHandle],
70
-
queryFn: () => resolveIdentity({ didOrHandle: userHandle }),
71
-
staleTime: 1000 * 60 * 60 * 24,
72
-
});
73
-
if (!authorIdentity) throw new Error("Could not find topic author.");
74
75
-
const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`;
76
77
-
const [postRes, repliesRes] = await Promise.all([
78
-
esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
79
-
query: { term: { "$metadata.uri": topicUri } },
80
-
size: 1,
81
-
}),
82
-
esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
83
-
query: { term: { root: topicUri } },
84
-
sort: [{ "$metadata.indexedAt": "asc" }],
85
-
size: 100,
86
-
}),
87
-
]);
88
89
-
if (postRes.hits.hits.length === 0) throw new Error("Topic not found.");
90
-
const mainPost = postRes.hits.hits[0]._source;
91
-
const fetchedReplies = repliesRes.hits.hits.map((h) => h._source);
92
-
const allPosts = [mainPost, ...fetchedReplies];
93
94
-
const postUris = allPosts.map((p) => p["$metadata.uri"]);
95
-
const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))];
96
97
-
const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([
98
-
esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({
99
-
query: {
100
-
bool: {
101
-
must: [
102
-
{
103
-
term: {
104
-
"$metadata.collection": "com.example.ft.topic.reaction",
105
-
},
106
-
},
107
-
{ terms: { reactionSubject: postUris } },
108
-
],
109
-
},
110
-
},
111
-
_source: ["reactionSubject", "reactionEmoji"],
112
-
size: 1000,
113
-
}),
114
-
esavQuery<{
115
-
hits: {
116
-
hits: { _source: { "$metadata.did": string; footer: string } }[];
117
-
};
118
-
}>({
119
-
query: {
120
-
bool: {
121
-
must: [
122
-
{ term: { $type: "com.example.ft.user.profile" } },
123
-
{ terms: { "$metadata.did": authorDids } },
124
-
],
125
-
},
126
-
},
127
-
_source: ["$metadata.did", "footer"],
128
-
size: authorDids.length,
129
-
}),
130
-
Promise.all(
131
-
authorDids.map(async (did) => {
132
-
try {
133
-
const identity = await queryClient.fetchQuery({
134
-
queryKey: ["identity", did],
135
-
queryFn: () => resolveIdentity({ didOrHandle: did }),
136
-
staleTime: 1000 * 60 * 60 * 24,
137
-
});
138
139
-
if (!identity?.pdsUrl) {
140
-
console.warn(
141
-
`Could not resolve PDS for ${did}, cannot fetch profile.`
142
-
);
143
-
return { did, profile: null };
144
-
}
145
146
-
const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`;
147
-
const profileRes = await fetch(profileUrl);
148
149
-
if (!profileRes.ok) {
150
-
console.warn(
151
-
`Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}`
152
-
);
153
-
return { did, profile: null };
154
-
}
155
156
-
const profileData = await profileRes.json();
157
-
return { did, profile: profileData.value };
158
-
} catch (e) {
159
-
console.error(
160
-
`Error during decentralized profile fetch for ${did}:`,
161
-
e
162
-
);
163
-
return { did, profile: null };
164
-
}
165
-
})
166
-
),
167
-
]);
168
169
-
const reactionsByPostUri = reactionsRes.hits.hits.reduce(
170
-
(acc, hit) => {
171
-
const reaction = hit._source;
172
-
(acc[reaction.reactionSubject] =
173
-
acc[reaction.reactionSubject] || []).push(reaction);
174
-
return acc;
175
-
},
176
-
{} as Record<string, ReactionDoc[]>
177
-
);
178
179
-
const footersByDid = footersRes.hits.hits.reduce(
180
-
(acc, hit) => {
181
-
acc[hit._source["$metadata.did"]] = hit._source.footer;
182
-
return acc;
183
-
},
184
-
{} as Record<string, string>
185
-
);
186
187
-
const authors: Record<string, AuthorInfo> = {};
188
-
await Promise.all(
189
-
authorDids.map(async (did) => {
190
-
const identity = await queryClient.fetchQuery({
191
-
queryKey: ["identity", did],
192
-
queryFn: () => resolveIdentity({ didOrHandle: did }),
193
-
staleTime: 1000 * 60 * 60 * 24,
194
-
});
195
-
if (!identity) return;
196
-
const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile;
197
-
authors[did] = {
198
-
...identity,
199
-
displayName: pdsProfile?.displayName,
200
-
avatarCid: pdsProfile?.avatar?.ref?.["$link"],
201
-
footer: footersByDid[did],
202
-
};
203
-
})
204
-
);
205
206
-
return { posts: allPosts, authors, reactions: reactionsByPostUri };
207
-
},
208
-
});
209
export const Route = createFileRoute(
210
"/f/$forumHandle/t/$userHandle/$topicRKey"
211
)({
212
-
loader: ({ context: { queryClient }, params }) =>
213
-
queryClient.ensureQueryData(
214
-
topicQueryOptions(
215
-
queryClient,
216
-
decodeURIComponent(params.userHandle),
217
-
params.topicRKey
218
-
)
219
-
),
220
component: ForumTopic,
221
-
pendingComponent: TopicPageSkeleton,
222
-
errorComponent: ({ error }) => (
223
-
<div className="text-center text-red-500 pt-20 text-lg">
224
-
Error: {(error as Error).message}
225
-
</div>
226
-
),
227
});
228
229
export function PostCardSkeleton() {
···
276
);
277
}
278
279
-
function UserInfoColumn({ author }: { author: AuthorInfo | null }) {
280
const avatarUrl =
281
-
author?.avatarCid && author?.pdsUrl
282
-
? `${author.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${author.did}&cid=${author.avatarCid}`
283
: undefined;
284
285
-
const authorDisplayName = author?.displayName || author?.handle || "Unknown";
286
const authorHandle = author?.handle ? `@${author.handle}` : "did:...";
287
288
return (
···
302
{authorDisplayName}
303
</div>
304
<div className="break-words whitespace-normal">{authorHandle}</div>
305
-
{author?.footer && (
306
<div className="border-t border-gray-700/80 mt-4 pt-3 text-xs text-gray-500 text-left whitespace-pre-wrap break-words">
307
{author.footer}
308
</div>
309
-
)}
310
</div>
311
);
312
}
···
340
}
341
342
export function PostCard({
343
agent,
344
post,
345
-
author,
346
-
reactions,
347
index,
348
onSetReplyParent,
349
onNewReaction,
350
isCreatingReaction,
351
}: {
352
agent: AtpAgent | null;
353
post: PostDoc;
354
-
author: AuthorInfo | null;
355
-
reactions: ReactionDoc[];
356
index: number;
357
onSetReplyParent: (post: PostDoc) => void;
358
onNewReaction: (post: PostDoc, emoji: string) => Promise<void>;
···
360
}) {
361
const postUri = post["$metadata.uri"];
362
const postDate = new Date(post["$metadata.indexedAt"]);
363
364
return (
365
<div
···
441
const { forumHandle, userHandle, topicRKey } = useParams({
442
from: "/f/$forumHandle/t/$userHandle/$topicRKey",
443
});
444
const { agent, loading: authLoading } = useAuth();
445
-
const queryClient = useQueryClient();
446
-
const initialData = Route.useLoaderData();
447
448
-
const { data, isError, error } = useQuery({
449
-
...topicQueryOptions(queryClient, userHandle, topicRKey),
450
-
initialData,
451
-
refetchInterval: 30 * 1000, // refresh every half minute
452
-
});
453
454
-
const { posts, authors, reactions } = data;
455
456
const [replyText, setReplyText] = useState("");
457
const [isSubmitting, setIsSubmitting] = useState(false);
···
462
const handleSetReplyParent = (post: PostDoc) => {
463
setReplyingTo(post);
464
document.getElementById("reply-box")?.focus();
465
-
};
466
-
467
-
const invalidateTopicQuery = () => {
468
-
queryClient.invalidateQueries({
469
-
queryKey: ["topic", userHandle, topicRKey],
470
-
});
471
};
472
473
const handleCreateReaction = async (post: PostDoc, emoji: string) => {
···
477
try {
478
await agent.com.atproto.repo.createRecord({
479
repo: agent.did,
480
-
collection: "com.example.ft.topic.reaction",
481
record: {
482
-
$type: "com.example.ft.topic.reaction",
483
reactionEmoji: emoji,
484
subject: post["$metadata.uri"],
485
createdAt: new Date().toISOString(),
486
},
487
});
488
-
invalidateTopicQuery();
489
} catch (e) {
490
console.error("Failed to create reaction", e);
491
setMutationError("Failed to post reaction. Please try again.");
···
502
try {
503
const rootPost = posts[0];
504
const parentPost = replyingTo || rootPost;
505
-
const identity = await queryClient.fetchQuery({
506
-
queryKey: ["identity", forumHandle],
507
-
queryFn: () => resolveIdentity({ didOrHandle: forumHandle }),
508
-
staleTime: 1000 * 60 * 60 * 24,
509
-
});
510
const forumDid = identity?.did;
511
if (!forumDid) {
512
throw new Error("Could not resolve forum handle to DID.");
513
}
514
await agent.com.atproto.repo.createRecord({
515
repo: agent.did,
516
-
collection: "com.example.ft.topic.post",
517
record: {
518
-
$type: "com.example.ft.topic.post",
519
text: replyText,
520
forum: forumDid,
521
reply: {
···
533
});
534
setReplyText("");
535
setReplyingTo(null);
536
-
invalidateTopicQuery();
537
} catch (e) {
538
setMutationError(`Failed to post reply: ${(e as Error).message}`);
539
} finally {
540
setIsSubmitting(false);
541
}
542
};
543
544
-
if (isError)
545
-
return (
546
-
<div className="text-red-500 p-8 text-center">
547
-
Error: {(error as Error).message}
548
-
</div>
549
-
);
550
551
const topicPost = posts[0];
552
const postIndexBeingRepliedTo = replyingTo
···
574
575
{posts.map((post, index) => (
576
<PostCard
577
agent={agent}
578
key={post["$metadata.uri"]}
579
post={post}
580
-
author={authors[post["$metadata.did"]]}
581
-
reactions={reactions[post["$metadata.uri"]] || []}
582
index={index}
583
onSetReplyParent={handleSetReplyParent}
584
onNewReaction={handleCreateReaction}
···
16
} from "@radix-ui/react-icons";
17
import * as Popover from "@radix-ui/react-popover";
18
import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query";
19
+
import {
20
+
parseAtUri,
21
+
useCachedProfileJotai,
22
+
useEsavDocument,
23
+
useEsavQuery,
24
+
type Profile,
25
+
} from "@/esav/hooks";
26
27
type PostDoc = {
28
"$metadata.uri": string;
···
65
66
const EMOJI_SELECTION = ["👍", "❤️", "😂", "🔥", "🤔", "🎉", "🙏", "🤯"];
67
68
+
// const topicQueryOptions = (
69
+
// queryClient: QueryClient,
70
+
// userHandle: string,
71
+
// topicRKey: string
72
+
// ) => ({
73
+
// queryKey: ["topic", userHandle, topicRKey],
74
+
// queryFn: async (): Promise<TopicData> => {
75
+
// const authorIdentity = await queryClient.fetchQuery({
76
+
// queryKey: ["identity", userHandle],
77
+
// queryFn: () => resolveIdentity({ didOrHandle: userHandle }),
78
+
// staleTime: 1000 * 60 * 60 * 24,
79
+
// });
80
+
// if (!authorIdentity) throw new Error("Could not find topic author.");
81
82
+
// const topicUri = `at://${authorIdentity.did}/party.whey.ft.topic.post/${topicRKey}`;
83
84
+
// const [postRes, repliesRes] = await Promise.all([
85
+
// esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
86
+
// query: { term: { "$metadata.uri": topicUri } },
87
+
// size: 1,
88
+
// }),
89
+
// esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({
90
+
// query: { term: { root: topicUri } },
91
+
// sort: [{ "$metadata.indexedAt": "asc" }],
92
+
// size: 100,
93
+
// }),
94
+
// ]);
95
96
+
// if (postRes.hits.hits.length === 0) throw new Error("Topic not found.");
97
+
// const mainPost = postRes.hits.hits[0]._source;
98
+
// const fetchedReplies = repliesRes.hits.hits.map((h) => h._source);
99
+
// const allPosts = [mainPost, ...fetchedReplies];
100
101
+
// const postUris = allPosts.map((p) => p["$metadata.uri"]);
102
+
// const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))];
103
104
+
// const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([
105
+
// esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({
106
+
// query: {
107
+
// bool: {
108
+
// must: [
109
+
// {
110
+
// term: {
111
+
// "$metadata.collection": "party.whey.ft.topic.reaction",
112
+
// },
113
+
// },
114
+
// { terms: { reactionSubject: postUris } },
115
+
// ],
116
+
// },
117
+
// },
118
+
// _source: ["reactionSubject", "reactionEmoji"],
119
+
// size: 1000,
120
+
// }),
121
+
// esavQuery<{
122
+
// hits: {
123
+
// hits: { _source: { "$metadata.did": string; footer: string } }[];
124
+
// };
125
+
// }>({
126
+
// query: {
127
+
// bool: {
128
+
// must: [
129
+
// { term: { $type: "party.whey.ft.user.profile" } },
130
+
// { terms: { "$metadata.did": authorDids } },
131
+
// ],
132
+
// },
133
+
// },
134
+
// _source: ["$metadata.did", "footer"],
135
+
// size: authorDids.length,
136
+
// }),
137
+
// Promise.all(
138
+
// authorDids.map(async (did) => {
139
+
// try {
140
+
// const identity = await queryClient.fetchQuery({
141
+
// queryKey: ["identity", did],
142
+
// queryFn: () => resolveIdentity({ didOrHandle: did }),
143
+
// staleTime: 1000 * 60 * 60 * 24,
144
+
// });
145
146
+
// if (!identity?.pdsUrl) {
147
+
// console.warn(
148
+
// `Could not resolve PDS for ${did}, cannot fetch profile.`
149
+
// );
150
+
// return { did, profile: null };
151
+
// }
152
153
+
// const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`;
154
+
// const profileRes = await fetch(profileUrl);
155
156
+
// if (!profileRes.ok) {
157
+
// console.warn(
158
+
// `Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}`
159
+
// );
160
+
// return { did, profile: null };
161
+
// }
162
163
+
// const profileData = await profileRes.json();
164
+
// return { did, profile: profileData.value };
165
+
// } catch (e) {
166
+
// console.error(
167
+
// `Error during decentralized profile fetch for ${did}:`,
168
+
// e
169
+
// );
170
+
// return { did, profile: null };
171
+
// }
172
+
// })
173
+
// ),
174
+
// ]);
175
176
+
// const reactionsByPostUri = reactionsRes.hits.hits.reduce(
177
+
// (acc, hit) => {
178
+
// const reaction = hit._source;
179
+
// (acc[reaction.reactionSubject] =
180
+
// acc[reaction.reactionSubject] || []).push(reaction);
181
+
// return acc;
182
+
// },
183
+
// {} as Record<string, ReactionDoc[]>
184
+
// );
185
186
+
// const footersByDid = footersRes.hits.hits.reduce(
187
+
// (acc, hit) => {
188
+
// acc[hit._source["$metadata.did"]] = hit._source.footer;
189
+
// return acc;
190
+
// },
191
+
// {} as Record<string, string>
192
+
// );
193
194
+
// const authors: Record<string, AuthorInfo> = {};
195
+
// await Promise.all(
196
+
// authorDids.map(async (did) => {
197
+
// const identity = await queryClient.fetchQuery({
198
+
// queryKey: ["identity", did],
199
+
// queryFn: () => resolveIdentity({ didOrHandle: did }),
200
+
// staleTime: 1000 * 60 * 60 * 24,
201
+
// });
202
+
// if (!identity) return;
203
+
// const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile;
204
+
// authors[did] = {
205
+
// ...identity,
206
+
// displayName: pdsProfile?.displayName,
207
+
// avatarCid: pdsProfile?.avatar?.ref?.["$link"],
208
+
// footer: footersByDid[did],
209
+
// };
210
+
// })
211
+
// );
212
+
213
+
// return { posts: allPosts, authors, reactions: reactionsByPostUri };
214
+
// },
215
+
// });
216
217
export const Route = createFileRoute(
218
"/f/$forumHandle/t/$userHandle/$topicRKey"
219
)({
220
component: ForumTopic,
221
});
222
223
export function PostCardSkeleton() {
···
270
);
271
}
272
273
+
function UserInfoColumn({ author }: { author: Profile | null }) {
274
const avatarUrl =
275
+
author?.profile.avatar?.ref.$link && author?.pdsUrl
276
+
? `${author.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${author.did}&cid=${author?.profile.avatar?.ref.$link}`
277
: undefined;
278
279
+
const authorDisplayName = author?.profile.displayName || author?.handle || "Unknown";
280
const authorHandle = author?.handle ? `@${author.handle}` : "did:...";
281
282
return (
···
296
{authorDisplayName}
297
</div>
298
<div className="break-words whitespace-normal">{authorHandle}</div>
299
+
{/* {author?.footer && (
300
<div className="border-t border-gray-700/80 mt-4 pt-3 text-xs text-gray-500 text-left whitespace-pre-wrap break-words">
301
{author.footer}
302
</div>
303
+
)} */}
304
</div>
305
);
306
}
···
334
}
335
336
export function PostCard({
337
+
forumdid,
338
agent,
339
post,
340
+
//author,
341
+
//reactions,
342
index,
343
onSetReplyParent,
344
onNewReaction,
345
isCreatingReaction,
346
}: {
347
+
forumdid: string;
348
agent: AtpAgent | null;
349
post: PostDoc;
350
+
//author: AuthorInfo | null;
351
+
//reactions: ReactionDoc[];
352
index: number;
353
onSetReplyParent: (post: PostDoc) => void;
354
onNewReaction: (post: PostDoc, emoji: string) => Promise<void>;
···
356
}) {
357
const postUri = post["$metadata.uri"];
358
const postDate = new Date(post["$metadata.indexedAt"]);
359
+
const [author, authorloading] = useCachedProfileJotai(post["$metadata.did"]);
360
+
361
+
const reactionsquery = {
362
+
query: {
363
+
bool: {
364
+
must: [
365
+
{
366
+
term: {
367
+
"$metadata.collection": "party.whey.ft.topic.reaction",
368
+
},
369
+
},
370
+
{
371
+
terms: {
372
+
reactionSubject: [post["$metadata.uri"]]
373
+
}
374
+
},
375
+
],
376
+
},
377
+
},
378
+
sort: [{ "$metadata.indexedAt": { order: "asc" } }],
379
+
};
380
+
381
+
const { uris: reactionUris = [], isLoading: isReactionsLoading } =
382
+
useEsavQuery(`forumtest/${forumdid}/${post["$metadata.uri"]}/reactions`, reactionsquery!, {
383
+
enabled: !!reactionsquery,
384
+
});
385
+
386
+
function isReactionDoc(doc: unknown): doc is ReactionDoc {
387
+
return (
388
+
typeof doc === 'object' &&
389
+
doc !== null &&
390
+
'reactionEmoji' in doc &&
391
+
'reactionSubject' in doc
392
+
);
393
+
}
394
+
395
+
const docsMap = useEsavDocument(reactionUris);
396
+
const reactions = reactionUris
397
+
.map((uri) => docsMap?.[uri]?.doc as unknown)
398
+
.filter(isReactionDoc);
399
+
400
+
if (!author || authorloading) {
401
+
return (
402
+
<span>
403
+
loading
404
+
</span>
405
+
)
406
+
}
407
408
return (
409
<div
···
485
const { forumHandle, userHandle, topicRKey } = useParams({
486
from: "/f/$forumHandle/t/$userHandle/$topicRKey",
487
});
488
+
const [forum, isforumdidLoading] = useCachedProfileJotai(forumHandle);
489
+
const [op, isOpdidLoading] = useCachedProfileJotai(userHandle);
490
+
491
+
const uri = useMemo(() => {
492
+
return `at://${op?.did}/party.whey.ft.topic.post/${topicRKey}`;
493
+
}, [op?.did]);
494
const { agent, loading: authLoading } = useAuth();
495
+
//const topic = useEsavDocument(uri);
496
+
//const parsed = parseAtUri(uri);
497
498
+
const opQuery = {
499
+
query: {
500
+
term: {
501
+
"$metadata.uri": uri,
502
+
},
503
+
},
504
+
size: 1,
505
+
sort: [{ "$metadata.indexedAt": { order: "asc" } }],
506
+
};
507
508
+
const fullRepliesQuery = {
509
+
query: {
510
+
bool: { must: [{ term: { root: uri } }] },
511
+
},
512
+
sort: [{ "$metadata.indexedAt": { order: "asc" } }],
513
+
};
514
+
515
+
const { uris: opUris = [], isLoading: isopQueryLoading } = useEsavQuery(
516
+
`forumtest/${op?.did}/${uri}`,
517
+
opQuery!,
518
+
{
519
+
enabled: !!opQuery && !!op,
520
+
}
521
+
);
522
+
523
+
const { uris: repliesUris = [], isLoading: isQueryLoading } = useEsavQuery(
524
+
`forumtest/${op?.did}/${uri}/replies`,
525
+
fullRepliesQuery!,
526
+
{
527
+
enabled: !!fullRepliesQuery && !!op,
528
+
}
529
+
);
530
+
531
+
const oppost = useEsavDocument(uri);
532
+
const docsMap = useEsavDocument(repliesUris);
533
+
const posts = useMemo(() => { return [
534
+
oppost?.doc as PostDoc,
535
+
...repliesUris.map((uri) => docsMap?.[uri]?.doc as PostDoc),
536
+
].filter((doc): doc is PostDoc => !!doc);
537
+
}, [oppost, docsMap]);
538
539
const [replyText, setReplyText] = useState("");
540
const [isSubmitting, setIsSubmitting] = useState(false);
···
545
const handleSetReplyParent = (post: PostDoc) => {
546
setReplyingTo(post);
547
document.getElementById("reply-box")?.focus();
548
};
549
550
const handleCreateReaction = async (post: PostDoc, emoji: string) => {
···
554
try {
555
await agent.com.atproto.repo.createRecord({
556
repo: agent.did,
557
+
collection: "party.whey.ft.topic.reaction",
558
record: {
559
+
$type: "party.whey.ft.topic.reaction",
560
reactionEmoji: emoji,
561
subject: post["$metadata.uri"],
562
createdAt: new Date().toISOString(),
563
},
564
});
565
+
//invalidateTopicQuery();
566
} catch (e) {
567
console.error("Failed to create reaction", e);
568
setMutationError("Failed to post reaction. Please try again.");
···
579
try {
580
const rootPost = posts[0];
581
const parentPost = replyingTo || rootPost;
582
+
const trimmed = forumHandle.startsWith("@")
583
+
? forumHandle.slice(1)
584
+
: forumHandle;
585
+
const identity = forum;
586
const forumDid = identity?.did;
587
if (!forumDid) {
588
throw new Error("Could not resolve forum handle to DID.");
589
}
590
await agent.com.atproto.repo.createRecord({
591
repo: agent.did,
592
+
collection: "party.whey.ft.topic.post",
593
record: {
594
+
$type: "party.whey.ft.topic.post",
595
text: replyText,
596
forum: forumDid,
597
reply: {
···
609
});
610
setReplyText("");
611
setReplyingTo(null);
612
+
//invalidateTopicQuery();
613
} catch (e) {
614
setMutationError(`Failed to post reply: ${(e as Error).message}`);
615
} finally {
616
setIsSubmitting(false);
617
}
618
};
619
+
if (!forum?.did || isOpdidLoading || isQueryLoading || isforumdidLoading || isopQueryLoading) {
620
+
return (
621
+
<TopicPageSkeleton />
622
+
)
623
+
}
624
625
+
// if (isError)
626
+
// return (
627
+
// <div className="text-red-500 p-8 text-center">
628
+
// Error: {(error as Error).message}
629
+
// </div>
630
+
// );
631
632
const topicPost = posts[0];
633
const postIndexBeingRepliedTo = replyingTo
···
655
656
{posts.map((post, index) => (
657
<PostCard
658
+
forumdid={forum?.did}
659
agent={agent}
660
key={post["$metadata.uri"]}
661
post={post}
662
+
//author={authors[post["$metadata.did"]]}
663
+
//reactions={reactions[post["$metadata.uri"]] || []}
664
index={index}
665
onSetReplyParent={handleSetReplyParent}
666
onNewReaction={handleCreateReaction}
+98
-80
src/routes/index.tsx
+98
-80
src/routes/index.tsx
···
3
import "../App.css";
4
import { esavQuery } from "@/helpers/esquery";
5
import { resolveIdentity } from "@/helpers/cachedidentityresolver";
6
7
type ForumDoc = {
8
"$metadata.uri": string;
···
42
must: [
43
{
44
term: {
45
-
"$metadata.collection": "com.example.ft.forum.definition",
46
},
47
},
48
{ term: { "$metadata.rkey": "self" } },
···
87
});
88
89
export const Route = createFileRoute("/")({
90
-
loader: ({ context: { queryClient } }) =>
91
-
queryClient.ensureQueryData(forumsQueryOptions(queryClient)),
92
component: Home,
93
-
pendingComponent: ForumGridSkeleton,
94
-
errorComponent: ({ error }) => (
95
-
<div className="text-red-500 p-4">Error: {(error as Error).message}</div>
96
-
),
97
});
98
99
function ForumGridSkeleton() {
···
138
}
139
140
function Home() {
141
-
const initialData = Route.useLoaderData();
142
-
const queryClient = useQueryClient();
143
144
-
const { data: forums }: { data: ResolvedForum[] } = useQuery({
145
-
...forumsQueryOptions(queryClient),
146
-
initialData,
147
-
});
148
149
return (
150
<div className="w-full flex flex-col items-center">
···
155
</div>
156
157
<div className="mt-4 w-full forum-grid">
158
-
{forums.map((forum) => {
159
-
const did = forum?.["$metadata.did"];
160
-
const { resolvedIdentity } = forum;
161
-
if (!resolvedIdentity) return null;
162
163
-
const cidBanner = forum?.$raw?.banner?.ref?.$link;
164
-
const cidAvatar = forum?.$raw?.avatar?.ref?.$link;
165
166
-
const bannerUrl =
167
-
cidBanner && resolvedIdentity
168
-
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}`
169
-
: null;
170
171
-
const avatarUrl =
172
-
cidAvatar && resolvedIdentity
173
-
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}`
174
-
: null;
175
176
-
return (
177
-
<Link
178
-
// @ts-ignore
179
-
to={`/f/@${resolvedIdentity.handle}`}
180
-
className="block"
181
-
key={forum?.$metadata?.uri}
182
-
>
183
-
<div
184
-
key={forum?.$metadata?.uri}
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"
186
-
>
187
-
{bannerUrl && (
188
-
<div
189
-
className="absolute inset-0 bg-cover bg-center"
190
-
style={{ backgroundImage: `url(${bannerUrl})` }}
191
-
/>
192
-
)}
193
-
<div className="absolute inset-0 bg-black/60" />
194
-
<div className="relative z-10 flex flex-col justify-between h-full p-5">
195
-
<div className="flex justify-between items-start gap-4">
196
-
<div className="flex flex-col">
197
-
{resolvedIdentity?.handle && (
198
-
<div className="text-blue-300 text-base font-mono mb-1">
199
-
/f/@{resolvedIdentity.handle}
200
-
</div>
201
-
)}
202
-
<div className="text-white text-2xl font-bold leading-tight">
203
-
{forum.displayName || "Unnamed Forum"}
204
-
</div>
205
-
</div>
206
-
{avatarUrl && (
207
-
<img
208
-
src={avatarUrl}
209
-
alt="Avatar"
210
-
className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0"
211
-
/>
212
-
)}
213
-
</div>
214
-
<div className="flex flex-col gap-2 mt-4">
215
-
<div className="text-sm text-gray-200 line-clamp-2">
216
-
{forum.description || "No description available."}
217
-
</div>
218
-
<div className="text-xs text-gray-400 font-medium">
219
-
0 members · ~0 topics · Active a while ago
220
-
</div>
221
-
</div>
222
-
</div>
223
-
</div>
224
-
</Link>
225
-
);
226
-
})}
227
</div>
228
</div>
229
</div>
230
-
</div>
231
);
232
-
}
···
3
import "../App.css";
4
import { esavQuery } from "@/helpers/esquery";
5
import { resolveIdentity } from "@/helpers/cachedidentityresolver";
6
+
import { useCachedProfileJotai, useEsavDocument, useEsavQuery } from "@/esav/hooks";
7
+
import type { QueryDoc } from "@/esav/types";
8
9
type ForumDoc = {
10
"$metadata.uri": string;
···
44
must: [
45
{
46
term: {
47
+
"$metadata.collection": "party.whey.ft.forum.definition",
48
},
49
},
50
{ term: { "$metadata.rkey": "self" } },
···
89
});
90
91
export const Route = createFileRoute("/")({
92
component: Home,
93
});
94
95
function ForumGridSkeleton() {
···
134
}
135
136
function Home() {
137
+
const homeQuery = {
138
+
query: {
139
+
bool: {
140
+
must: [
141
+
{
142
+
term: {
143
+
"$metadata.collection": "party.whey.ft.forum.definition",
144
+
},
145
+
},
146
+
{ term: { "$metadata.rkey": "self" } },
147
+
],
148
+
},
149
+
},
150
+
sort: [{ '$metadata.indexedAt': 'desc' }],
151
+
size: 50,
152
+
};
153
+
const { uris, isLoading } = useEsavQuery("forumtest", homeQuery);
154
155
+
if (isLoading) {
156
+
return <ForumGridSkeleton />
157
+
}
158
159
return (
160
<div className="w-full flex flex-col items-center">
···
165
</div>
166
167
<div className="mt-4 w-full forum-grid">
168
+
{uris.map((uri) => (
169
+
<ForumItem key={uri} uri={uri} />
170
+
))}
171
+
</div>
172
+
</div>
173
+
</div>
174
+
</div>
175
+
);
176
+
}
177
178
+
function ForumItem({uri}:{uri:string}){
179
+
const data = useEsavDocument(uri);
180
+
const did = data?.doc?.["$metadata.did"];
181
+
const [profile, isLoading] = useCachedProfileJotai(did);
182
+
if (!data) return null
183
+
const forum = data.doc;
184
+
const resolvedIdentity = profile;
185
186
+
const cidBanner = resolvedIdentity?.profile.banner?.ref?.$link;
187
+
const cidAvatar = resolvedIdentity?.profile.avatar?.ref?.$link;
188
189
+
const bannerUrl =
190
+
cidBanner && resolvedIdentity
191
+
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}`
192
+
: null;
193
194
+
const avatarUrl =
195
+
cidAvatar && resolvedIdentity
196
+
? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}`
197
+
: null;
198
+
199
+
200
+
return (
201
+
<Link
202
+
// @ts-expect-error force "@" instead of the encoded one
203
+
to={`/f/@${resolvedIdentity?.handle}`}
204
+
className="block"
205
+
key={forum["$metadata.uri"]}
206
+
>
207
+
<div
208
+
key={forum["$metadata.uri"]}
209
+
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"
210
+
>
211
+
{bannerUrl && (
212
+
<div
213
+
className="absolute inset-0 bg-cover bg-center"
214
+
style={{ backgroundImage: `url(${bannerUrl})` }}
215
+
/>
216
+
)}
217
+
<div className="absolute inset-0 bg-black/60" />
218
+
<div className="relative z-10 flex flex-col justify-between h-full p-5">
219
+
<div className="flex justify-between items-start gap-4">
220
+
<div className="flex flex-col">
221
+
{resolvedIdentity?.handle && (
222
+
<div className="text-blue-300 text-base font-mono mb-1">
223
+
/f/@{resolvedIdentity.handle}
224
+
</div>
225
+
)}
226
+
<div className="text-white text-2xl font-bold leading-tight">
227
+
{resolvedIdentity?.profile.displayName || "Unnamed Forum"}
228
+
</div>
229
+
</div>
230
+
{avatarUrl && (
231
+
<img
232
+
src={avatarUrl}
233
+
alt="Avatar"
234
+
className="w-12 h-12 rounded-full object-cover border border-zinc-700 flex-shrink-0"
235
+
/>
236
+
)}
237
+
</div>
238
+
<div className="flex flex-col gap-2 mt-4">
239
+
<div className="text-sm text-gray-200 line-clamp-2">
240
+
{String(forum.description || "No description available.")}
241
+
</div>
242
+
<div className="text-xs text-gray-400 font-medium">
243
+
0 members · ~0 topics · Active a while ago
244
+
</div>
245
</div>
246
</div>
247
</div>
248
+
</Link>
249
);
250
+
}
+9
-7
src/routes/search.tsx
+9
-7
src/routes/search.tsx
···
18
PostCard,
19
PostCardSkeleton,
20
} from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey";
21
22
type PostDoc = {
23
"$metadata.uri": string;
···
71
function SearchResultCard({ post, ...rest }: SearchResultCardProps) {
72
const navigate = useNavigate();
73
const [forumHandle, setForumHandle] = useState<string | undefined>(undefined);
74
const { get, set } = usePersistentStore();
75
76
const thing = post["forum"]// || new AtUripost["root"]
···
162
)}
163
</div>
164
165
-
<PostCard {...rest} post={post} onSetReplyParent={handleNavigateToPost} />
166
</div>
167
);
168
}
···
208
},
209
filter: [
210
{
211
-
term: { "$metadata.collection": "com.example.ft.topic.post" },
212
},
213
],
214
},
···
238
must: [
239
{
240
term: {
241
-
"$metadata.collection": "com.example.ft.topic.reaction",
242
},
243
},
244
],
···
263
}>({
264
query: {
265
bool: {
266
-
must: [{ term: { $type: "com.example.ft.user.profile" } }],
267
filter: [{ terms: { "$metadata.did": allDids } }],
268
},
269
},
···
349
const date = new Date().toISOString();
350
const response = await agent.com.atproto.repo.createRecord({
351
repo: agent.did,
352
-
collection: "com.example.ft.topic.reaction",
353
record: {
354
-
$type: "com.example.ft.topic.reaction",
355
reactionEmoji: emoji,
356
subject: postUri,
357
createdAt: date,
···
359
});
360
const uri = new AtUri(response.data.uri)
361
const newReaction: ReactionDoc = {
362
-
"$metadata.collection": "com.example.ft.topic.reaction",
363
"$metadata.uri": response.data.uri,
364
"$metadata.cid": response.data.cid,
365
"$metadata.did": agent.did,
···
18
PostCard,
19
PostCardSkeleton,
20
} from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey";
21
+
import { useCachedProfileJotai } from "@/esav/hooks";
22
23
type PostDoc = {
24
"$metadata.uri": string;
···
72
function SearchResultCard({ post, ...rest }: SearchResultCardProps) {
73
const navigate = useNavigate();
74
const [forumHandle, setForumHandle] = useState<string | undefined>(undefined);
75
+
const [did, loadinger] = useCachedProfileJotai(forumHandle)
76
const { get, set } = usePersistentStore();
77
78
const thing = post["forum"]// || new AtUripost["root"]
···
164
)}
165
</div>
166
167
+
{did && (<PostCard forumdid={did.did} {...rest} post={post} onSetReplyParent={handleNavigateToPost} />)}
168
</div>
169
);
170
}
···
210
},
211
filter: [
212
{
213
+
term: { "$metadata.collection": "party.whey.ft.topic.post" },
214
},
215
],
216
},
···
240
must: [
241
{
242
term: {
243
+
"$metadata.collection": "party.whey.ft.topic.reaction",
244
},
245
},
246
],
···
265
}>({
266
query: {
267
bool: {
268
+
must: [{ term: { $type: "party.whey.ft.user.profile" } }],
269
filter: [{ terms: { "$metadata.did": allDids } }],
270
},
271
},
···
351
const date = new Date().toISOString();
352
const response = await agent.com.atproto.repo.createRecord({
353
repo: agent.did,
354
+
collection: "party.whey.ft.topic.reaction",
355
record: {
356
+
$type: "party.whey.ft.topic.reaction",
357
reactionEmoji: emoji,
358
subject: postUri,
359
createdAt: date,
···
361
});
362
const uri = new AtUri(response.data.uri)
363
const newReaction: ReactionDoc = {
364
+
"$metadata.collection": "party.whey.ft.topic.reaction",
365
"$metadata.uri": response.data.uri,
366
"$metadata.cid": response.data.cid,
367
"$metadata.did": agent.did,