summaryrefslogtreecommitdiff
path: root/gui/src/logic
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src/logic')
-rw-r--r--gui/src/logic/cache.ts222
-rw-r--r--gui/src/logic/constants.ts12
-rw-r--r--gui/src/logic/hooks.ts173
-rw-r--r--gui/src/logic/nostr.ts30
-rw-r--r--gui/src/logic/nostrill.ts155
-rw-r--r--gui/src/logic/requests/nostrill.ts65
-rw-r--r--gui/src/logic/trill/helpers.ts186
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