diff options
| author | polwex <polwex@sortug.com> | 2025-11-19 05:47:30 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-11-19 05:47:30 +0700 |
| commit | 74d84cb2f22600b6246343e9ea606cf0db7517f0 (patch) | |
| tree | 0d68285c8e74e6543645e17ab2751d543c1ff9a6 /gui/src/logic | |
| parent | e6e657be3a3b1dae426b46f3bc16f9a5cf4861c2 (diff) | |
Big GUI improvements on Nostr rendering and fetchingpolwex/iris
Diffstat (limited to 'gui/src/logic')
| -rw-r--r-- | gui/src/logic/cache.ts | 222 | ||||
| -rw-r--r-- | gui/src/logic/constants.ts | 12 | ||||
| -rw-r--r-- | gui/src/logic/hooks.ts | 173 | ||||
| -rw-r--r-- | gui/src/logic/nostr.ts | 30 | ||||
| -rw-r--r-- | gui/src/logic/nostrill.ts | 155 | ||||
| -rw-r--r-- | gui/src/logic/requests/nostrill.ts | 65 | ||||
| -rw-r--r-- | gui/src/logic/trill/helpers.ts | 186 |
7 files changed, 795 insertions, 48 deletions
diff --git a/gui/src/logic/cache.ts b/gui/src/logic/cache.ts new file mode 100644 index 0000000..e5ec956 --- /dev/null +++ b/gui/src/logic/cache.ts @@ -0,0 +1,222 @@ +// indexedDBCache.ts +export interface CacheConfig { + dbName: string; + storeName: string; + version?: number; +} + +export interface CachedData<T> { + key: string; + data: T; + timestamp: number; + expiresAt?: number; +} + +class IndexedDBCache { + private dbName: string; + private storeName: string; + private version: number; + private dbPromise: Promise<IDBDatabase> | null = null; + + constructor(config: CacheConfig) { + this.dbName = config.dbName; + this.storeName = config.storeName; + this.version = config.version || 1; + } + + /** + * Initialize the IndexedDB database + */ + private async initDB(): Promise<IDBDatabase> { + if (this.dbPromise) { + return this.dbPromise; + } + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => { + reject(new Error(`Failed to open database: ${request.error}`)); + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create object store if it doesn't exist + if (!db.objectStoreNames.contains(this.storeName)) { + const objectStore = db.createObjectStore(this.storeName, { + keyPath: "key", + }); + objectStore.createIndex("timestamp", "timestamp", { unique: false }); + objectStore.createIndex("expiresAt", "expiresAt", { unique: false }); + } + }; + }); + + return this.dbPromise; + } + + /** + * Store data in IndexedDB + */ + async set<T>(key: string, data: T, ttlMs?: number): Promise<void> { + const db = await this.initDB(); + const timestamp = Date.now(); + const expiresAt = ttlMs ? timestamp + ttlMs : undefined; + + const cachedData: CachedData<T> = { + key, + data, + timestamp, + expiresAt, + }; + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.put(cachedData); + + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new Error(`Failed to store data: ${request.error}`)); + }); + } + + /** + * Retrieve data from IndexedDB + */ + async get<T>(key: string): Promise<T | null> { + const db = await this.initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + const request = store.get(key); + + request.onsuccess = () => { + const result = request.result as CachedData<T> | undefined; + + if (!result) { + resolve(null); + return; + } + + // Check if data has expired + if (result.expiresAt && Date.now() > result.expiresAt) { + // Delete expired data + this.delete(key); + resolve(null); + return; + } + + resolve(result.data); + }; + + request.onerror = () => + reject(new Error(`Failed to retrieve data: ${request.error}`)); + }); + } + + /** + * Delete data from IndexedDB + */ + async delete(key: string): Promise<void> { + const db = await this.initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.delete(key); + + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new Error(`Failed to delete data: ${request.error}`)); + }); + } + + /** + * Check if a key exists and is not expired + */ + async has(key: string): Promise<boolean> { + const data = await this.get(key); + return data !== null; + } + + /** + * Clear all data from the store + */ + async clear(): Promise<void> { + const db = await this.initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.clear(); + + request.onsuccess = () => resolve(); + request.onerror = () => + reject(new Error(`Failed to clear store: ${request.error}`)); + }); + } + + /** + * Get all keys in the store + */ + async keys(): Promise<string[]> { + const db = await this.initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + const request = store.getAllKeys(); + + request.onsuccess = () => resolve(request.result as string[]); + request.onerror = () => + reject(new Error(`Failed to get keys: ${request.error}`)); + }); + } + + /** + * Remove expired entries + */ + async cleanExpired(): Promise<number> { + const db = await this.initDB(); + let deletedCount = 0; + + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + const request = store.openCursor(); + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest) + .result as IDBCursorWithValue | null; + + if (cursor) { + const value = cursor.value as CachedData<unknown>; + + if (value.expiresAt && Date.now() > value.expiresAt) { + cursor.delete(); + deletedCount++; + } + + cursor.continue(); + } else { + resolve(deletedCount); + } + }; + + request.onerror = () => + reject(new Error(`Failed to clean expired: ${request.error}`)); + }); + } +} + +// Export a singleton factory +export const createCache = (config: CacheConfig) => new IndexedDBCache(config); + +export default IndexedDBCache; diff --git a/gui/src/logic/constants.ts b/gui/src/logic/constants.ts index fcf5573..a1569fd 100644 --- a/gui/src/logic/constants.ts +++ b/gui/src/logic/constants.ts @@ -25,9 +25,19 @@ export const REF_REGEX = new RegExp( ); export const RADIO_REGEX = new RegExp(/urbit:\/\/radio\/~[a-z-_]+/gim); +// export const URL_REGEX = new RegExp( +// /^(https?:\/\/)?((localhost)|([\w-]+(\.[\w-]+)+)|(\d{1,3}(\.\d{1,3}){3}))(:\d{2,5})?(\/[^\s?#]*)?(\?[^#\s]*)?(#[^\s]*)?$/gim, +// ); +export const URL_REGEX = new RegExp( + /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi, +); export const IMAGE_REGEX = new RegExp( - /https:\/\/.+\.(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)\b/gim, + /https:\/\/.+\.(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)\b/gi, +); +export const IMAGE_SUBREGEX = new RegExp( + /.*(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/, ); +export const VIDEO_SUBREGEX = new RegExp(/.*(mov|mp4|ogv|mkv|m3uv)$/); export const SHIP_REGEX = new RegExp(/\B~[a-z-]+/); export const HASHTAGS_REGEX = new RegExp(/#[a-z-]+/g); diff --git a/gui/src/logic/hooks.ts b/gui/src/logic/hooks.ts new file mode 100644 index 0000000..c03698c --- /dev/null +++ b/gui/src/logic/hooks.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef, useState } from "react"; + +export default function useTimeout(callback: () => void, delay: number) { + const timeoutRef = useRef<number | null>(null); + const savedCallback = useRef(callback); + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + useEffect(() => { + const tick = () => savedCallback.current(); + if (typeof delay === "number") { + timeoutRef.current = setTimeout(tick, delay); + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + } + }, [delay]); + return timeoutRef; +} + +export function usePersistentState<T>(key: string, initial: T) { + const [value, setValue] = useState<T>(() => { + if (typeof window === "undefined") return initial; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return initial; + return JSON.parse(raw) as T; + } catch { + return initial; + } + }); + + useEffect(() => { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore quota errors in dev, etc. + } + }, [key, value]); + + return [value, setValue] as const; +} +// wsCache.js +// const CACHE_KEY = "ws_dev_cache"; + +// export const getCachedData = (key: string) => { +// if (typeof window === "undefined") return null; + +// const cached = localStorage.getItem(CACHE_KEY + key); +// if (!cached) return null; + +// const { data, timestamp } = JSON.parse(cached); +// if (Date.now() - timestamp > 30 * 60 * 1000) { +// localStorage.removeItem(CACHE_KEY); +// return null; +// } + +// return data; +// }; + +// export const setCachedData = (key: string, data: any) => { +// if (typeof window === "undefined") return; +// localStorage.setItem( +// CACHE_KEY + key, +// JSON.stringify({ +// data, +// timestamp: Date.now(), +// }), +// ); +// }; + +// // Add this to your component for easy clearing +// export const clearWebSocketCache = () => { +// localStorage.removeItem(CACHE_KEY); +// window.location.reload(); +// }; + +// wsCache.js +interface CacheEntry<T> { + data: T; + timestamp: number; +} + +const DB_NAME = "WebSocketCacheDB"; +const STORE_NAME = "cache"; +const DB_VERSION = 1; +const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes + +const openDB = (): Promise<IDBDatabase> => { + return new Promise((resolve, reject) => { + const request: IDBOpenDBRequest = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event: IDBVersionChangeEvent) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + }); +}; + +export const getCachedData = async <T = unknown,>( + key: string = "default", +): Promise<T | null> => { + try { + const db = await openDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, "readonly"); + const store = transaction.objectStore(STORE_NAME); + const request: IDBRequest<CacheEntry<T>> = store.get(key); + + request.onsuccess = () => { + const result = request.result; + if (result && Date.now() - result.timestamp < CACHE_DURATION) { + resolve(result.data); + } else { + resolve(null); + } + }; + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.warn("[Cache] IndexedDB read failed:", error); + return null; + } +}; + +export const setCachedData = async <T,>( + data: T, + key: string = "default", +): Promise<void> => { + try { + const db = await openDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const entry: CacheEntry<T> = { + data, + timestamp: Date.now(), + }; + + const request = store.put(entry, key); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.warn("[Cache] IndexedDB write failed:", error); + if (error instanceof DOMException && error.name === "QuotaExceededError") { + await clearCache(); + } + } +}; + +export const clearCache = async (key?: string): Promise<void> => { + try { + const db = await openDB(); + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + if (key) { + await store.delete(key); + } else { + await store.clear(); + } + } catch (error) { + console.warn("[Cache] Clear failed:", error); + } +}; diff --git a/gui/src/logic/nostr.ts b/gui/src/logic/nostr.ts index 3a9a586..3112f4b 100644 --- a/gui/src/logic/nostr.ts +++ b/gui/src/logic/nostr.ts @@ -21,26 +21,32 @@ export function generateNprofile(pubkey: string) { const nprofile = nip19.nprofileEncode(prof); return nprofile; } -export function isValidNostrKey(key: string): boolean { +export function decodeNostrKey(key: string): string | null { try { - nip19.decode(key); - return true; + const { type, data } = nip19.decode(key); + if (type === "nevent") return data.id; + else if (type === "nprofile") return data.pubkey; + else if (type === "naddr") return data.pubkey; + else if (type === "npub") return data; + else if (type === "nsec") return uint8ArrayToHexString(data); + else if (type === "note") return data; + else return null; } catch (e) { try { + // TODO do we want this for something nip19.npubEncode(key); - return true; + return key; } catch (e2) { console.error(e2, "not valid nostr key"); - return false; + return null; } } } - -// let sk = generateSecretKey() -// let nsec = nip19.nsecEncode(sk) -// let { type, data } = nip19.decode(nsec) -// assert(type === 'nsec') -// assert(data === sk) +function uint8ArrayToHexString(uint8Array: Uint8Array) { + return Array.from(uint8Array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} // let pk = getPublicKey(generateSecretKey()) // let npub = nip19.npubEncode(pk) @@ -55,3 +61,5 @@ export function isValidNostrKey(key: string): boolean { // assert(type === 'nprofile') // assert(data.pubkey === pk) // assert(data.relays.length === 2) +// +// nevent1qqsp3faj5jy9fpc6779rcs9kdccc0mxwlv2pnhymwqtjmletn72u5echttguv; diff --git a/gui/src/logic/nostrill.ts b/gui/src/logic/nostrill.ts index 97d2156..f976c95 100644 --- a/gui/src/logic/nostrill.ts +++ b/gui/src/logic/nostrill.ts @@ -1,9 +1,12 @@ import type { Event } from "@/types/nostr"; -import type { Content, FC, Poast } from "@/types/trill"; +import type { Content, Cursor, FC, FlatFeed, Poast } from "@/types/trill"; import { engagementBunt, openLock } from "./bunts"; import type { UserType } from "@/types/nostrill"; import type { Result } from "@/types/ui"; import { isValidPatp } from "urbit-ob"; +import { IMAGE_SUBREGEX, URL_REGEX, VIDEO_SUBREGEX } from "./constants"; +import { decodeNostrKey } from "./nostr"; + export function eventsToFc(postEvents: Event[]): FC { const fc = postEvents.reduce( (acc: FC, event: Event) => { @@ -18,9 +21,49 @@ export function eventsToFc(postEvents: Event[]): FC { ); return fc; } +export function addEventToFc(event: Event, fc: FC): FC { + const p = eventToPoast(event); + if (!p) return fc; + fc.feed[p.id] = p; + if (!fc.start || event.created_at < Number(fc.start)) fc.start = p.id; + if (!fc.end || event.created_at > Number(fc.end)) fc.end = p.id; + return fc; +} +export function extractURLs(text: string): { + text: Array<{ text: string } | { link: { href: string; show: string } }>; + pics: string[]; + vids: string[]; +} { + const pics: string[] = []; + const vids: string[] = []; + const tokens: Array< + { text: string } | { link: { href: string; show: string } } + > = []; + const sections = text.split(URL_REGEX); + for (const sec of sections) { + if (!sec) continue; + const s = sec.trim(); + if (!s) continue; + if (URL_REGEX.test(s)) { + if (IMAGE_SUBREGEX.test(s)) { + pics.push(s); + } else if (VIDEO_SUBREGEX.test(s)) { + vids.push(s); + } else tokens.push({ link: { href: s, show: s } }); + } else tokens.push({ text: s }); + } + + return { text: tokens, pics, vids }; +} + export function eventToPoast(event: Event): Poast | null { if (event.kind !== 1) return null; - const contents: Content = [{ paragraph: [{ text: event.content }] }]; + const inl = extractURLs(event.content || ""); + const contents: Content = [ + { paragraph: inl.text }, + { media: { images: inl.pics } }, + ]; + if (inl.vids.length > 0) contents.push({ media: { video: inl.vids[0] } }); const ts = event.created_at * 1000; const id = `${ts}`; const poast: Poast = { @@ -28,11 +71,12 @@ export function eventToPoast(event: Event): Poast | null { host: event.pubkey, author: event.pubkey, contents, - thread: id, + thread: null, parent: null, read: openLock, write: openLock, tags: [], + hash: event.id, time: ts, engagement: engagementBunt, children: [], @@ -48,17 +92,21 @@ export function eventToPoast(event: Event): Poast | null { // TODO if (marker === "root") poast.thread = eventId; else if (marker === "reply") poast.parent = eventId; + // TODO this are kinda useful too as quotes or whatever + // else if (marker === "mention") poast.parent = eventId; } // - if (ff === "r") + else if (ff === "r") contents.push({ paragraph: [{ link: { show: tag[1]!, href: tag[1]! } }], }); - if (ff === "p") - contents.push({ - paragraph: [{ ship: tag[1]! }], - }); - if (ff === "q") + else if (ff === "p") { + // + } + // contents.push({ + // paragraph: [{ ship: tag[1]! }], + // }); + else if (ff === "q") contents.push({ ref: { type: "nostr", @@ -66,10 +114,23 @@ export function eventToPoast(event: Event): Poast | null { path: tag[2] || "" + `/${tag[3] || ""}`, }, }); + // else console.log("odd tag", tag); } + if (!poast.parent && !poast.thread) { + const tags = event.tags.filter((t) => t[0] !== "p"); + console.log("no parent", { event, poast, tags }); + } + if (!poast.parent && poast.thread) poast.parent = poast.thread; return poast; } +export function stringToUser(s: string): Result<UserType> { + const p = isValidPatp(s); + if (p) return { ok: { urbit: s } }; + const dec = decodeNostrKey(s); + if (dec) return { ok: { nostr: s } }; + else return { error: "invalid user" }; +} export function userToString(user: UserType): Result<string> { if ("urbit" in user) { const isValid = isValidPatp(user.urbit); @@ -78,16 +139,6 @@ export function userToString(user: UserType): Result<string> { } else if ("nostr" in user) return { ok: user.nostr }; else return { error: "unknown user" }; } -export function isValidNostrPubkey(pubkey: string): boolean { - // TODO - if (pubkey.length !== 64) return false; - try { - BigInt("0x" + pubkey); - return true; - } catch (_e) { - return false; - } -} // NOTE common tags: // imeta // client @@ -138,3 +189,69 @@ export function isValidNostrPubkey(pubkey: string): boolean { // } // return effects; // } +// + +function findId(feed: FlatFeed, id: string): Result<string> { + const has = feed[id]; + if (!has) return { ok: id }; + else { + try { + const bigint = BigInt(id); + const n = bigint + 1n; + return findId(feed, n.toString()); + } catch (e) { + return { error: "not a number" }; + } + } +} +function updateCursor(cursor: Cursor, ncursor: Cursor, earlier: boolean) { + if (!cursor) return ncursor; + if (!ncursor) return cursor; + const or = BigInt(cursor); + const nw = BigInt(ncursor); + const shouldChange = earlier ? nw < or : nw > or; + return shouldChange ? ncursor : cursor; +} +export function consolidateFeeds(fols: Map<string, FC>): FC { + const f: FlatFeed = {}; + let start: Cursor = null; + let end: Cursor = null; + const feeds = fols.entries(); + for (const [_userString, fc] of feeds) { + start = updateCursor(start, fc.start, true); + end = updateCursor(end, fc.end, false); + + const poasts = Object.values(fc.feed); + for (const p of poasts) { + const nid = findId(f, p.id); + if ("error" in nid) continue; + f[nid.ok] = p; + } + } + return { start, end, feed: f }; +} +export function disaggregate( + fols: Map<string, FC>, + choice: "urbit" | "nostr", +): FC { + const f: FlatFeed = {}; + let start: Cursor = null; + let end: Cursor = null; + const feeds = fols.entries(); + for (const [userString, fc] of feeds) { + const want = + choice === "urbit" + ? isValidPatp(userString) + : !!decodeNostrKey(userString); + if (!want) continue; + start = updateCursor(start, fc.start, true); + end = updateCursor(end, fc.end, false); + const poasts = Object.values(fc.feed); + for (const p of poasts) { + const nid = findId(f, p.id); + if ("error" in nid) continue; + f[nid.ok] = p; + } + } + return { start, end, feed: f }; +} diff --git a/gui/src/logic/requests/nostrill.ts b/gui/src/logic/requests/nostrill.ts index 81f0bb1..c2c0074 100644 --- a/gui/src/logic/requests/nostrill.ts +++ b/gui/src/logic/requests/nostrill.ts @@ -22,10 +22,24 @@ export default class IO { }); } private async poke(json: any) { - return this.airlock.poke({ app: "nostrill", mark: "json", json }); + try { + const res = await this.airlock.poke({ + app: "nostrill", + mark: "json", + json, + }); + return { ok: res }; + } catch (e) { + return { error: `${e}` }; + } } private async scry(path: string) { - return this.airlock.scry({ app: "nostrill", path }); + try { + const res = await this.airlock.scry({ app: "nostrill", path }); + return { ok: res }; + } catch (e) { + return { error: `${e}` }; + } } private async sub(path: string, handler: Handler) { const has = this.subs.get(path); @@ -83,11 +97,12 @@ export default class IO { // const path = `/j/thread/${host}/${id}/${start}/${end}/${FeedPostCount}/${order}`; const path = `/j/thread/${host}/${id}`; const res = await this.scry(path); - if (!("begs" in res)) return { error: "wrong result" }; - if ("ng" in res.begs) return { error: res.begs.ng }; - if ("ok" in res.begs) { - if (!("thread" in res.begs.ok)) return { error: "wrong result" }; - else return { ok: res.begs.ok.thread }; + if ("error" in res) return res; + if (!("begs" in res.ok)) return { error: "wrong result" }; + if ("ng" in res.ok.begs) return { error: res.ok.begs.ng }; + if ("ok" in res.ok.begs) { + if (!("thread" in res.ok.begs.ok)) return { error: "wrong result" }; + else return { ok: res.ok.begs.ok.thread }; } else return { error: "wrong result" }; } // pokes @@ -99,16 +114,16 @@ export default class IO { const json = { add: { content } }; return this.poke({ post: json }); } - async addReply(content: string, host: string, id: string, thread: string) { + async addReply(content: string, host: UserType, id: string, thread: string) { const json = { reply: { content, host, id, thread } }; return this.poke({ post: json }); } - async addQuote(content: string, pid: PID) { - const json = { quote: { content, host: pid.ship, id: pid.id } }; + async addQuote(content: string, host: UserType, id: string) { + const json = { quote: { content, host, id } }; return this.poke({ post: json }); } - async addRP(pid: PID) { - const json = { rp: { host: pid.ship, id: pid.id } }; + async addRP(host: UserType, id: string) { + const json = { rp: { host, id } }; return this.poke({ post: json }); } @@ -122,7 +137,7 @@ export default class IO { // return this.poke(json); // } - async deletePost(host: Ship, id: string) { + async deletePost(host: UserType, id: string) { const json = { del: { host, @@ -132,12 +147,12 @@ export default class IO { return this.poke({ post: json }); } - async addReact(ship: Ship, id: PostID, reaction: string) { + async addReact(host: UserType, id: PostID, reaction: string) { const json = { reaction: { reaction: reaction, - id: id, - host: ship, + id, + host, }, }; @@ -180,6 +195,10 @@ export default class IO { const json = { sync: null }; return await this.poke({ rela: json }); } + async getProfiles(users: UserType[]) { + const json = { fetch: users }; + return await this.poke({ prof: json }); + } async relayPost(host: string, id: string, relays: string[]) { const json = { send: { host, id, relays } }; return await this.poke({ rela: json }); @@ -214,6 +233,20 @@ export default class IO { return { error: `${e}` }; } } + // nostr + // + async nostrFeed(pubkey: string): AsyncRes<number> { + const json = { rela: { user: pubkey } }; + return await this.poke(json); + } + async nostrThread(id: string): AsyncRes<number> { + const json = { rela: { thread: id } }; + return await this.poke(json); + } + async nostrProfiles() { + const json = { prof: null }; + return await this.poke({ rela: json }); + } } // notifications diff --git a/gui/src/logic/trill/helpers.ts b/gui/src/logic/trill/helpers.ts index 8bd1b0c..6936cf0 100644 --- a/gui/src/logic/trill/helpers.ts +++ b/gui/src/logic/trill/helpers.ts @@ -1,4 +1,6 @@ -import type { FullNode, Poast } from "@/types/trill"; +import type { NostrEvent } from "@/types/nostr"; +import type { FlatFeed, FullFeed, FullNode, Poast } from "@/types/trill"; +import { eventToPoast } from "../nostrill"; export function toFlat(n: FullNode): Poast { return { @@ -32,3 +34,185 @@ export function extractThread(node: FullNode): res { }, bunt); return r; } + +export function findReplies(n: Poast, f: FlatFeed): Poast[] { + const posts = Object.values(f); + const kids: Poast[] = []; + for (const p of posts) { + if (p.parent === n.id) kids.push(p); + } + return kids; +} + +export function eventToFn(ev: NostrEvent) { + const p = eventToPoast(ev)!; + const fn: FullNode = { ...p, children: {} }; + return fn; +} +export function eventsToFF(nodes: FullNode[]): FullFeed { + // Step 1: Create a map with all nodes having empty children + const nodeMap: Record<string, FullNode> = {}; + nodes.forEach((node) => { + nodeMap[node.hash] = node; + }); + + // Step 2: Build relationships by adding each node to its parent's children + const rootNodes: FullFeed = {}; + nodes.forEach((node) => { + const currentNode = nodeMap[node.hash]; + + if (!node.parent) { + rootNodes[node.hash] = currentNode; // It's a root + } else if (nodeMap[node.parent]) { + nodeMap[node.parent].children[node.hash] = currentNode; // Add to parent + } else { + rootNodes[node.hash] = currentNode; // Parent missing, treat as root + } + }); + + return rootNodes; +} + +export function getDescendants(node: FullNode): FullNode[] { + const descendants: FullNode[] = []; + + function traverse(currentNode: FullNode) { + Object.values(currentNode.children).forEach((child) => { + descendants.push(child); + traverse(child); + }); + } + + traverse(node); + return descendants; +} + +/** + * Alternative implementation that handles orphaned nodes differently + * Orphaned nodes (whose parents aren't in the array) are collected separately + */ +export function buildTreeWithOrphans(nodes: FullNode[]): { + tree: FullFeed; + orphans: FullFeed; +} { + const nodeMap: Record<string, FullNode> = {}; + + // Initialize all nodes + nodes.forEach((node) => { + nodeMap[node.hash] = node; + }); + + const rootNodes: FullFeed = {}; + const orphanNodes: FullFeed = {}; + + nodes.forEach((node) => { + const currentNode = nodeMap[node.id]; + + if (!node.parent) { + // Root node + rootNodes[node.id] = currentNode; + } else if (nodeMap[node.parent]) { + // Parent exists, add to parent's children + nodeMap[node.parent].children[node.id] = currentNode; + } else { + // Parent doesn't exist, it's an orphan + orphanNodes[node.id] = currentNode; + } + }); + + return { tree: rootNodes, orphans: orphanNodes }; +} + +export function findNodeById( + tree: FullFeed, + targetId: string, +): FullNode | null { + function search(nodes: FullFeed): FullNode | null { + for (const node of Object.values(nodes)) { + if (node.id === targetId) { + return node; + } + + const found = search(node.children); + if (found) { + return found; + } + } + return null; + } + + return search(tree); +} + +export function getPathToNode( + tree: FullFeed, + targetId: string, +): FullNode[] | null { + function search(nodes: FullFeed, path: FullNode[]): FullNode[] | null { + for (const node of Object.values(nodes)) { + const currentPath = [...path, node]; + + if (node.id === targetId) { + return currentPath; + } + + const found = search(node.children, currentPath); + if (found) { + return found; + } + } + return null; + } + + return search(tree, []); +} + +export function flattenTree(tree: FullFeed): FullNode[] { + const result: FullNode[] = []; + + function traverse(nodes: FullFeed) { + Object.values(nodes).forEach((node) => { + result.push(node); + traverse(node.children); + }); + } + + traverse(tree); + return result; +} + +export function getTreeDepth(tree: FullFeed): number { + function getDepth(nodes: FullFeed, currentDepth: number): number { + if (Object.keys(nodes).length === 0) { + return currentDepth; + } + + let maxDepth = currentDepth; + Object.values(nodes).forEach((node) => { + const childDepth = getDepth(node.children, currentDepth + 1); + maxDepth = Math.max(maxDepth, childDepth); + }); + + return maxDepth; + } + + return getDepth(tree, 0); +} + +/** + * Count total nodes in the tree + */ +export function countNodes(tree: FullFeed): number { + let count = 0; + + function traverse(nodes: FullFeed) { + count += Object.keys(nodes).length; + Object.values(nodes).forEach((node) => { + traverse(node.children); + }); + } + + traverse(tree); + return count; +} +// http://localhost:5173/apps/nostrill/t/nevent1qqsp3faj5jy9fpc6779rcs9kdccc0mxwlv2pnhymwqtjmletn72u5echttguv |
