From e82d8d2b2af98b82e71abcc6cffa9ded1b3fcb38 Mon Sep 17 00:00:00 2001 From: rimar1337 <132627503+rimar1337@users.noreply.github.com> Date: Fri, 8 Aug 2025 22:12:39 +0700 Subject: [PATCH] ESAV Live and new namespace --- README.md | 22 +- package-lock.json | 30 + package.json | 1 + src/esav/ESAVLiveProvider.tsx | 111 +++ src/esav/atoms.ts | 69 ++ src/esav/components.tsx | 167 +++++ src/esav/hooks.ts | 313 +++++++++ src/esav/types.ts | 52 ++ src/helpers/cachedidentityresolver.ts | 1 - src/main.tsx | 26 +- src/routes/__root.tsx | 5 +- src/routes/f/$forumHandle.tsx | 198 ++---- src/routes/f/$forumHandle/index.tsx | 643 ++++++++---------- .../$forumHandle/t/$userHandle/$topicRKey.tsx | 490 +++++++------ src/routes/index.tsx | 182 ++--- src/routes/search.tsx | 16 +- 16 files changed, 1522 insertions(+), 804 deletions(-) create mode 100644 src/esav/ESAVLiveProvider.tsx create mode 100644 src/esav/atoms.ts create mode 100644 src/esav/components.tsx create mode 100644 src/esav/hooks.ts create mode 100644 src/esav/types.ts diff --git a/README.md b/README.md index c66b413..fbe689b 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,21 @@ Discuss at: [https://forumtest.whey.party/f/@forumtest.whey.party](https://forum custom record types: ```json "record_types": [ - "com.example.ft.topic.post", - "com.example.ft.topic.reaction", - "com.example.ft.topic.moderation", - "com.example.ft.forum.definition", - "com.example.ft.forum.layout", - "com.example.ft.forum.request", - "com.example.ft.forum.accept", - "com.example.ft.forum.category" + "party.whey.ft.topic.post", + "party.whey.ft.topic.reaction", + "party.whey.ft.topic.moderation", + "party.whey.ft.forum.definition", + "party.whey.ft.forum.layout", + "party.whey.ft.forum.request", + "party.whey.ft.forum.accept", + "party.whey.ft.forum.category" ], ``` custom indexes: ```json "index_fields": { - "com.example.ft.topic.reaction": { + "party.whey.ft.topic.reaction": { "subject": { "id": "reactionSubject", "type": "keyword" @@ -36,7 +36,7 @@ custom indexes: "type": "keyword" } }, - "com.example.ft.topic.post": { + "party.whey.ft.topic.post": { "text": { "id": "text", "type": "text" @@ -58,7 +58,7 @@ custom indexes: "type": "keyword" } }, - "com.example.ft.forum.definition": { + "party.whey.ft.forum.definition": { "description": { "id": "description", "type": "text" diff --git a/package-lock.json b/package-lock.json index 14815f2..2ae9453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@tanstack/react-router-devtools": "^1.130.2", "@tanstack/router-plugin": "^1.121.2", "idb-keyval": "^6.2.2", + "jotai": "^2.13.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.1.11" @@ -3649,6 +3650,35 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jotai": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.13.0.tgz", + "integrity": "sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0", + "@babel/template": ">=7.0.0", + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index b235eda..0167e4f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@tanstack/react-router-devtools": "^1.130.2", "@tanstack/router-plugin": "^1.121.2", "idb-keyval": "^6.2.2", + "jotai": "^2.13.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.1.11" diff --git a/src/esav/ESAVLiveProvider.tsx b/src/esav/ESAVLiveProvider.tsx new file mode 100644 index 0000000..15c0426 --- /dev/null +++ b/src/esav/ESAVLiveProvider.tsx @@ -0,0 +1,111 @@ +import { useSetAtom, useStore } from "jotai"; +import { useEffect, useRef, type PropsWithChildren } from "react"; +import { addLogEntryAtom, documentsAtom, queryStateFamily, websocketAtom, websocketStatusAtom } from './atoms'; +import type { QueryDeltaMessage } from "./types"; + +export function ESAVLiveProvider({ + children, + url, +}: PropsWithChildren<{ url: string }>) { + const store = useStore(); + const setWebsocket = useSetAtom(websocketAtom); + const setWebsocketStatus = useSetAtom(websocketStatusAtom); + const addLog = useSetAtom(addLogEntryAtom); + + const reconnectTimer = useRef(null); + + const isUnmounting = useRef(false); + + useEffect(() => { + let reconnectAttempts = 0; + const connect = () => { + if (isUnmounting.current) return; + + console.log(`[ESAV] Connecting (Attempt ${reconnectAttempts + 1})...`); + setWebsocketStatus("connecting"); + const ws = new WebSocket(url); + + ws.onopen = () => { + console.log("[ESAV] WebSocket connection opened"); + setWebsocketStatus("open"); + setWebsocket(ws); + reconnectAttempts = 0; + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + } + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + if (message.type === "query-delta") { + addLog({ type: 'incoming', payload: message }); + const deltaMessage = message as QueryDeltaMessage; + const { documents, queries } = deltaMessage + + if (documents) { + store.set(documentsAtom, (prev) => ({ ...prev, ...documents })); + } + + if (queries) { + for (const queryId in queries) { + const targetQueryAtom = queryStateFamily(queryId); + store.set(targetQueryAtom, queries[queryId]); + } + } + } else if (message.type === "ping") { + ws.send(JSON.stringify({ type: "pong" })); + } else if (message.type === "error") { + addLog({ type: 'incoming', payload: message }); + console.error("[ESAV] Received error from server:", message.error); + } + } catch (e) { + console.error("[ESAV] Failed to parse message from server", e); + } + }; + ws.onclose = () => { + console.log("[ESAV] WebSocket connection closed"); + setWebsocket(null); + if (isUnmounting.current) { + console.log("[ESAV] Unmounting, not reconnecting."); + return; + } + + setWebsocketStatus("closed"); + + const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000); + console.log(`[ESAV] Will attempt to reconnect in ${delay / 1000}s`); + reconnectAttempts++; + + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + reconnectTimer.current = setTimeout(connect, delay); + }; + + ws.onerror = (err) => { + console.error("[ESAV] WebSocket error", err); + ws.close(); + }; + }; + + isUnmounting.current = false; + connect(); + + return () => { + isUnmounting.current = true; + console.log( + "[ESAV] Provider unmounting. Cleaning up timers and connection." + ); + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + } + const currentWs = store.get(websocketAtom); + if (currentWs) { + currentWs.onclose = null; + currentWs.close(); + } + }; + }, [url, store, setWebsocket, setWebsocketStatus]); + + return <>{children}; +} diff --git a/src/esav/atoms.ts b/src/esav/atoms.ts new file mode 100644 index 0000000..ebc50e5 --- /dev/null +++ b/src/esav/atoms.ts @@ -0,0 +1,69 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import type { EsavDocument, QueryState, LogEntry } from './types'; +const MAX_LOG_SIZE = 500; + +/** + * Manages the WebSocket instance itself. + * Should only be written to by the provider. + */ +export const websocketAtom = atom(null); + +/** + * Tracks the current status of the WebSocket connection. + */ +export const websocketStatusAtom = atom<'connecting' | 'open' | 'closed'>('closed'); + +/** + * A global, normalized cache for all documents received from the server. + * Maps a document URI (at://...) to its full data. + * This prevents data duplication across multiple queries. + */ +export const documentsAtom = atom>({}); + +/** + * A family of atoms to hold the state for each individual query. + * You get the state for a query by providing its unique queryId. + */ +export const queryStateFamily = atomFamily((_queryId: string) => + atom(null) +); + +/** + * Tracks active subscriptions and their component usage count. + * This is an internal atom used by our hooks to know when to + * send `subscribe` and `unsubscribe` messages. + */ +export const activeSubscriptionsAtom = atom< + Record }> +>({}); + + +/** + * Holds the array of log entries for display. + */ +export const logEntriesAtom = atom([]); + +let logIdCounter = 0; + +/** + * A "write-only" atom to add a new entry to the log. + * This encapsulates the logic for creating a new entry with an ID and timestamp. + * Any component can call this to add a log without needing to know the implementation details. + */ +export const addLogEntryAtom = atom( + null, + (get, set, newEntry: Omit) => { + const entry: LogEntry = { + id: logIdCounter++, + timestamp: new Date(), + ...newEntry, + }; + const currentLog = get(logEntriesAtom); + const newLog = [entry, ...currentLog]; + if (newLog.length > MAX_LOG_SIZE) { + newLog.length = MAX_LOG_SIZE; + } + set(logEntriesAtom, newLog); + } +); \ No newline at end of file diff --git a/src/esav/components.tsx b/src/esav/components.tsx new file mode 100644 index 0000000..9799af8 --- /dev/null +++ b/src/esav/components.tsx @@ -0,0 +1,167 @@ +import { useAtomValue } from "jotai"; +import { useState } from "react"; +import { websocketStatusAtom, logEntriesAtom } from "./atoms"; +import type { LogEntry } from "./types"; + + +export function ReconnectingHeader() { + const status = useAtomValue(websocketStatusAtom); + + if (status === "open") { + return null; + } + + const message = + status === "connecting" + ? "Connecting to ESAV Live..." + : "Connection lost. Attempting to reconnect..."; + + return ( +
+ {message} +
+ ); +} + +const LogEntryItem = ({ entry }: { entry: LogEntry }) => { + const { type, timestamp, payload } = entry; + + const typeStyles = { + incoming: { icon: "⬇️", color: "#4caf50", name: "Incoming" }, + outgoing: { icon: "⬆️", color: "#ffeb3b", name: "Outgoing" }, + status: { icon: "ℹ️", color: "#2196f3", name: "Status" }, + error: { icon: "❌", color: "#f44336", name: "Error" }, + }; + + const { icon, color, name } = typeStyles[type]; + + return ( +
+
+ {icon} + {name} + + {timestamp.toLocaleTimeString()} + +
+ {typeof payload === "object" ? ( +
+          {JSON.stringify(payload, null, 2)}
+        
+ ) : ( + {String(payload)} + )} +
+ ); +}; + +export function DeltaLogViewer() { + const [open, setOpen] = useState(false); + const log = useAtomValue(logEntriesAtom); + + return ( +
+
+ ESAV Live Log + +
+
+ {log.length === 0 ? ( +
+ Waiting for events... +
+ ) : ( + log.map((entry) => ) + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/esav/hooks.ts b/src/esav/hooks.ts new file mode 100644 index 0000000..caaaf7f --- /dev/null +++ b/src/esav/hooks.ts @@ -0,0 +1,313 @@ +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + activeSubscriptionsAtom, + documentsAtom, + queryStateFamily, + websocketAtom, + websocketStatusAtom, + addLogEntryAtom +} from './atoms'; +import type { EsavDocument, QueryDoc, SubscribeMessage, UnsubscribeMessage } from './types'; +import { atomWithStorage } from 'jotai/utils'; + +interface UseEsavQueryOptions { + enabled?: boolean; +} + +/** + * The primary hook for subscribing to a live query and getting its results. + * Manages sending subscribe/unsubscribe messages automatically. + * + * @param queryId A unique ID for this query. + * @param esQuery The full Elasticsearch query object. + * @param options Hook options, like `enabled`. + * @returns The hydrated query results and loading status. + */ +export function useEsavQuery( + queryId: string, + esQuery: Record, + options: UseEsavQueryOptions = { enabled: true } +) { + // @ts-expect-error intended + const [activeSubscriptions, setActiveSubscriptions] = useAtom(activeSubscriptionsAtom); + const ws = useAtomValue(websocketAtom); + const addLog = useSetAtom(addLogEntryAtom); + const wsStatus = useAtomValue(websocketStatusAtom); + const queryState = useAtomValue(queryStateFamily(queryId)); + const allDocuments = useAtomValue(documentsAtom); + + const { enabled = true } = options; + const stringifiedEsQuery = useMemo(() => JSON.stringify(esQuery), [esQuery]); + + const esQueryRef = useRef(esQuery); + const queryStateRef = useRef(queryState); + useEffect(() => { + esQueryRef.current = esQuery; + queryStateRef.current = queryState; + }); + + useEffect(() => { + if (!enabled || wsStatus !== 'open' || !ws) { + return; + } + + const currentQuery = esQueryRef.current; + + setActiveSubscriptions((prev) => { + const count = prev[queryId]?.count ?? 0; + if (count === 0) { + console.log(`[ESAV] Subscribing to ${queryId}`); + const message: SubscribeMessage = { + type: 'subscribe', + queryId, + esquery: currentQuery, + ecid: queryStateRef.current?.ecid, + }; + addLog({ type: 'outgoing', payload: message }); + ws.send(JSON.stringify(message)); + } + return { ...prev, [queryId]: { count: count + 1, esQuery: currentQuery } }; + }); + + return () => { + setActiveSubscriptions((prev) => { + const count = prev[queryId]?.count ?? 1; + if (count <= 1) { + console.log(`[ESAV] Unsubscribing from ${queryId}`); + if (ws.readyState === WebSocket.OPEN) { + const message: UnsubscribeMessage = { type: 'unsubscribe', queryId }; + addLog({ type: 'outgoing', payload: message }); + ws.send(JSON.stringify(message)); + } + const { [queryId]: _, ...rest } = prev; + return rest; + } else { + return { ...prev, [queryId]: { ...prev[queryId], count: count - 1 } }; + } + }); + }; + }, [queryId, stringifiedEsQuery, enabled, ws, wsStatus, setActiveSubscriptions]); + + + const hydratedData = useMemo(() => { + if (!queryState?.result) return []; + return queryState.result + .map((uri) => allDocuments[uri]) + .filter(Boolean); + }, [queryState?.result, allDocuments]); + + const isLoading = wsStatus !== 'open' || queryState === null; + + return { + data: hydratedData, + uris: queryState?.result ?? [], + ecid: queryState?.ecid, + isLoading, + status: wsStatus, + }; +} + +type DocumentMap = Record; + +/** + * A simple hook to get a single document from the global cache. + * @param uri The at:// URI of the document. + */ +export function useEsavDocument(uri: string): EsavDocument | undefined; +export function useEsavDocument(uri: string[]): DocumentMap; +export function useEsavDocument(uri: undefined): undefined; +export function useEsavDocument(uri: string | string[] | undefined): EsavDocument | undefined | DocumentMap { + const allDocuments = useAtomValue(documentsAtom); + + if (typeof uri === 'string') { + return allDocuments[uri]; + } + + if (Array.isArray(uri)) { + return uri.reduce((acc, key) => { + acc[key] = allDocuments[key]; + return acc; + }, {}); + } + + return undefined; +} + + +export interface Profile { + did: string; + handle: string; + pdsUrl: string; + profile: { + "$type": "app.bsky.actor.profile", + "avatar"?: { + "$type": "blob", + "ref": { + "$link": string + }, + "mimeType": string, + "size": number + }, + "banner"?: { + "$type": "blob", + "ref": { + "$link": string + }, + "mimeType": string, + "size": number + }, + "createdAt": string, + "description": string, + "displayName": string + }; +} + +/** + * A persistent atom to store the mapping from a user's handle to their DID. + * This avoids re-resolving handles we've already seen. + * + * Stored in localStorage under the key 'handleToDidCache'. + */ +const handleToDidAtom = atomWithStorage>( + 'handleToDidCache', + {} +); + +/** + * A persistent atom to store the full profile document, keyed by the user's DID. + * This is the primary cache for profile data. + * + * Stored in localStorage under the key 'didToProfileCache'. + */ +const didToProfileAtom = atomWithStorage>( + 'didToProfileCache', + {} +); + +/** + * Get a cached Profile document using Jotai persistent atoms. + * It will first check the cache, and if the profile is not found, + * it will fetch it from the network and update the cache. + * + * @param input The user's did or handle (with or without the @) + * @returns A tuple containing the Profile (or null) and a boolean indicating if it's loading. + */ +export const useCachedProfileJotai = (input?: string | null): [Profile | null, boolean] => { + const [handleToDidCache, setHandleToDidCache] = useAtom(handleToDidAtom); + const [didToProfileCache, setDidToProfileCache] = useAtom(didToProfileAtom); + + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const resolveAndFetchProfile = async () => { + if (!input) { + setProfile(null); + return; + } + + setIsLoading(true); + + const normalizedInput = normalizeHandle(input); + const type = classifyIdentifier(normalizedInput); + + if (type === "unknown") { + console.error("Invalid identifier provided:", input); + setProfile(null); + setIsLoading(false); + return; + } + + let didFromCache: string | undefined; + if (type === 'handle') { + didFromCache = handleToDidCache[normalizedInput]; + } else { + didFromCache = normalizedInput; + } + + if (didFromCache && didToProfileCache[didFromCache]) { + setProfile(didToProfileCache[didFromCache]); + setIsLoading(false); + return; + } + + try { + const queryParam = type === "handle" ? "handle" : "did"; + const res = await fetch( + `https://esav.whey.party/xrpc/party.whey.esav.resolveIdentity?${queryParam}=${normalizedInput}&includeBskyProfile=true` + ); + + if (!res.ok) { + throw new Error(`Failed to fetch profile for ${input}`); + } + + const newProfile: Profile = await res.json(); + + setDidToProfileCache(prev => ({ ...prev, [newProfile.did]: newProfile })); + setHandleToDidCache(prev => ({ ...prev, [newProfile.handle]: newProfile.did })); + + setProfile(newProfile); + + } catch (error) { + console.error(error); + setProfile(null); + } finally { + setIsLoading(false); + } + }; + + resolveAndFetchProfile(); + + }, [input, handleToDidCache, didToProfileCache, setHandleToDidCache, setDidToProfileCache]); + + return [profile, isLoading]; +}; + +export type IdentifierType = "did" | "handle" | "unknown"; + +function classifyIdentifier(input: string | null | undefined): IdentifierType { + if (!input) return "unknown"; + if (/^did:[a-z0-9]+:[\w.-]+$/i.test(input)) return "did"; + if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(input)) return "handle"; + return "unknown"; +} + +function normalizeHandle(input: string): string { + if (!input) return ''; + return input.startsWith('@') ? input.slice(1) : input; +} + + + +type AtUriParts = { + did: string; + collection: string; + rkey: string; +}; + +export function parseAtUri(uri: string): AtUriParts | null { + if (!uri.startsWith('at://')) return null; + + const parts = uri.slice(5).split('/'); + if (parts.length < 3) return null; + + const [did, collection, ...rest] = parts; + const rkey = rest.join('/'); // in case rkey includes slashes (rare, but allowed) + + return { did, collection, rkey }; +} +/** + * use useEsavDocument instead its nicer + * @deprecated + * @param uris + * @returns + */ +export function useResolvedDocuments(uris: string[]) { + const allDocuments = useAtomValue(documentsAtom); + + return uris.reduce>((acc, uri) => { + acc[uri] = allDocuments[uri].doc; + return acc; + }, {}); +} \ No newline at end of file diff --git a/src/esav/types.ts b/src/esav/types.ts new file mode 100644 index 0000000..efcdad0 --- /dev/null +++ b/src/esav/types.ts @@ -0,0 +1,52 @@ +// A document as stored in our global cache +export interface EsavDocument { + cid: string; + doc: QueryDoc; +} + +export interface QueryDoc { + "$metadata.uri": string; + "$metadata.cid": string; + "$metadata.did": string; + "$metadata.collection": string; + "$metadata.rkey": string; + "$metadata.indexedAt": string; + $raw?: Record; + [key: string]: unknown; +} + +// The state for a single query subscription +export interface QueryState { + ecid: string; + result: string[]; // An ordered array of document URIs +} + +// The server->client message we expect +export interface QueryDeltaMessage { + type: 'query-delta'; + documents?: Record; + queries?: Record; +} + +// The client->server message for subscribing +export interface SubscribeMessage { + type: 'subscribe'; + queryId: string; + esquery: Record; + ecid?: string; // Optional last known ECID +} + +// The client->server message for unsubscribing +export interface UnsubscribeMessage { + type: 'unsubscribe'; + queryId: string; +} + +export type LogEntryType = 'incoming' | 'outgoing' | 'status' | 'error'; + +export interface LogEntry { + id: number; + timestamp: Date; + type: LogEntryType; + payload: any; +} \ No newline at end of file diff --git a/src/helpers/cachedidentityresolver.ts b/src/helpers/cachedidentityresolver.ts index e47abb2..7895410 100644 --- a/src/helpers/cachedidentityresolver.ts +++ b/src/helpers/cachedidentityresolver.ts @@ -3,7 +3,6 @@ export type ResolvedIdentity = handle: string did: string pdsUrl: string - bskyPds: boolean } | undefined const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour diff --git a/src/main.tsx b/src/main.tsx index 5a3b709..7d345d6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,8 +10,10 @@ import reportWebVitals from "./reportWebVitals.ts"; import { AuthProvider } from "./providers/PassAuthProvider.tsx"; import { PersistentStoreProvider } from "./providers/PersistentStoreProvider.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ESAVLiveProvider } from "./esav/ESAVLiveProvider.tsx"; const queryClient = new QueryClient(); +const ESAV_WEBSOCKET_URL = 'wss://esav.whey.party/xrpc/party.whey.esav.esSync'; // Create a new router instance const router = createRouter({ @@ -37,20 +39,22 @@ const rootElement = document.getElementById("app"); if (rootElement && !rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - - - - - {/* Pass the router instance with the context to the provider */} - - - - - + // + + + + + {/* Pass the router instance with the context to the provider */} + + + + + + // ); } // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); \ No newline at end of file +reportWebVitals(); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 94502c0..792ff21 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { DeltaLogViewer, ReconnectingHeader } from "@/esav/components"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -14,9 +15,11 @@ export const Route = createRootRouteWithContext<{ component: () => ( <>
+ + ), -}); +}); \ No newline at end of file diff --git a/src/routes/f/$forumHandle.tsx b/src/routes/f/$forumHandle.tsx index 23f51c0..b5efe88 100644 --- a/src/routes/f/$forumHandle.tsx +++ b/src/routes/f/$forumHandle.tsx @@ -5,8 +5,9 @@ import { import { esavQuery } from "@/helpers/esquery"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { Outlet } from "@tanstack/react-router"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; +import { useCachedProfileJotai, useEsavDocument, useEsavQuery } from "@/esav/hooks"; type ForumDoc = { "$metadata.uri": string; @@ -34,120 +35,8 @@ type ResolvedForumData = { identity: ResolvedIdentity; }; -const forumQueryOptions = (queryClient: QueryClient, forumHandle: string) => ({ - queryKey: ["forum", forumHandle], - queryFn: async (): Promise => { - if (!forumHandle) { - throw new Error("Forum handle is required."); - } - const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, ""); - - const identity = await queryClient.fetchQuery({ - queryKey: ["identity", normalizedHandle], - queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), - staleTime: 1000 * 60 * 60 * 24, // 24 hours - }); - - if (!identity) { - throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); - } - - const forumRes = await esavQuery<{ - hits: { hits: { _source: ForumDoc }[] }; - }>({ - query: { - bool: { - must: [ - { term: { "$metadata.did": identity.did } }, - { - term: { - "$metadata.collection": "com.example.ft.forum.definition", - }, - }, - { term: { "$metadata.rkey": "self" } }, - ], - }, - }, - }); - - const forumDoc = forumRes.hits.hits[0]?._source; - if (!forumDoc) { - throw new Error("Forum definition not found."); - } - - return { forumDoc, identity }; - }, -}); - export const Route = createFileRoute("/f/$forumHandle")({ - loader: async ({ context: { queryClient }, params }) => { - const normalizedHandle = decodeURIComponent(params.forumHandle).replace(/^@/, ""); - - const identity = await queryClient.fetchQuery({ - queryKey: ["identity", normalizedHandle], - queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), - staleTime: 1000 * 60 * 60 * 24, - }); - - if (!identity) { - throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); - } - - const forums = queryClient.getQueryData(["forums", "list"]); - const forumFromList = forums?.find(f => f["$metadata.did"] === identity.did) - - const initialData: ResolvedForumData | undefined = forumFromList - ? { - forumDoc: forumFromList, - identity: { - handle: forumFromList.resolvedIdentity!.handle, - did: forumFromList["$metadata.did"], - pdsUrl: forumFromList.resolvedIdentity!.pdsUrl, - bskyPds: false, - }, - } - : undefined - - if (initialData) { - return initialData; - } - - // Fallback to direct fetch - const forumRes = await esavQuery<{ - hits: { hits: { _source: ForumDoc }[] }; - }>({ - query: { - bool: { - must: [ - { term: { "$metadata.did": identity.did } }, - { - term: { - "$metadata.collection": "com.example.ft.forum.definition", - }, - }, - { term: { "$metadata.rkey": "self" } }, - ], - }, - }, - }); - - const forumDoc = forumRes.hits.hits[0]?._source; - if (!forumDoc) { - throw new Error("Forum definition not found."); - } - - return { - forumDoc, - identity, - }; -}, component: ForumHeader, - pendingComponent: ForumHeaderContentSkeleton, - errorComponent: ({ error }) => ( -
- Error: {(error as Error).message} -
- ), }); function ForumHeaderContentSkeleton() { @@ -186,7 +75,6 @@ function ForumHeaderContentSkeleton() { - ); } @@ -227,25 +115,58 @@ function ForumHeaderSearch() { ); } -function ForumHeaderContent({ - forumDoc, - identity, - forumHandle, -}: { - forumDoc: ForumDoc; - identity: ResolvedIdentity; - forumHandle: string; -}) { - const did = identity?.did; - const bannerCid = forumDoc?.$raw?.banner?.ref?.$link; - const avatarCid = forumDoc?.$raw?.avatar?.ref?.$link; +function ForumHeaderContent() { + const { forumHandle } = Route.useParams(); + const [profile, isLoading] = useCachedProfileJotai(forumHandle); + + const forumQuery = useMemo(() => { + if (!profile?.did) { + return null; + } + + const query = { + query: { + bool: { + must: [ + { term: { "$metadata.did": profile.did } }, + { + term: { + "$metadata.collection": "party.whey.ft.forum.definition", + }, + }, + { term: { "$metadata.rkey": "self" } }, + ], + }, + }, + sort: [{ '$metadata.indexedAt': 'desc' }] + }; + return query; + }, [profile?.did]); + + const { + uris = [], + isLoading: isQueryLoading, + } = useEsavQuery(`forumtest/${profile?.did}`, forumQuery!, { + enabled: !!profile?.did && !!forumQuery, + }); + + const data = useEsavDocument(uris[0]); + + if (!profile || isLoading || isQueryLoading || !data) { + return ; + } + + const did = profile.did; + const bannerCid = profile.profile?.banner?.ref?.$link; + const avatarCid = profile.profile?.avatar?.ref?.$link; + const bannerUrl = did && bannerCid - ? `${identity?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${bannerCid}` + ? `${profile?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${bannerCid}` : null; const avatarUrl = did && avatarCid - ? `${identity?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCid}` + ? `${profile?.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCid}` : null; return ( @@ -281,7 +202,7 @@ function ForumHeaderContent({ )}
- {forumDoc.displayName || "Unnamed Forum"} + {profile.profile.displayName || "Unnamed Forum"}
/f/{decodeURIComponent(forumHandle || "")} @@ -290,7 +211,7 @@ function ForumHeaderContent({
- {forumDoc.description || "No description provided."} + {profile.profile.description || "No description provided."}
@@ -325,24 +246,9 @@ function ForumHeaderContent({ } function ForumHeader() { - const { forumHandle } = Route.useParams(); - const initialData = Route.useLoaderData(); - const queryClient = useQueryClient(); - - const { data } = useQuery({ - ...forumQueryOptions(queryClient, forumHandle), - initialData, - }); - - const { forumDoc, identity } = data; - return ( <> - + ); diff --git a/src/routes/f/$forumHandle/index.tsx b/src/routes/f/$forumHandle/index.tsx index 45525a3..5940a2c 100644 --- a/src/routes/f/$forumHandle/index.tsx +++ b/src/routes/f/$forumHandle/index.tsx @@ -4,7 +4,7 @@ import { Link, useParams, } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { resolveIdentity, type ResolvedIdentity, @@ -16,6 +16,14 @@ import { ChevronDownIcon, CheckIcon, Cross2Icon } from "@radix-ui/react-icons"; import { useAuth } from "@/providers/PassAuthProvider"; import { AtUri, BskyAgent } from "@atproto/api"; import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; +import { + useCachedProfileJotai, + useEsavQuery, + useEsavDocument, + parseAtUri, + type Profile, + useResolvedDocuments, +} from "@/esav/hooks"; type PostDoc = { "$metadata.uri": string; @@ -67,199 +75,6 @@ type TopicListData = { profilesMap: Record; }; -const topicListQueryOptions = ( - queryClient: QueryClient, - forumHandle: string -) => ({ - queryKey: ["topics", forumHandle], - queryFn: async (): Promise => { - const normalizedHandle = decodeURIComponent(forumHandle).replace(/^@/, ""); - - const identity = await queryClient.fetchQuery({ - queryKey: ["identity", normalizedHandle], - queryFn: () => resolveIdentity({ didOrHandle: normalizedHandle }), - staleTime: 1000 * 60 * 60 * 24, // 24 hours - }); - - if (!identity) { - throw new Error(`Could not resolve forum handle: @${normalizedHandle}`); - } - - const postRes = await esavQuery<{ - hits: { hits: { _source: PostDoc }[] }; - }>({ - query: { - bool: { - must: [ - { term: { forum: identity.did } }, - { term: { "$metadata.collection": "com.example.ft.topic.post" } }, - { bool: { must_not: [{ exists: { field: "root" } }] } }, - ], - }, - }, - sort: [{ "$metadata.indexedAt": { order: "desc" } }], - size: 100, - }); - const initialPosts = postRes.hits.hits.map((h) => h._source); - - const postsWithDetails = await Promise.all( - initialPosts.map(async (post) => { - const [repliesRes, latestReplyRes] = await Promise.all([ - esavQuery<{ - hits: { total: { value: number } }; - aggregations: { unique_dids: { buckets: { key: string }[] } }; - }>({ - size: 0, - track_total_hits: true, - query: { - bool: { must: [{ term: { root: post["$metadata.uri"] } }] }, - }, - aggs: { - unique_dids: { terms: { field: "$metadata.did", size: 10000 } }, - }, - }), - esavQuery<{ - hits: { hits: { _source: LatestReply }[] }; - }>({ - query: { - bool: { must: [{ term: { root: post["$metadata.uri"] } }] }, - }, - sort: [{ "$metadata.indexedAt": { order: "desc" } }], - size: 1, - _source: ["$metadata.did", "$metadata.indexedAt"], - }), - ]); - - const replyCount = repliesRes.hits.total.value; - const replyDids = repliesRes.aggregations.unique_dids.buckets.map( - (b) => b.key - ); - const participants = Array.from( - new Set([post["$metadata.did"], ...replyDids]) - ); - const latestReply = latestReplyRes.hits.hits[0]?._source ?? null; - - return { ...post, replyCount, participants, latestReply }; - }) - ); - - const postUris = postsWithDetails.map((p) => p["$metadata.uri"]); - const didsToResolve = new Set(); - postsWithDetails.forEach((p) => { - didsToResolve.add(p["$metadata.did"]); - p.participants?.forEach((did) => didsToResolve.add(did)); - if (p.latestReply) { - didsToResolve.add(p.latestReply["$metadata.did"]); - } - }); - const authorDids = Array.from(didsToResolve); - - const [reactionsRes, pdsProfiles] = await Promise.all([ - esavQuery<{ - hits: { - hits: { - _source: { reactionSubject: string; reactionEmoji: string }; - }[]; - }; - }>({ - query: { - bool: { - must: [ - { - term: { - "$metadata.collection": "com.example.ft.topic.reaction", - }, - }, - { terms: { reactionSubject: postUris } }, - ], - }, - }, - _source: ["reactionSubject", "reactionEmoji"], - size: 10000, - }), - Promise.all( - authorDids.map(async (did) => { - try { - const identityRes = await queryClient.fetchQuery({ - queryKey: ["identity", did], - queryFn: () => resolveIdentity({ didOrHandle: did }), - staleTime: 1000 * 60 * 60 * 24, - }); - - if (!identityRes?.pdsUrl) { - return { - did, - handle: identityRes?.handle ?? null, - pdsUrl: null, - profile: null, - }; - } - - const profileUrl = `${identityRes.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`; - const profileReq = await fetch(profileUrl); - - if (!profileReq.ok) { - console.warn( - `Failed to fetch profile for ${did} from ${identityRes.pdsUrl}` - ); - return { - did, - handle: identityRes.handle, - pdsUrl: identityRes.pdsUrl, - profile: null, - }; - } - - const profileData = await profileReq.json(); - return { - did, - handle: identityRes.handle, - pdsUrl: identityRes.pdsUrl, - profile: profileData.value, - }; - } catch (e) { - console.error(`Error resolving or fetching profile for ${did}`, e); - return { did, handle: null, pdsUrl: null, profile: null }; - } - }) - ), - ]); - - const reactionsByPost: Record> = {}; - for (const hit of reactionsRes.hits.hits) { - const { reactionSubject, reactionEmoji } = hit._source; - if (!reactionsByPost[reactionSubject]) - reactionsByPost[reactionSubject] = {}; - reactionsByPost[reactionSubject][reactionEmoji] = - (reactionsByPost[reactionSubject][reactionEmoji] || 0) + 1; - } - - const topReactions: Record = {}; - for (const uri in reactionsByPost) { - const counts = reactionsByPost[uri]; - const topEmoji = Object.entries(counts).reduce( - (a, b) => (b[1] > a[1] ? b : a), - ["", 0] - ); - if (topEmoji[0]) { - topReactions[uri] = { emoji: topEmoji[0], count: topEmoji[1] }; - } - } - - const profilesMap: Record = {}; - for (const p of pdsProfiles) { - profilesMap[p.did] = p; - } - - const finalPosts = postsWithDetails.map((post) => ({ - ...post, - topReaction: topReactions[post["$metadata.uri"]] || null, - })); - - return { posts: finalPosts, identity, profilesMap }; - }, -}); - function getRelativeTimeString(input: string | Date): string { const date = typeof input === "string" ? new Date(input) : input; const now = new Date(); @@ -283,17 +98,7 @@ function getRelativeTimeString(input: string | Date): string { } export const Route = createFileRoute("/f/$forumHandle/")({ - loader: ({ context: { queryClient }, params }) => - queryClient.ensureQueryData( - topicListQueryOptions(queryClient, params.forumHandle) - ), component: Forum, - pendingComponent: TopicListSkeleton, - errorComponent: ({ error }) => ( -
- Error: {(error as Error).message} -
- ), }); function ForumHeaderSkeleton() { @@ -378,21 +183,42 @@ function TopicListSkeleton() { } export function Forum() { + const { forumHandle } = Route.useParams(); + const [profile, isLoading] = useCachedProfileJotai(forumHandle); + + const postsQuery = useMemo(() => { + if (!profile?.did) { + return null; + } + + const query = { + query: { + bool: { + must: [ + { term: { forum: profile.did } }, + { term: { "$metadata.collection": "party.whey.ft.topic.post" } }, + { bool: { must_not: [{ exists: { field: "root" } }] } }, + ], + }, + }, + sort: [{ "$metadata.indexedAt": { order: "desc" } }] + }; + return query; + }, [profile?.did]); + + const { uris = [], isLoading: isQueryLoading } = useEsavQuery( + `forumtest/${profile?.did}/topics`, + postsQuery!, + { + enabled: !!profile?.did && !!postsQuery, + } + ); + const navigate = useNavigate(); const { agent, loading: authLoading } = useAuth(); - const { forumHandle } = useParams({ from: "/f/$forumHandle/" }); - const initialData = Route.useLoaderData(); const queryClient = useQueryClient(); - const { data } = useQuery({ - ...topicListQueryOptions(queryClient, forumHandle), - initialData, - refetchInterval: 1000 * 60, // refresh every minute - }); - - const { posts, identity, profilesMap } = data; - const [selectedCategory, setSelectedCategory] = useState("uncategorized"); const [sortOrder, setSortOrder] = useState("latest"); const [isModalOpen, setIsModalOpen] = useState(false); @@ -402,7 +228,7 @@ export function Forum() { const [formError, setFormError] = useState(null); const handleCreateTopic = async () => { - if (!agent || !agent.did || !identity) { + if (!agent || !agent.did) { setFormError("You must be logged in to create a topic."); return; } @@ -417,13 +243,13 @@ export function Forum() { try { const response = await agent.com.atproto.repo.createRecord({ repo: agent.did, - collection: "com.example.ft.topic.post", + collection: "party.whey.ft.topic.post", record: { - $type: "com.example.ft.topic.post", + $type: "party.whey.ft.topic.post", title: newTopicTitle, text: newTopicText, createdAt: new Date().toISOString(), - forum: identity.did, + forum: profile?.did, }, }); @@ -446,6 +272,10 @@ export function Forum() { } }; + if (!profile || isLoading || isQueryLoading) { + return ; + } + return (
@@ -525,8 +355,8 @@ export function Forum() { @@ -640,127 +470,15 @@ export function Forum() { - {posts.length > 0 ? ( - posts.map((post) => { - const rootAuthorProfile = profilesMap[post["$metadata.did"]]; - - const lastPostAuthorDid = post.latestReply - ? post.latestReply["$metadata.did"] - : post["$metadata.did"]; - const lastPostTimestamp = post.latestReply - ? post.latestReply["$metadata.indexedAt"] - : post["$metadata.indexedAt"]; - const lastPostAuthorProfile = profilesMap[lastPostAuthorDid]; - - const lastPostAuthorAvatar = - lastPostAuthorProfile?.profile?.avatar?.ref?.$link && - lastPostAuthorProfile.pdsUrl - ? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}` - : undefined; - - return ( - - navigate({ - to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`, - }) - } - key={post["$metadata.uri"]} - className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative" - > - - - View topic: - -
- {post.title} -
-
- by{" "} - - {rootAuthorProfile?.handle - ? `@${rootAuthorProfile.handle}` - : rootAuthorProfile?.did.slice(4, 12)} - - , {getRelativeTimeString(post["$metadata.indexedAt"])} -
- - -
- {post.participants?.slice(0, 5).map((did) => { - const participant = profilesMap[did]; - const avatarUrl = - participant?.profile?.avatar?.ref?.$link && - participant?.pdsUrl - ? `${participant.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${participant.profile.avatar.ref.$link}` - : undefined; - return avatarUrl ? ( - {`@${participant?.handle - ) : ( -
- ); - })} -
- - - {(post.replyCount ?? 0) < 1 ? "-" : post.replyCount} - - - {post.topReaction ? ( -
- {post.topReaction.emoji} - - {post.topReaction.count} - -
- ) : ( - "-" - )} - - -
-
-
- {lastPostAuthorProfile?.profile?.displayName || - (lastPostAuthorProfile?.handle - ? `@${lastPostAuthorProfile.handle}` - : "...")} -
-
- {getRelativeTimeString(lastPostTimestamp)} -
-
- {lastPostAuthorAvatar ? ( - {lastPostAuthorProfile?.profile?.displayName} - ) : ( -
- )} -
- - - ); - }) + {uris.length > 0 ? ( + uris.map((uri) => ( + + )) ) : ( @@ -774,3 +492,246 @@ export function Forum() {
); } + +function TopicRow({ + forumHandle, + profile, + uri, +}: { + forumHandle: string; + profile: Profile; + uri: string; +}) { + const navigate = useNavigate(); + const topic = useEsavDocument(uri); + const parsed = parseAtUri(uri); + + const fullRepliesQuery = { + query: { + bool: { must: [{ term: { root: uri } }] }, + }, + sort: [{ "$metadata.indexedAt": { order: "asc" } }], + }; + + const { uris: repliesUris = [], isLoading: isQueryLoading } = useEsavQuery( + `forumtest/${profile.did}/${uri}/replies`, + fullRepliesQuery!, + { + enabled: !!fullRepliesQuery, + } + ); + + const topReactions = { + query: { + bool: { + must: [ + { + term: { + "$metadata.collection": "party.whey.ft.topic.reaction", + }, + }, + { + terms: { + reactionSubject: [uri] + } + }, + ], + }, + }, + sort: [{ "$metadata.indexedAt": { order: "asc" } }], + }; + + const { uris: reactionUris = [], isLoading: isReactionsLoading } = + useEsavQuery(`forumtest/${profile.did}/${uri}/OPreply/reactions`, topReactions!, { + enabled: !!topReactions, + }); + + const lastReplyUri = + repliesUris.length > 0 ? repliesUris[repliesUris.length - 1] : uri; + + const [op, isOpLoading] = useCachedProfileJotai(parsed?.did); + const [lastReplyAuthor, isLastReplyAuthorLoading] = useCachedProfileJotai( + lastReplyUri && parseAtUri(lastReplyUri)?.did + ); + + const lastReply = useEsavDocument(lastReplyUri); + + const participants = Array.from( + new Set( + [ + parsed?.did, + ...repliesUris.map((i) => parseAtUri(i)?.did), + ].filter((did): did is string => typeof did === "string") + ) + ); + + + if ( + !topic || + isQueryLoading || + isOpLoading || + isLastReplyAuthorLoading || + !op || + isReactionsLoading + ) { + return ; + } + + const rootAuthorProfile = op.profile; + + const lastPostAuthorDid = lastReply?.doc["$metadata.did"]; + const lastPostTimestamp = lastReply?.doc["$metadata.indexedAt"]; + const lastPostAuthorProfile = lastReplyAuthor; + + const lastPostAuthorAvatar = + lastPostAuthorProfile?.profile?.avatar?.ref?.$link && + lastPostAuthorProfile.pdsUrl + ? `${lastPostAuthorProfile.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${lastPostAuthorDid}&cid=${lastPostAuthorProfile.profile.avatar.ref.$link}` + : undefined; + + const post = topic.doc as PostDoc; + + return ( + + navigate({ + to: `/f/${forumHandle}/t/${post["$metadata.did"]}/${post["$metadata.rkey"]}`, + }) + } + key={post["$metadata.uri"]} + className="bg-gray-800 hover:bg-gray-700/50 rounded-lg cursor-pointer transition-colors duration-150 group relative" + > + + + View topic: + +
+ {post.title} +
+
+ by{" "} + + {op.handle ? `@${op.handle}` : op?.did.slice(4, 12)} + + , {getRelativeTimeString(post["$metadata.indexedAt"])} +
+ + +
+ {participants + .filter(Boolean) + .slice(0, 5) + .map((did) => ( + + ))} +
+ + + {(repliesUris.length ?? 0) < 1 ? "-" : repliesUris.length} + + + {reactionUris ? : "-"} + + +
+
+
+ {lastPostAuthorProfile?.profile?.displayName || + (lastPostAuthorProfile?.handle + ? `@${lastPostAuthorProfile.handle}` + : "...")} +
+
+ {lastPostTimestamp && getRelativeTimeString(lastPostTimestamp)} +
+
+ {lastPostAuthorAvatar ? ( + {lastPostAuthorProfile?.profile?.displayName} + ) : ( +
+ )} +
+ + + ); +} + +function TopReactionc({ uris }: { uris: string[] }) { + const resolvedReactions = useResolvedDocuments(uris); + + const didEmojiSet = new Map>(); + const emojiCounts = new Map(); + + Object.values(resolvedReactions).forEach((doc) => { + if (!doc) return; + + const did = doc["$metadata.did"]; + const emoji = doc.$raw?.reactionEmoji as string; + if (!emoji) return; + + if (!didEmojiSet.has(did)) { + didEmojiSet.set(did, new Set()); + } + + const emojiSet = didEmojiSet.get(did)!; + if (!emojiSet.has(emoji)) { + emojiSet.add(emoji); + emojiCounts.set(emoji, (emojiCounts.get(emoji) || 0) + 1); + } + }); + + // Step 2: Find top emoji + let topEmoji: string | null = null; + let topCount = 0; + for (const [emoji, count] of emojiCounts) { + if (count > topCount) { + topEmoji = emoji; + topCount = count; + } + } + + if (!topEmoji) return null; // No valid reactions + + return ( +
+ {topEmoji} + {topCount} +
+ ); +} + +function Participant({ did }: { did: string }) { + const [user, isloading] = useCachedProfileJotai(did); + if (isloading || !user) { + return ( +
+ ); + } + const avatarUrl = + user.profile?.avatar?.ref?.$link && user.pdsUrl + ? `${user.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${user.profile.avatar.ref.$link}` + : undefined; + return ( + {`@${user?.handle + ); +} diff --git a/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx b/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx index bdc3651..8a8bf73 100644 --- a/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx +++ b/src/routes/f/$forumHandle/t/$userHandle/$topicRKey.tsx @@ -16,6 +16,13 @@ import { } from "@radix-ui/react-icons"; import * as Popover from "@radix-ui/react-popover"; import { useQuery, useQueryClient, QueryClient } from "@tanstack/react-query"; +import { + parseAtUri, + useCachedProfileJotai, + useEsavDocument, + useEsavQuery, + type Profile, +} from "@/esav/hooks"; type PostDoc = { "$metadata.uri": string; @@ -58,172 +65,159 @@ type TopicData = { const EMOJI_SELECTION = ["👍", "❤️", "😂", "🔥", "🤔", "🎉", "🙏", "🤯"]; -const topicQueryOptions = ( - queryClient: QueryClient, - userHandle: string, - topicRKey: string -) => ({ - queryKey: ["topic", userHandle, topicRKey], - queryFn: async (): Promise => { - const authorIdentity = await queryClient.fetchQuery({ - queryKey: ["identity", userHandle], - queryFn: () => resolveIdentity({ didOrHandle: userHandle }), - staleTime: 1000 * 60 * 60 * 24, - }); - if (!authorIdentity) throw new Error("Could not find topic author."); - - const topicUri = `at://${authorIdentity.did}/com.example.ft.topic.post/${topicRKey}`; - - const [postRes, repliesRes] = await Promise.all([ - esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ - query: { term: { "$metadata.uri": topicUri } }, - size: 1, - }), - esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ - query: { term: { root: topicUri } }, - sort: [{ "$metadata.indexedAt": "asc" }], - size: 100, - }), - ]); - - if (postRes.hits.hits.length === 0) throw new Error("Topic not found."); - const mainPost = postRes.hits.hits[0]._source; - const fetchedReplies = repliesRes.hits.hits.map((h) => h._source); - const allPosts = [mainPost, ...fetchedReplies]; - - const postUris = allPosts.map((p) => p["$metadata.uri"]); - const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))]; - - const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ - esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ - query: { - bool: { - must: [ - { - term: { - "$metadata.collection": "com.example.ft.topic.reaction", - }, - }, - { terms: { reactionSubject: postUris } }, - ], - }, - }, - _source: ["reactionSubject", "reactionEmoji"], - size: 1000, - }), - esavQuery<{ - hits: { - hits: { _source: { "$metadata.did": string; footer: string } }[]; - }; - }>({ - query: { - bool: { - must: [ - { term: { $type: "com.example.ft.user.profile" } }, - { terms: { "$metadata.did": authorDids } }, - ], - }, - }, - _source: ["$metadata.did", "footer"], - size: authorDids.length, - }), - Promise.all( - authorDids.map(async (did) => { - try { - const identity = await queryClient.fetchQuery({ - queryKey: ["identity", did], - queryFn: () => resolveIdentity({ didOrHandle: did }), - staleTime: 1000 * 60 * 60 * 24, - }); - - if (!identity?.pdsUrl) { - console.warn( - `Could not resolve PDS for ${did}, cannot fetch profile.` - ); - return { did, profile: null }; - } - - const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`; - const profileRes = await fetch(profileUrl); - - if (!profileRes.ok) { - console.warn( - `Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}` - ); - return { did, profile: null }; - } - - const profileData = await profileRes.json(); - return { did, profile: profileData.value }; - } catch (e) { - console.error( - `Error during decentralized profile fetch for ${did}:`, - e - ); - return { did, profile: null }; - } - }) - ), - ]); - - const reactionsByPostUri = reactionsRes.hits.hits.reduce( - (acc, hit) => { - const reaction = hit._source; - (acc[reaction.reactionSubject] = - acc[reaction.reactionSubject] || []).push(reaction); - return acc; - }, - {} as Record - ); - - const footersByDid = footersRes.hits.hits.reduce( - (acc, hit) => { - acc[hit._source["$metadata.did"]] = hit._source.footer; - return acc; - }, - {} as Record - ); +// const topicQueryOptions = ( +// queryClient: QueryClient, +// userHandle: string, +// topicRKey: string +// ) => ({ +// queryKey: ["topic", userHandle, topicRKey], +// queryFn: async (): Promise => { +// const authorIdentity = await queryClient.fetchQuery({ +// queryKey: ["identity", userHandle], +// queryFn: () => resolveIdentity({ didOrHandle: userHandle }), +// staleTime: 1000 * 60 * 60 * 24, +// }); +// if (!authorIdentity) throw new Error("Could not find topic author."); + +// const topicUri = `at://${authorIdentity.did}/party.whey.ft.topic.post/${topicRKey}`; + +// const [postRes, repliesRes] = await Promise.all([ +// esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ +// query: { term: { "$metadata.uri": topicUri } }, +// size: 1, +// }), +// esavQuery<{ hits: { hits: { _source: PostDoc }[] } }>({ +// query: { term: { root: topicUri } }, +// sort: [{ "$metadata.indexedAt": "asc" }], +// size: 100, +// }), +// ]); + +// if (postRes.hits.hits.length === 0) throw new Error("Topic not found."); +// const mainPost = postRes.hits.hits[0]._source; +// const fetchedReplies = repliesRes.hits.hits.map((h) => h._source); +// const allPosts = [mainPost, ...fetchedReplies]; + +// const postUris = allPosts.map((p) => p["$metadata.uri"]); +// const authorDids = [...new Set(allPosts.map((p) => p["$metadata.did"]))]; + +// const [reactionsRes, footersRes, pdsProfiles] = await Promise.all([ +// esavQuery<{ hits: { hits: { _source: ReactionDoc }[] } }>({ +// query: { +// bool: { +// must: [ +// { +// term: { +// "$metadata.collection": "party.whey.ft.topic.reaction", +// }, +// }, +// { terms: { reactionSubject: postUris } }, +// ], +// }, +// }, +// _source: ["reactionSubject", "reactionEmoji"], +// size: 1000, +// }), +// esavQuery<{ +// hits: { +// hits: { _source: { "$metadata.did": string; footer: string } }[]; +// }; +// }>({ +// query: { +// bool: { +// must: [ +// { term: { $type: "party.whey.ft.user.profile" } }, +// { terms: { "$metadata.did": authorDids } }, +// ], +// }, +// }, +// _source: ["$metadata.did", "footer"], +// size: authorDids.length, +// }), +// Promise.all( +// authorDids.map(async (did) => { +// try { +// const identity = await queryClient.fetchQuery({ +// queryKey: ["identity", did], +// queryFn: () => resolveIdentity({ didOrHandle: did }), +// staleTime: 1000 * 60 * 60 * 24, +// }); + +// if (!identity?.pdsUrl) { +// console.warn( +// `Could not resolve PDS for ${did}, cannot fetch profile.` +// ); +// return { did, profile: null }; +// } + +// const profileUrl = `${identity.pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`; +// const profileRes = await fetch(profileUrl); + +// if (!profileRes.ok) { +// console.warn( +// `Failed to fetch profile for ${did} from ${identity.pdsUrl}. Status: ${profileRes.status}` +// ); +// return { did, profile: null }; +// } + +// const profileData = await profileRes.json(); +// return { did, profile: profileData.value }; +// } catch (e) { +// console.error( +// `Error during decentralized profile fetch for ${did}:`, +// e +// ); +// return { did, profile: null }; +// } +// }) +// ), +// ]); + +// const reactionsByPostUri = reactionsRes.hits.hits.reduce( +// (acc, hit) => { +// const reaction = hit._source; +// (acc[reaction.reactionSubject] = +// acc[reaction.reactionSubject] || []).push(reaction); +// return acc; +// }, +// {} as Record +// ); + +// const footersByDid = footersRes.hits.hits.reduce( +// (acc, hit) => { +// acc[hit._source["$metadata.did"]] = hit._source.footer; +// return acc; +// }, +// {} as Record +// ); + +// const authors: Record = {}; +// await Promise.all( +// authorDids.map(async (did) => { +// const identity = await queryClient.fetchQuery({ +// queryKey: ["identity", did], +// queryFn: () => resolveIdentity({ didOrHandle: did }), +// staleTime: 1000 * 60 * 60 * 24, +// }); +// if (!identity) return; +// const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; +// authors[did] = { +// ...identity, +// displayName: pdsProfile?.displayName, +// avatarCid: pdsProfile?.avatar?.ref?.["$link"], +// footer: footersByDid[did], +// }; +// }) +// ); + +// return { posts: allPosts, authors, reactions: reactionsByPostUri }; +// }, +// }); - const authors: Record = {}; - await Promise.all( - authorDids.map(async (did) => { - const identity = await queryClient.fetchQuery({ - queryKey: ["identity", did], - queryFn: () => resolveIdentity({ didOrHandle: did }), - staleTime: 1000 * 60 * 60 * 24, - }); - if (!identity) return; - const pdsProfile = pdsProfiles.find((p) => p.did === did)?.profile; - authors[did] = { - ...identity, - displayName: pdsProfile?.displayName, - avatarCid: pdsProfile?.avatar?.ref?.["$link"], - footer: footersByDid[did], - }; - }) - ); - - return { posts: allPosts, authors, reactions: reactionsByPostUri }; - }, -}); export const Route = createFileRoute( "/f/$forumHandle/t/$userHandle/$topicRKey" )({ - loader: ({ context: { queryClient }, params }) => - queryClient.ensureQueryData( - topicQueryOptions( - queryClient, - decodeURIComponent(params.userHandle), - params.topicRKey - ) - ), component: ForumTopic, - pendingComponent: TopicPageSkeleton, - errorComponent: ({ error }) => ( -
- Error: {(error as Error).message} -
- ), }); export function PostCardSkeleton() { @@ -276,13 +270,13 @@ function TopicPageSkeleton() { ); } -function UserInfoColumn({ author }: { author: AuthorInfo | null }) { +function UserInfoColumn({ author }: { author: Profile | null }) { const avatarUrl = - author?.avatarCid && author?.pdsUrl - ? `${author.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${author.did}&cid=${author.avatarCid}` + author?.profile.avatar?.ref.$link && author?.pdsUrl + ? `${author.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${author.did}&cid=${author?.profile.avatar?.ref.$link}` : undefined; - const authorDisplayName = author?.displayName || author?.handle || "Unknown"; + const authorDisplayName = author?.profile.displayName || author?.handle || "Unknown"; const authorHandle = author?.handle ? `@${author.handle}` : "did:..."; return ( @@ -302,11 +296,11 @@ function UserInfoColumn({ author }: { author: AuthorInfo | null }) { {authorDisplayName}
{authorHandle}
- {author?.footer && ( + {/* {author?.footer && (
{author.footer}
- )} + )} */}
); } @@ -340,19 +334,21 @@ function Reactions({ reactions }: { reactions: ReactionDoc[] }) { } export function PostCard({ + forumdid, agent, post, - author, - reactions, + //author, + //reactions, index, onSetReplyParent, onNewReaction, isCreatingReaction, }: { + forumdid: string; agent: AtpAgent | null; post: PostDoc; - author: AuthorInfo | null; - reactions: ReactionDoc[]; + //author: AuthorInfo | null; + //reactions: ReactionDoc[]; index: number; onSetReplyParent: (post: PostDoc) => void; onNewReaction: (post: PostDoc, emoji: string) => Promise; @@ -360,6 +356,54 @@ export function PostCard({ }) { const postUri = post["$metadata.uri"]; const postDate = new Date(post["$metadata.indexedAt"]); + const [author, authorloading] = useCachedProfileJotai(post["$metadata.did"]); + + const reactionsquery = { + query: { + bool: { + must: [ + { + term: { + "$metadata.collection": "party.whey.ft.topic.reaction", + }, + }, + { + terms: { + reactionSubject: [post["$metadata.uri"]] + } + }, + ], + }, + }, + sort: [{ "$metadata.indexedAt": { order: "asc" } }], + }; + + const { uris: reactionUris = [], isLoading: isReactionsLoading } = + useEsavQuery(`forumtest/${forumdid}/${post["$metadata.uri"]}/reactions`, reactionsquery!, { + enabled: !!reactionsquery, + }); + + function isReactionDoc(doc: unknown): doc is ReactionDoc { + return ( + typeof doc === 'object' && + doc !== null && + 'reactionEmoji' in doc && + 'reactionSubject' in doc + ); + } + + const docsMap = useEsavDocument(reactionUris); + const reactions = reactionUris + .map((uri) => docsMap?.[uri]?.doc as unknown) + .filter(isReactionDoc); + + if (!author || authorloading) { + return ( + + loading + + ) + } return (
{ + return `at://${op?.did}/party.whey.ft.topic.post/${topicRKey}`; + }, [op?.did]); const { agent, loading: authLoading } = useAuth(); - const queryClient = useQueryClient(); - const initialData = Route.useLoaderData(); + //const topic = useEsavDocument(uri); + //const parsed = parseAtUri(uri); - const { data, isError, error } = useQuery({ - ...topicQueryOptions(queryClient, userHandle, topicRKey), - initialData, - refetchInterval: 30 * 1000, // refresh every half minute - }); + const opQuery = { + query: { + term: { + "$metadata.uri": uri, + }, + }, + size: 1, + sort: [{ "$metadata.indexedAt": { order: "asc" } }], + }; + + const fullRepliesQuery = { + query: { + bool: { must: [{ term: { root: uri } }] }, + }, + sort: [{ "$metadata.indexedAt": { order: "asc" } }], + }; - const { posts, authors, reactions } = data; + const { uris: opUris = [], isLoading: isopQueryLoading } = useEsavQuery( + `forumtest/${op?.did}/${uri}`, + opQuery!, + { + enabled: !!opQuery && !!op, + } + ); + + const { uris: repliesUris = [], isLoading: isQueryLoading } = useEsavQuery( + `forumtest/${op?.did}/${uri}/replies`, + fullRepliesQuery!, + { + enabled: !!fullRepliesQuery && !!op, + } + ); + + const oppost = useEsavDocument(uri); + const docsMap = useEsavDocument(repliesUris); + const posts = useMemo(() => { return [ + oppost?.doc as PostDoc, + ...repliesUris.map((uri) => docsMap?.[uri]?.doc as PostDoc), + ].filter((doc): doc is PostDoc => !!doc); + }, [oppost, docsMap]); const [replyText, setReplyText] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -464,12 +547,6 @@ export function ForumTopic() { document.getElementById("reply-box")?.focus(); }; - const invalidateTopicQuery = () => { - queryClient.invalidateQueries({ - queryKey: ["topic", userHandle, topicRKey], - }); - }; - const handleCreateReaction = async (post: PostDoc, emoji: string) => { if (!agent?.did || isCreatingReaction) return; setIsCreatingReaction(true); @@ -477,15 +554,15 @@ export function ForumTopic() { try { await agent.com.atproto.repo.createRecord({ repo: agent.did, - collection: "com.example.ft.topic.reaction", + collection: "party.whey.ft.topic.reaction", record: { - $type: "com.example.ft.topic.reaction", + $type: "party.whey.ft.topic.reaction", reactionEmoji: emoji, subject: post["$metadata.uri"], createdAt: new Date().toISOString(), }, }); - invalidateTopicQuery(); + //invalidateTopicQuery(); } catch (e) { console.error("Failed to create reaction", e); setMutationError("Failed to post reaction. Please try again."); @@ -502,20 +579,19 @@ export function ForumTopic() { try { const rootPost = posts[0]; const parentPost = replyingTo || rootPost; - const identity = await queryClient.fetchQuery({ - queryKey: ["identity", forumHandle], - queryFn: () => resolveIdentity({ didOrHandle: forumHandle }), - staleTime: 1000 * 60 * 60 * 24, - }); + const trimmed = forumHandle.startsWith("@") + ? forumHandle.slice(1) + : forumHandle; + const identity = forum; const forumDid = identity?.did; if (!forumDid) { throw new Error("Could not resolve forum handle to DID."); } await agent.com.atproto.repo.createRecord({ repo: agent.did, - collection: "com.example.ft.topic.post", + collection: "party.whey.ft.topic.post", record: { - $type: "com.example.ft.topic.post", + $type: "party.whey.ft.topic.post", text: replyText, forum: forumDid, reply: { @@ -533,20 +609,25 @@ export function ForumTopic() { }); setReplyText(""); setReplyingTo(null); - invalidateTopicQuery(); + //invalidateTopicQuery(); } catch (e) { setMutationError(`Failed to post reply: ${(e as Error).message}`); } finally { setIsSubmitting(false); } }; - - if (isError) + if (!forum?.did || isOpdidLoading || isQueryLoading || isforumdidLoading || isopQueryLoading) { return ( -
- Error: {(error as Error).message} -
- ); + + ) + } + + // if (isError) + // return ( + //
+ // Error: {(error as Error).message} + //
+ // ); const topicPost = posts[0]; const postIndexBeingRepliedTo = replyingTo @@ -574,11 +655,12 @@ export function ForumTopic() { {posts.map((post, index) => ( ({ must: [ { term: { - "$metadata.collection": "com.example.ft.forum.definition", + "$metadata.collection": "party.whey.ft.forum.definition", }, }, { term: { "$metadata.rkey": "self" } }, @@ -87,13 +89,7 @@ const forumsQueryOptions = (queryClient: QueryClient) => ({ }); export const Route = createFileRoute("/")({ - loader: ({ context: { queryClient } }) => - queryClient.ensureQueryData(forumsQueryOptions(queryClient)), component: Home, - pendingComponent: ForumGridSkeleton, - errorComponent: ({ error }) => ( -
Error: {(error as Error).message}
- ), }); function ForumGridSkeleton() { @@ -138,13 +134,27 @@ function ForumCardSkeleton() { } function Home() { - const initialData = Route.useLoaderData(); - const queryClient = useQueryClient(); + const homeQuery = { + query: { + bool: { + must: [ + { + term: { + "$metadata.collection": "party.whey.ft.forum.definition", + }, + }, + { term: { "$metadata.rkey": "self" } }, + ], + }, + }, + sort: [{ '$metadata.indexedAt': 'desc' }], + size: 50, + }; + const { uris, isLoading } = useEsavQuery("forumtest", homeQuery); - const { data: forums }: { data: ResolvedForum[] } = useQuery({ - ...forumsQueryOptions(queryClient), - initialData, - }); + if (isLoading) { + return + } return (
@@ -155,78 +165,86 @@ function Home() {
- {forums.map((forum) => { - const did = forum?.["$metadata.did"]; - const { resolvedIdentity } = forum; - if (!resolvedIdentity) return null; - - const cidBanner = forum?.$raw?.banner?.ref?.$link; - const cidAvatar = forum?.$raw?.avatar?.ref?.$link; - - const bannerUrl = - cidBanner && resolvedIdentity - ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}` - : null; - - const avatarUrl = - cidAvatar && resolvedIdentity - ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}` - : null; - - return ( - -
- {bannerUrl && ( -
- )} -
-
-
-
- {resolvedIdentity?.handle && ( -
- /f/@{resolvedIdentity.handle} -
- )} -
- {forum.displayName || "Unnamed Forum"} -
-
- {avatarUrl && ( - Avatar - )} -
-
-
- {forum.description || "No description available."} -
-
- 0 members · ~0 topics · Active a while ago -
-
-
-
- - ); - })} + {uris.map((uri) => ( + + ))}
); } + +function ForumItem({uri}:{uri:string}){ + const data = useEsavDocument(uri); + const did = data?.doc?.["$metadata.did"]; + const [profile, isLoading] = useCachedProfileJotai(did); + if (!data) return null + const forum = data.doc; + const resolvedIdentity = profile; + + const cidBanner = resolvedIdentity?.profile.banner?.ref?.$link; + const cidAvatar = resolvedIdentity?.profile.avatar?.ref?.$link; + + const bannerUrl = + cidBanner && resolvedIdentity + ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidBanner}` + : null; + + const avatarUrl = + cidAvatar && resolvedIdentity + ? `${resolvedIdentity.pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cidAvatar}` + : null; + + + return ( + +
+ {bannerUrl && ( +
+ )} +
+
+
+
+ {resolvedIdentity?.handle && ( +
+ /f/@{resolvedIdentity.handle} +
+ )} +
+ {resolvedIdentity?.profile.displayName || "Unnamed Forum"} +
+
+ {avatarUrl && ( + Avatar + )} +
+
+
+ {String(forum.description || "No description available.")} +
+
+ 0 members · ~0 topics · Active a while ago +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/src/routes/search.tsx b/src/routes/search.tsx index ad365c0..3ca9a98 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -18,6 +18,7 @@ import { PostCard, PostCardSkeleton, } from "@/routes/f/$forumHandle/t/$userHandle/$topicRKey"; +import { useCachedProfileJotai } from "@/esav/hooks"; type PostDoc = { "$metadata.uri": string; @@ -71,6 +72,7 @@ interface SearchResultCardProps { function SearchResultCard({ post, ...rest }: SearchResultCardProps) { const navigate = useNavigate(); const [forumHandle, setForumHandle] = useState(undefined); + const [did, loadinger] = useCachedProfileJotai(forumHandle) const { get, set } = usePersistentStore(); const thing = post["forum"]// || new AtUripost["root"] @@ -162,7 +164,7 @@ function SearchResultCard({ post, ...rest }: SearchResultCardProps) { )}
- + {did && ()}
); } @@ -208,7 +210,7 @@ export function SearchPage() { }, filter: [ { - term: { "$metadata.collection": "com.example.ft.topic.post" }, + term: { "$metadata.collection": "party.whey.ft.topic.post" }, }, ], }, @@ -238,7 +240,7 @@ export function SearchPage() { must: [ { term: { - "$metadata.collection": "com.example.ft.topic.reaction", + "$metadata.collection": "party.whey.ft.topic.reaction", }, }, ], @@ -263,7 +265,7 @@ export function SearchPage() { }>({ query: { bool: { - must: [{ term: { $type: "com.example.ft.user.profile" } }], + must: [{ term: { $type: "party.whey.ft.user.profile" } }], filter: [{ terms: { "$metadata.did": allDids } }], }, }, @@ -349,9 +351,9 @@ export function SearchPage() { const date = new Date().toISOString(); const response = await agent.com.atproto.repo.createRecord({ repo: agent.did, - collection: "com.example.ft.topic.reaction", + collection: "party.whey.ft.topic.reaction", record: { - $type: "com.example.ft.topic.reaction", + $type: "party.whey.ft.topic.reaction", reactionEmoji: emoji, subject: postUri, createdAt: date, @@ -359,7 +361,7 @@ export function SearchPage() { }); const uri = new AtUri(response.data.uri) const newReaction: ReactionDoc = { - "$metadata.collection": "com.example.ft.topic.reaction", + "$metadata.collection": "party.whey.ft.topic.reaction", "$metadata.uri": response.data.uri, "$metadata.cid": response.data.cid, "$metadata.did": agent.did, -- 2.51.2