diff --git a/src/components/semble.tsx b/src/components/semble.tsx index 8fa2ee6..bc48a85 100644 --- a/src/components/semble.tsx +++ b/src/components/semble.tsx @@ -2,8 +2,7 @@ import { getProfile, - getSembleCollections, - getSembleCards, + getSembleData, type SembleCard, type SembleCollection, } from "../data.ts"; @@ -38,9 +37,8 @@ function sembleCardUrl(card: SembleCard): string { } export async function Semble() { - const [collections, cards, profile] = await Promise.all([ - getSembleCollections(), - getSembleCards(30), + const [{ collections, cards }, profile] = await Promise.all([ + getSembleData(30), getProfile(), ]); diff --git a/src/data.ts b/src/data.ts index 27e069f..cafc9da 100644 --- a/src/data.ts +++ b/src/data.ts @@ -464,7 +464,6 @@ export interface FeedPost { // --- Semble (network.cosmik) --- -const SEMBLE_API = "https://api.semble.so"; const SEMBLE_HANDLE = process.env.SEMBLE_HANDLE || "cameron.stream"; export interface SembleCardContent { @@ -512,30 +511,167 @@ export interface SembleCollection { }; } -export async function getSembleCollections(): Promise { - try { - const res = await fetch( - `${SEMBLE_API}/api/collections/user/${encodeURIComponent(SEMBLE_HANDLE)}` - ); - if (!res.ok) return []; - const data = await res.json(); - return (data.collections ?? []) as SembleCollection[]; - } catch { - return []; +export interface SembleData { + cards: SembleCard[]; + collections: SembleCollection[]; +} + +interface SembleRecord { + uri: string; + value: Record; +} + +function objectValue(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; } + return value as Record; } -export async function getSembleCards(limit = 20): Promise { - try { - const res = await fetch( - `${SEMBLE_API}/api/cards/user/${encodeURIComponent(SEMBLE_HANDLE)}?limit=${limit}&sortOrder=desc` +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function recordId(uri: string): string { + return uri.split("/").pop() ?? uri; +} + +function refUri(value: unknown): string | undefined { + return stringValue(objectValue(value)?.uri); +} + +export async function getSembleData(limit = 20): Promise { + const did = getDid(); + const [cardRecords, collectionRecords, collectionLinkRecords] = + (await Promise.all([ + listRecords(did, "network.cosmik.card", 100), + listRecords(did, "network.cosmik.collection", 100), + listRecords(did, "network.cosmik.collectionLink", 100), + ])) as [SembleRecord[], SembleRecord[], SembleRecord[]]; + + const linksByCard = new Map(); + const linkCountByCollection = new Map(); + + for (const linkRecord of collectionLinkRecords) { + const cardUri = refUri(linkRecord.value.card); + const collectionUri = refUri(linkRecord.value.collection); + if (!cardUri || !collectionUri) continue; + + const collectionUris = linksByCard.get(cardUri) ?? []; + collectionUris.push(collectionUri); + linksByCard.set(cardUri, collectionUris); + linkCountByCollection.set( + collectionUri, + (linkCountByCollection.get(collectionUri) ?? 0) + 1 ); - if (!res.ok) return []; - const data = await res.json(); - return (data.cards ?? []) as SembleCard[]; - } catch { - return []; } + + const collections = collectionRecords + .map((record): SembleCollection | null => { + const name = stringValue(record.value.name); + const createdAt = stringValue(record.value.createdAt); + if (!name || !createdAt) return null; + + return { + id: recordId(record.uri), + uri: record.uri, + name, + description: stringValue(record.value.description), + accessType: stringValue(record.value.accessType), + cardCount: linkCountByCollection.get(record.uri) ?? 0, + createdAt, + updatedAt: stringValue(record.value.updatedAt) ?? createdAt, + author: { + id: did, + handle: SEMBLE_HANDLE, + }, + }; + }) + .filter((collection): collection is SembleCollection => collection !== null) + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + const collectionsByUri = new Map( + collections + .filter((collection) => collection.uri) + .map((collection) => [collection.uri!, collection]) + ); + + const notesByParentUri = new Map(); + for (const record of cardRecords) { + if (record.value.type !== "NOTE") continue; + const parentUri = refUri(record.value.parentCard); + const text = stringValue(objectValue(record.value.content)?.text); + if (!parentUri || !text) continue; + notesByParentUri.set(parentUri, { + id: recordId(record.uri), + text, + }); + } + + const cards = cardRecords + .map((record): SembleCard | null => { + if (record.value.type !== "URL") return null; + + const content = objectValue(record.value.content); + const metadata = objectValue(content?.metadata); + const url = stringValue(content?.url) ?? stringValue(record.value.url); + const createdAt = stringValue(record.value.createdAt); + if (!url || !createdAt) return null; + + const cardCollections = (linksByCard.get(record.uri) ?? []) + .map((collectionUri) => collectionsByUri.get(collectionUri)) + .filter( + (collection): collection is SembleCollection => + collection !== undefined + ) + .map((collection) => ({ + id: collection.id, + name: collection.name, + })); + + return { + id: recordId(record.uri), + type: "URL", + url, + uri: record.uri, + cardContent: { + url, + title: stringValue(metadata?.title), + description: stringValue(metadata?.description), + imageUrl: stringValue(metadata?.imageUrl), + siteName: stringValue(metadata?.siteName), + author: stringValue(metadata?.author), + type: stringValue(metadata?.type), + }, + note: notesByParentUri.get(record.uri), + createdAt, + updatedAt: stringValue(record.value.updatedAt) ?? createdAt, + collections: cardCollections, + author: { + id: did, + handle: SEMBLE_HANDLE, + }, + }; + }) + .filter((card): card is SembleCard => card !== null) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + .slice(0, limit); + + return { cards, collections }; +} + +export async function getSembleCollections(): Promise { + return (await getSembleData()).collections; +} + +export async function getSembleCards(limit = 20): Promise { + return (await getSembleData(limit)).cards; } export async function getAuthorFeed(limit = 20): Promise {