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 | |
| parent | e6e657be3a3b1dae426b46f3bc16f9a5cf4861c2 (diff) | |
Big GUI improvements on Nostr rendering and fetchingpolwex/iris
40 files changed, 1897 insertions, 704 deletions
diff --git a/gui/bun.lock b/gui/bun.lock index 33af6d1..d34d11c 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -7,6 +7,7 @@ "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.85.9", "any-ascii": "^0.3.3", + "lucide-react": "^0.554.0", "nostr-tools": "^2.17.2", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -496,6 +497,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@0.554.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA=="], + "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], diff --git a/gui/package.json b/gui/package.json index 9ab8472..30baccb 100644 --- a/gui/package.json +++ b/gui/package.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.85.9", "any-ascii": "^0.3.3", + "lucide-react": "^0.554.0", "nostr-tools": "^2.17.2", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/gui/src/Router.tsx b/gui/src/Router.tsx index 5026ef0..5566524 100644 --- a/gui/src/Router.tsx +++ b/gui/src/Router.tsx @@ -3,7 +3,7 @@ import useLocalState from "@/state/state"; import Feed from "@/pages/Feed"; import User from "@/pages/User"; import Settings from "@/pages/Settings"; -import Thread from "@/pages/Thread"; +import Thread, { NostrThreadLoader } from "@/pages/Thread"; import { Switch, Router, Redirect, Route } from "wouter"; import { P404 } from "./pages/Error"; @@ -14,12 +14,13 @@ export default function r() { <Router base="/apps/nostrill"> <Sidebar /> <main> - <Route path="/" component={toGlobal} /> + <Route path="/" component={toMain} /> <Route path="/sets" component={Settings} /> <Route path="/f" component={Feed} /> <Route path="/f/:taip" component={Feed} /> <Route path="/u/:user" component={User} /> <Route path="/t/:host/:id" component={Thread} /> + <Route path="/t/:id" component={NostrThreadLoader} /> </main> {modal && modal} </Router> @@ -27,6 +28,6 @@ export default function r() { </Switch> ); } -function toGlobal() { +function toMain() { return <Redirect to="/f" />; } diff --git a/gui/src/components/Avatar.tsx b/gui/src/components/Avatar.tsx index 2b38848..664bc90 100644 --- a/gui/src/components/Avatar.tsx +++ b/gui/src/components/Avatar.tsx @@ -7,7 +7,6 @@ import UserModal from "./modals/UserModal"; export default function ({ user, - userString, size, color, noClickOnName, @@ -24,7 +23,6 @@ export default function ({ }) { const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); // TODO revisit this when %whom updates - console.log({ profile }); const avatarInner = profile ? ( <img src={profile.picture} width={size} height={size} /> ) : "urbit" in user && isValidPatp(user.urbit) ? ( @@ -43,7 +41,7 @@ export default function ({ function openModal(e: React.MouseEvent) { if (noClickOnName) return; e.stopPropagation(); - setModal(<UserModal userString={userString} />); + setModal(<UserModal user={user} />); } const name = ( <div className="name cp" role="link" onMouseUp={openModal}> diff --git a/gui/src/components/composer/Composer.tsx b/gui/src/components/composer/Composer.tsx index 8b7e343..1fb0c6c 100644 --- a/gui/src/components/composer/Composer.tsx +++ b/gui/src/components/composer/Composer.tsx @@ -1,10 +1,13 @@ +import "@/styles/Composer.css"; import useLocalState from "@/state/state"; +import spinner from "@/assets/triangles.svg"; import Sigil from "@/components/Sigil"; import { useState, useEffect, useRef, type FormEvent } from "react"; import Snippets, { ReplySnippet } from "./Snippets"; import toast from "react-hot-toast"; import Icon from "@/components/Icon"; import { wait } from "@/logic/utils"; +import type { UserType } from "@/types/nostrill"; function Composer({ isAnon }: { isAnon?: boolean }) { const { api, composerData, addNotification, setComposerData } = useLocalState( @@ -15,12 +18,12 @@ function Composer({ isAnon }: { isAnon?: boolean }) { setComposerData: s.setComposerData, }), ); - const our = api!.airlock.our!; const [input, setInput] = useState(""); const [isExpanded, setIsExpanded] = useState(false); const [isLoading, setLoading] = useState(false); const inputRef = useRef<HTMLInputElement>(null); + console.log({ composerData }); useEffect(() => { if (composerData) { setIsExpanded(true); @@ -29,8 +32,6 @@ function Composer({ isAnon }: { isAnon?: boolean }) { composerData.post && "trill" in composerData.post ) { - const author = composerData.post.trill.author; - setInput(`${author} `); } // Auto-focus input when composer opens setTimeout(() => { @@ -38,27 +39,46 @@ function Composer({ isAnon }: { isAnon?: boolean }) { }, 100); // Small delay to ensure the composer is rendered } }, [composerData]); + async function addSimple() { + if (!api) return; // TODOhandle error + return await api.addPost(input); + } + async function addComplex() { + if (!api) return; // TODOhandle error + if (!composerData) return; + const host: UserType = + "trill" in composerData.post + ? { urbit: composerData.post.trill.author } + : "nostr" in composerData.post + ? { nostr: composerData.post.nostr.pubkey } + : { urbit: api.airlock.our! }; + const id = + "trill" in composerData.post + ? composerData.post.trill.id + : "nostr" in composerData.post + ? composerData.post.nostr.eventId + : ""; + const thread = + "trill" in composerData.post + ? composerData.post.trill.thread || composerData.post.trill.id + : "nostr" in composerData.post + ? composerData.post.nostr.eventId + : ""; + + const res = + composerData.type === "reply" + ? api.addReply(input, host, id, thread) + : composerData?.type === "quote" + ? api.addQuote(input, host, id) + : wait(500); + return await res; + } async function poast(e: FormEvent<HTMLFormElement>) { e.preventDefault(); - // TODO + if (!api) return; // TODOhandle error + const our = api.airlock.our!; setLoading(true); - - const res = - composerData?.type === "reply" && "trill" in composerData.post - ? api!.addReply( - input, - composerData.post.trill.host, - composerData.post.trill.id, - composerData.post.trill.thread || composerData.post.trill.id, - ) - : composerData?.type === "quote" && "trill" in composerData.post - ? api!.addQuote(input, { - ship: composerData.post.trill.host, - id: composerData.post.trill.id, - }) - : !composerData - ? api!.addPost(input) - : wait(500); + const res = !composerData ? addSimple() : addComplex(); const ares = await res; if (ares) { // // Check for mentions in the post (ship names starting with ~) @@ -95,6 +115,7 @@ function Composer({ isAnon }: { isAnon?: boolean }) { setInput(""); setComposerData(null); // Clear composer data after successful post toast.success("post sent"); + setLoading(false); setIsExpanded(false); } } @@ -121,7 +142,7 @@ function Composer({ isAnon }: { isAnon?: boolean }) { onSubmit={poast} > <div className="sigil avatar"> - <Sigil patp={our} size={46} /> + <Sigil patp={api?.airlock.our || ""} size={46} /> </div> <div className="composer-content"> @@ -172,9 +193,15 @@ function Composer({ isAnon }: { isAnon?: boolean }) { onFocus={() => setIsExpanded(true)} placeholder={placeHolder} /> - <button type="submit" disabled={!input.trim()} className="post-btn"> - Post - </button> + {isLoading ? ( + <div className="loading-container"> + <img src={spinner} /> + </div> + ) : ( + <button type="submit" disabled={!input.trim()} className="post-btn"> + Post + </button> + )} </div> {/* Quote snippets appear below input */} diff --git a/gui/src/components/feed/PostList.tsx b/gui/src/components/feed/PostList.tsx index 12b58b4..3659bde 100644 --- a/gui/src/components/feed/PostList.tsx +++ b/gui/src/components/feed/PostList.tsx @@ -1,6 +1,8 @@ import TrillPost from "@/components/post/Post"; import type { FC } from "@/types/trill"; import useLocalState from "@/state/state"; +import type { UserType } from "@/types/nostrill"; +import { isValidPatp } from "urbit-ob"; // import { useEffect } from "react"; // import { useQueryClient } from "@tanstack/react-query"; // import { toFull } from "../thread/helpers"; @@ -26,14 +28,21 @@ function TrillFeed({ data, refetch }: { data: FC; refetch: Function }) { .filter((i) => !data.feed[i].parent) .sort() .reverse() - .slice(0, 50) + // .slice(0, 50) .map((i) => { const poast = data.feed[i]; - const profile = profiles.get(poast.author); + const user: UserType = poast.event + ? { nostr: poast.event.pubkey } + : isValidPatp(poast.author) + ? { urbit: poast.author } + : { nostr: poast.author }; + const userString = "urbit" in user ? user.urbit : user.nostr; + const profile = profiles.get(userString); return ( <TrillPost key={i} poast={poast} + user={user} profile={profile} refetch={refetch} /> diff --git a/gui/src/components/layout/Sidebar.tsx b/gui/src/components/layout/Sidebar.tsx index bc11e48..12087c1 100644 --- a/gui/src/components/layout/Sidebar.tsx +++ b/gui/src/components/layout/Sidebar.tsx @@ -30,7 +30,7 @@ function SlidingMenu() { <h3> Nostrill </h3> </div> <h3>Feeds</h3> - <div className="opt" role="link" onClick={() => goto(`/f/global`)}> + <div className="opt" role="link" onClick={() => goto(`/f`)}> <Icon name="home" size={20} /> <div>Home</div> </div> diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx index aeffc95..4a7f812 100644 --- a/gui/src/components/modals/UserModal.tsx +++ b/gui/src/components/modals/UserModal.tsx @@ -6,13 +6,12 @@ import Icon from "@/components/Icon"; import useLocalState from "@/state/state"; import { useLocation } from "wouter"; import toast from "react-hot-toast"; -import { isValidPatp } from "urbit-ob"; -import { isValidNostrPubkey } from "@/logic/nostrill"; import { generateNprofile } from "@/logic/nostr"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import type { UserType } from "@/types/nostrill"; -export default function ({ userString }: { userString: string }) { - const { setModal, api, pubkey, profiles, following, followers } = +export default function ({ user }: { user: UserType }) { + const { setModal, api, lastFact, pubkey, profiles, following, followers } = useLocalState((s) => ({ setModal: s.setModal, api: s.api, @@ -20,31 +19,15 @@ export default function ({ userString }: { userString: string }) { profiles: s.profiles, following: s.following, followers: s.followers, + lastFact: s.lastFact, })); const [_, navigate] = useLocation(); - const [loading, setLoading] = useState(false); + const [isLoading, setLoading] = useState(false); function close() { setModal(null); } - const user = isValidPatp(userString) - ? { urbit: userString } - : isValidNostrPubkey(userString) - ? { nostr: userString } - : { error: "" }; - - if ("error" in user) { - return ( - <Modal close={close}> - <div className="user-modal-error"> - <Icon name="comet" size={48} /> - <p>Invalid user identifier</p> - </div> - </Modal> - ); - } - const itsMe = "urbit" in user ? user.urbit === api?.airlock.our @@ -52,6 +35,7 @@ export default function ({ userString }: { userString: string }) { ? user.nostr === pubkey : false; + const userString = "urbit" in user ? user.urbit : user.nostr; const profile = profiles.get(userString); const isFollowing = following.has(userString); const isFollower = followers.includes(userString); @@ -60,6 +44,15 @@ export default function ({ userString }: { userString: string }) { const userFeed = following.get(userString); const postCount = userFeed ? Object.keys(userFeed.feed).length : 0; + // useEffect(() => { + // if (!lastFact) return; + // if (!("fols" in lastFact)) return; + // if (!("new" in lastFact.fols)) return; + // if (lastFact.fols.new.user === userString) setLoading(false); + // const name = profile?.name || userString; + // toast.success(`Followed ${name}`); + // }, [lastFact]); + async function copy(e: React.MouseEvent) { e.stopPropagation(); await navigator.clipboard.writeText(userString); @@ -93,7 +86,7 @@ export default function ({ userString }: { userString: string }) { } catch (err) { toast.error("Action failed"); } finally { - setLoading(false); + // setLoading(false); } } @@ -112,16 +105,6 @@ export default function ({ userString }: { userString: string }) { ? `${userString.slice(0, 10)}...${userString.slice(-8)}` : userString; - // Check if a string is a URL - const isURL = (str: string): boolean => { - try { - new URL(str); - return true; - } catch { - return str.startsWith("http://") || str.startsWith("https://"); - } - }; - // Get banner image from profile.other const bannerImage = profile?.other?.banner || profile?.other?.Banner; @@ -210,29 +193,15 @@ export default function ({ userString }: { userString: string }) { {otherFields.length > 0 && ( <div className="user-modal-custom-fields"> <h4>Additional Info</h4> - {otherFields.map(([key, value]) => ( - <div key={key} className="custom-field-item"> - <span className="field-key">{key}:</span> - {isURL(value) ? ( - <a - href={value} - target="_blank" - rel="noopener noreferrer" - className="field-value field-link" - onClick={(e) => e.stopPropagation()} - > - {value} - <Icon - name="nostr" - size={12} - className="external-link-icon" - /> - </a> - ) : ( - <span className="field-value">{value}</span> - )} - </div> - ))} + {otherFields.map(([key, value]) => { + console.log({ key, value }); + return ( + <div key={key} className="custom-field-item"> + <span className="field-key">{key}:</span> + <ProfValue value={value} /> + </div> + ); + })} </div> )} @@ -242,10 +211,10 @@ export default function ({ userString }: { userString: string }) { <button className={`action-btn ${isFollowing ? "following" : "follow"}`} onClick={handleFollow} - disabled={loading} + disabled={isLoading} > <Icon name="pals" size={16} /> - {loading ? "..." : isFollowing ? "Following" : "Follow"} + {isLoading ? "..." : isFollowing ? "Following" : "Follow"} </button> )} <> @@ -275,3 +244,37 @@ export default function ({ userString }: { userString: string }) { </Modal> ); } + +// Check if a string is a URL +const isURL = (str: string): boolean => { + if (!str) return false; + try { + new URL(str); + return true; + } catch { + return false; + } +}; + +export function ProfValue({ value }: { value: any }) { + if (typeof value === "string") + return isURL(value) ? ( + <a + href={value} + target="_blank" + rel="noopener noreferrer" + className="field-value field-link" + onClick={(e) => e.stopPropagation()} + > + {value} + <Icon name="nostr" size={12} className="external-link-icon" /> + </a> + ) : ( + <span className="field-value">{value}</span> + ); + else if (typeof value === "number") + return <span className="field-value">{value}</span>; + else if (typeof value === "object") + return <span className="field-value">{JSON.stringify(value)}</span>; + else return <span className="field-value">{JSON.stringify(value)}</span>; +} diff --git a/gui/src/components/nostr/Feed.tsx b/gui/src/components/nostr/Feed.tsx index d21307b..dec4ab5 100644 --- a/gui/src/components/nostr/Feed.tsx +++ b/gui/src/components/nostr/Feed.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { eventsToFc } from "@/logic/nostrill"; import Icon from "@/components/Icon"; import toast from "react-hot-toast"; +import { Contact, RefreshCw } from "lucide-react"; export default function Nostr() { const { nostrFeed, api, relays } = useLocalState((s) => ({ @@ -12,11 +13,8 @@ export default function Nostr() { api: s.api, relays: s.relays, })); - console.log({ relays }); const [isSyncing, setIsSyncing] = useState(false); - console.log({ nostrFeed }); const feed = eventsToFc(nostrFeed); - console.log({ feed }); const refetch = () => feed; const handleResync = async () => { @@ -34,6 +32,21 @@ export default function Nostr() { } }; + async function fetchProfiles() { + if (!api) return; + + setIsSyncing(true); + try { + await api.syncRelays(); + toast.success("Nostr feed sync initiated"); + } catch (error) { + toast.error("Failed to sync Nostr feed"); + console.error("Sync error:", error); + } finally { + setIsSyncing(false); + } + } + if (Object.keys(relays).length === 0) return ( <div className="nostr-empty-state"> @@ -97,18 +110,28 @@ export default function Nostr() { {Object.keys(feed.feed).length} posts </span> </div> - <button - onClick={handleResync} - disabled={isSyncing} - className="resync-btn-small" - title="Sync with Nostr relays" - > - {isSyncing ? ( - <img src={spinner} alt="Loading" className="btn-spinner-small" /> - ) : ( - <Icon name="settings" size={16} /> - )} - </button> + <div className="flex gap-4"> + <button + className="btn-small" + onClick={fetchProfiles} + title="Fetch user profiles" + > + <Contact /> + </button> + + <button + onClick={handleResync} + disabled={isSyncing} + className="btn-small" + title="Sync with Nostr relays" + > + {isSyncing ? ( + <img src={spinner} alt="Loading" className="btn-spinner-small" /> + ) : ( + <RefreshCw /> + )} + </button> + </div> </div> <PostList data={feed} refetch={refetch} /> </div> diff --git a/gui/src/components/nostr/Thread.tsx b/gui/src/components/nostr/Thread.tsx new file mode 100644 index 0000000..c46b547 --- /dev/null +++ b/gui/src/components/nostr/Thread.tsx @@ -0,0 +1,174 @@ +import useLocalState from "@/state/state"; +import Icon from "@/components/Icon"; +import spinner from "@/assets/triangles.svg"; +import type { FC, FullFeed, FullNode } from "@/types/trill"; +import Composer from "@/components/composer/Composer"; +import type { UserProfile } from "@/types/nostrill"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { eventsToFF, eventToFn } from "@/logic/trill/helpers"; +import { toFlat } from "../post/RP"; +import type { NostrEvent } from "@/types/nostr"; +import { createCache } from "@/logic/cache"; +import Post from "../post/Post"; +import Modal from "../modals/Modal"; + +type Props = { + host: string; + id: string; + feed?: FC; + profile?: UserProfile; +}; +const cache = createCache({ dbName: "nostrill", storeName: "nosted" }); + +export default function Thread(props: Props) { + const { api, composerData, setComposerData, setModal, lastFact } = + useLocalState((s) => ({ + api: s.api, + lastFact: s.lastFact, + composerData: s.composerData, + setComposerData: s.setComposerData, + setModal: s.setModal, + })); + const { id, feed, profile } = props; + const poast = feed?.feed[id]; + const host = poast?.author || ""; + const [error, setError] = useState(""); + // const [data, setData] = useState<{fc: FC, head: Poast}>(() => getCachedData(id)); + const [data, setData] = useState<FullFeed>(); + + useEffect(() => { + console.log({ composerData }); + if (composerData) + setModal( + <Modal + close={() => { + setComposerData(null); + }} + > + <Composer /> + </Modal>, + ); + }, [composerData]); + // useTimeout(() => { + // if (!data) setError("Request timed out"); + // }, 10_000); + + useEffect(() => { + if (!lastFact) return; + if (!("nostr" in lastFact)) return; + if (!("thread" in lastFact.nostr)) return; + toast.success("thread fetched succesfully, rendering"); + cache.set("evs", lastFact.nostr.thread); + const nodes = lastFact.nostr.thread.map(eventToFn); + const ff = eventsToFF(nodes); + setData(ff); + }, [lastFact]); + + useEffect(() => { + if (!api) return; + const init = async () => { + const cached: NostrEvent[] | null = await cache.get("evs"); + if (cached) { + const nodes = cached.map(eventToFn); + const ff = eventsToFF(nodes); + setData(ff); + } + }; + init(); + }, [id]); + + async function tryAgain() { + if (!api) return; + setError(""); + api.nostrThread(id); + } + + return ( + <> + <div className="thread-header"> + <div className="thread-nav"> + <button + className="back-btn" + onClick={() => window.history.back()} + title="Go back" + > + <Icon name="reply" size={16} /> + <span>Back to Feed</span> + </button> + </div> + <h2>Thread</h2> + <div className="thread-info"> + <span className="thread-host">~{host}</span> + <span className="thread-separator">•</span> + <span className="thread-id">#{id}</span> + </div> + </div> + <div id="feed-proper"> + {data ? ( + <> + <Head node={data[id]} profile={profile} /> + </> + ) : error ? ( + <div className="text-center m-10 text-2xl"> + <h2>Error Loading Thread</h2> + <p className="error">{error}</p> + <button className="cycle-btn mx-auto my-8" onClick={tryAgain}> + Try Again + </button> + </div> + ) : ( + <> + <h2 className="text-center my-8">Loading Thread...</h2> + <div className="loading-container"> + <img className="x-center" src={spinner} alt="Loading" /> + </div> + </> + )} + </div> + </> + ); +} +function Head({ node, profile }: { node: FullNode; profile?: UserProfile }) { + return ( + <> + <Post + poast={toFlat(node)} + user={{ nostr: node.author }} + profile={profile} + /> + <div id="thread-children"> + <Minithread ff={node.children} /> + </div> + </> + ); +} + +function Minithread({ ff }: { ff: FullFeed }) { + const profiles = useLocalState((s) => s.profiles); + const nodes = Object.values(ff); + return ( + <div id="tail"> + {nodes.map((c) => { + const profile = profiles.get(c.author); + return ( + <div key={c.hash} className="minithread"> + <Post + user={{ nostr: c.author }} + poast={toFlat(c)} + profile={profile} + /> + <Grandchildren node={c} /> + </div> + ); + })} + </div> + ); +} +function Grandchildren({ node }: { node: FullNode }) { + return ( + <div className="tail"> + <Minithread ff={node.children} /> + </div> + ); +} diff --git a/gui/src/components/nostr/User.tsx b/gui/src/components/nostr/User.tsx index a9e9e2f..be7b15c 100644 --- a/gui/src/components/nostr/User.tsx +++ b/gui/src/components/nostr/User.tsx @@ -1,34 +1,52 @@ import useLocalState from "@/state/state"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Icon from "@/components/Icon"; import toast from "react-hot-toast"; -import type { UserType } from "@/types/nostrill"; import type { FC } from "@/types/trill"; import Composer from "../composer/Composer"; import PostList from "@/components/feed/PostList"; +import { addEventToFc, eventsToFc } from "@/logic/nostrill"; export default function NostrUser({ - user, userString, + pubkey, feed, isFollowLoading, setIsFollowLoading, isAccessLoading, setIsAccessLoading, }: { - user: UserType; userString: string; + pubkey: string; feed: FC | undefined; isFollowLoading: boolean; setIsFollowLoading: (b: boolean) => void; isAccessLoading: boolean; setIsAccessLoading: (b: boolean) => void; }) { - const { api } = useLocalState((s) => ({ + const { api, addNotification, lastFact } = useLocalState((s) => ({ api: s.api, + addNotification: s.addNotification, + lastFact: s.lastFact, })); const [fc, setFC] = useState<FC>(); + useEffect(() => { + if (!lastFact) return; + if (!("nostr" in lastFact)) return; + if ("user" in lastFact.nostr) { + const feed = eventsToFc(lastFact.nostr.user); + setFC(feed); + } else if ("event" in lastFact.nostr) { + const ev = lastFact.nostr.event; + if (ev.kind === 1 && ev.pubkey === pubkey) { + const f = feed || fc; + if (!f) return; + const nf = addEventToFc(ev, f); + setFC(nf); + } + } + }, [lastFact]); // Show empty state with resync option when no feed data async function refetch() { @@ -39,6 +57,7 @@ export default function NostrUser({ setIsFollowLoading(true); try { + const user = { nostr: pubkey }; if (feed) { await api.unfollow(user); } else { @@ -55,26 +74,19 @@ export default function NostrUser({ if (!api) return; setIsAccessLoading(true); - // try { - // const res = await api.peekFeed(user.urbit); - // toast.success(`Access request sent to ${user.urbit}`); - // addNotification({ - // type: "access_request", - // from: userString, - // message: `Access request sent to ${userString}`, - // }); - // if ("error" in res) toast.error(res.error); - // else { - // console.log("peeked", res.ok.feed); - // setFC(res.ok.feed); - // if (res.ok.profile) addProfile(userString, res.ok.profile); - // } - // } catch (error) { - // toast.error(`Failed to request access from ${user.urbit}`); - // console.error("Access request error:", error); - // } finally { - // setIsAccessLoading(false); - // } + try { + await api.nostrFeed(pubkey); + addNotification({ + type: "fetching_nostr", + from: userString, + message: `Fetching nostr feed from ${userString}`, + }); + } catch (error) { + toast.error(`Failed to request access from ${user.urbit}`); + console.error("Access request error:", error); + } finally { + setIsAccessLoading(false); + } } return ( <> diff --git a/gui/src/components/post/Body.tsx b/gui/src/components/post/Body.tsx index b4f1bb2..ca5aa6e 100644 --- a/gui/src/components/post/Body.tsx +++ b/gui/src/components/post/Body.tsx @@ -6,7 +6,6 @@ import type { Media as MediaType, ExternalContent, } from "@/types/trill"; -import Icon from "@/components/Icon"; import type { PostProps } from "./Post"; import Media from "./Media"; import JSONContent, { YoutubeSnippet } from "./External"; @@ -15,6 +14,7 @@ import Quote from "./Quote"; import PostData from "./Loader"; import Card from "./Card.tsx"; import type { Ship } from "@/types/urbit.ts"; +import { extractURLs } from "@/logic/nostrill.ts"; function Body(props: PostProps) { const text = props.poast.contents.filter((c) => { @@ -95,41 +95,63 @@ function TextBlock({ block }: { block: Block }) { ) ) : null; } + function Inlin({ i }: { i: Inline }) { const [_, navigate] = useLocation(); function gotoShip(e: React.MouseEvent, ship: Ship) { e.stopPropagation(); navigate(`/feed/${ship}`); } - return "text" in i ? ( - <span>{i.text}</span> - ) : "italic" in i ? ( - <i>{i.italic}</i> - ) : "bold" in i ? ( - <strong>{i.bold}</strong> - ) : "strike" in i ? ( - <span>{i.strike}</span> - ) : "underline" in i ? ( - <span>{i.underline}</span> - ) : "sup" in i ? ( - <sup>{i.sup}</sup> - ) : "sub" in i ? ( - <sub>{i.sub}</sub> - ) : "ship" in i ? ( - <span - className="mention" - role="link" - onMouseUp={(e) => gotoShip(e, i.ship)} - > - {i.ship} - </span> - ) : "codespan" in i ? ( - <code>{i.codespan}</code> - ) : "link" in i ? ( - <LinkParser {...i.link} /> - ) : "break" in i ? ( - <br /> - ) : null; + if ("text" in i) { + const tokens = extractURLs(i.text); + return ( + <> + {tokens.text.map((t, i) => + "text" in t ? ( + <span key={t.text + i}>{t.text}</span> + ) : ( + <a key={t.link.href + i} href={t.link.href}> + {t.link.show} + </a> + ), + )} + {tokens.pics.map((p, i) => ( + <img key={p + i} src={p} /> + ))} + {tokens.vids.map((p, i) => ( + <video key={p + i} src={p} controls /> + ))} + </> + ); + } else { + return "italic" in i ? ( + <i>{i.italic}</i> + ) : "bold" in i ? ( + <strong>{i.bold}</strong> + ) : "strike" in i ? ( + <span>{i.strike}</span> + ) : "underline" in i ? ( + <span>{i.underline}</span> + ) : "sup" in i ? ( + <sup>{i.sup}</sup> + ) : "sub" in i ? ( + <sub>{i.sub}</sub> + ) : "ship" in i ? ( + <span + className="mention" + role="link" + onMouseUp={(e) => gotoShip(e, i.ship)} + > + {i.ship} + </span> + ) : "codespan" in i ? ( + <code>{i.codespan}</code> + ) : "link" in i ? ( + <LinkParser {...i.link} /> + ) : "break" in i ? ( + <br /> + ) : null; + } } function LinkParser({ href, show }: { href: string; show: string }) { diff --git a/gui/src/components/post/Footer.tsx b/gui/src/components/post/Footer.tsx index 41752fc..d4732ce 100644 --- a/gui/src/components/post/Footer.tsx +++ b/gui/src/components/post/Footer.tsx @@ -7,9 +7,10 @@ import { displayCount } from "@/logic/utils"; import { TrillReactModal, stringToReact } from "./Reactions"; import toast from "react-hot-toast"; import NostrIcon from "./wrappers/NostrIcon"; +import type { SPID } from "@/types/ui"; // TODO abstract this somehow -function Footer({ poast, refetch }: PostProps) { +function Footer({ user, poast, refetch }: PostProps) { const [_showMenu, setShowMenu] = useState(false); const [location, navigate] = useLocation(); const [reposting, _setReposting] = useState(false); @@ -22,11 +23,16 @@ function Footer({ poast, refetch }: PostProps) { }), ); const our = api!.airlock.our!; + function getComposerData(): SPID { + return "urbit" in user + ? { trill: poast } + : { nostr: { post: poast, pubkey: user.nostr, eventId: poast.hash } }; + } function doReply(e: React.MouseEvent) { console.log("do reply"); e.stopPropagation(); e.preventDefault(); - setComposerData({ type: "reply", post: { trill: poast } }); + setComposerData({ type: "reply", post: getComposerData() }); // Scroll to top where composer is located window.scrollTo({ top: 0, behavior: "smooth" }); // Focus will be handled by the composer component @@ -36,7 +42,7 @@ function Footer({ poast, refetch }: PostProps) { e.preventDefault(); setComposerData({ type: "quote", - post: { trill: poast }, + post: getComposerData(), }); // Scroll to top where composer is located window.scrollTo({ top: 0, behavior: "smooth" }); @@ -50,7 +56,7 @@ function Footer({ poast, refetch }: PostProps) { async function cancelRP(e: React.MouseEvent) { e.stopPropagation(); e.preventDefault(); - const r = await api!.deletePost(poast.host, poast.id); + const r = await api!.deletePost(user, poast.id); if (r) toast.success("Repost deleted"); // refetch(); if (location.includes(poast.id)) navigate("/"); @@ -59,8 +65,8 @@ function Footer({ poast, refetch }: PostProps) { // TODO update backend because contents are only markdown now e.stopPropagation(); e.preventDefault(); - const pid = { ship: poast.host, id: poast.id }; - const r = await api!.addRP(pid); + const id = "urbit" in user ? poast.id : poast.hash; + const r = await api!.addRP(user, id); if (r) { toast.success("Your repost was published"); } diff --git a/gui/src/components/post/Header.tsx b/gui/src/components/post/Header.tsx index 5898eba..21c4f6c 100644 --- a/gui/src/components/post/Header.tsx +++ b/gui/src/components/post/Header.tsx @@ -13,13 +13,16 @@ function Header(props: PostProps) { function openThread(e: React.MouseEvent) { e.stopPropagation(); const sel = window.getSelection()?.toString(); - if (!sel) navigate(`/t/${poast.host}/${poast.id}`); + const id = "urbit" in props.user ? poast.id : poast.hash; + if (!sel) navigate(`/t/${poast.host}/${id}`); } const { poast } = props; const name = profile ? ( profile.name + ) : "urbit" in props.user ? ( + <p className="p-only">{props.user.urbit}</p> ) : ( - <p className="p-only">{poast.author}</p> + <p className="p-only">{props.user.nostr}</p> ); return ( <header> diff --git a/gui/src/components/post/Post.tsx b/gui/src/components/post/Post.tsx index 7413e70..9df1993 100644 --- a/gui/src/components/post/Post.tsx +++ b/gui/src/components/post/Post.tsx @@ -9,10 +9,11 @@ import RP from "./RP"; import UserModal from "../modals/UserModal"; import type { Ship } from "@/types/urbit"; import Sigil from "../Sigil"; -import type { UserProfile } from "@/types/nostrill"; +import type { UserProfile, UserType } from "@/types/nostrill"; export interface PostProps { poast: Poast; + user: UserType; fake?: boolean; rter?: Ship; rtat?: number; @@ -52,12 +53,18 @@ function TrillPost(props: PostProps) { const [_, navigate] = useLocation(); function openThread(_e: React.MouseEvent) { const sel = window.getSelection()?.toString(); - if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + const id = "urbit" in props.user ? poast.id : poast.hash; + const path = `/t/${poast.host}/${id}`; + if (poast.hash.includes("000000")) { + console.log("bad hash", poast); + return; + } + if (!sel) navigate(path); } function openModal(e: React.MouseEvent) { e.stopPropagation(); - setModal(<UserModal userString={poast.author} />); + setModal(<UserModal user={props.user} />); } const avatar = profile ? ( <div className="avatar sigil cp" role="link" onMouseUp={openModal}> diff --git a/gui/src/components/post/PostWrapper.tsx b/gui/src/components/post/PostWrapper.tsx deleted file mode 100644 index c4e754f..0000000 --- a/gui/src/components/post/PostWrapper.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import useLocalState from "@/state/state"; -import type { NostrPost, PostWrapper } from "@/types/nostrill"; - -export default Post; -function Post(pw: PostWrapper) { - if ("nostr" in pw) return <NostrPost post={pw.nostr} />; - else return <TrillPost post={pw.urbit.post} nostr={pw.urbit.nostr} />; -} - -function NostrPost({ post, event, relay }: NostrPost) { - const { profiles } = useLocalState(); - const profile = profiles.get(event.pubkey); - return <></>; -} diff --git a/gui/src/components/post/wrappers/Nostr.tsx b/gui/src/components/post/wrappers/Nostr.tsx index 2782fb8..7e96354 100644 --- a/gui/src/components/post/wrappers/Nostr.tsx +++ b/gui/src/components/post/wrappers/Nostr.tsx @@ -1,4 +1,4 @@ -import type { NostrMetadata, NostrPost } from "@/types/nostrill"; +import type { NostrPost } from "@/types/nostrill"; import Post from "../Post"; import useLocalState from "@/state/state"; @@ -7,9 +7,15 @@ function NostrPost({ data }: { data: NostrPost }) { const { profiles } = useLocalState((s) => ({ profiles: s.profiles })); const profile = profiles.get(data.event.pubkey); - return <Post poast={data.post} profile={profile} />; + return ( + <Post + user={{ urbit: data.post.author }} + poast={data.post} + profile={profile} + /> + ); } -export function NostrSnippet({ eventId, pubkey, relay }: NostrMetadata) { - return <div>wtf</div>; +export function NostrSnippet({ eventId, pubkey, relay, post }: NostrPost) { + return <Post user={{ nostr: pubkey }} poast={post} />; } diff --git a/gui/src/components/profile/Profile.tsx b/gui/src/components/profile/Profile.tsx index ab65a7b..a554696 100644 --- a/gui/src/components/profile/Profile.tsx +++ b/gui/src/components/profile/Profile.tsx @@ -3,6 +3,7 @@ import type { UserProfile, UserType } from "@/types/nostrill"; import useLocalState from "@/state/state"; import Avatar from "../Avatar"; import ProfileEditor from "./Editor"; +import { ProfValue } from "../modals/UserModal"; interface Props { user: UserType; @@ -15,8 +16,9 @@ const Loader: React.FC<Props> = (props) => { const { profiles } = useLocalState((s) => ({ profiles: s.profiles, })); - const profile = profiles.get(props.userString); - console.log({ profiles }); + const { user } = props; + const userString2 = "urbit" in user ? user.urbit : user.nostr; + const profile = profiles.get(userString2); if (props.isMe) return <ProfileEditor {...props} profile={profile} />; else return <Profile profile={profile} {...props} />; @@ -63,17 +65,10 @@ function Profile({ {customFields.map(([key, value], index) => { if (key.toLocaleLowerCase() === "banner") return null; - const isURL = URL.parse(value); return ( <div key={index} className="custom-field-view"> <span className="field-key">{key}:</span> - {isURL ? ( - <a className="field-value" href={value} target="_blank"> - {value} - </a> - ) : ( - <span className="field-value">{value}</span> - )} + <ProfValue value={value} /> </div> ); })} diff --git a/gui/src/components/trill/Thread.tsx b/gui/src/components/trill/Thread.tsx new file mode 100644 index 0000000..a56ccf1 --- /dev/null +++ b/gui/src/components/trill/Thread.tsx @@ -0,0 +1,219 @@ +import useLocalState from "@/state/state"; +import Icon from "@/components/Icon"; +import spinner from "@/assets/triangles.svg"; +import Post from "@/components/post/Post"; +import { extractThread, toFlat } from "@/logic/trill/helpers"; +import type { FC, FullNode, Poast } from "@/types/trill"; +import Composer from "@/components/composer/Composer"; +import type { UserProfile } from "@/types/nostrill"; +import type { Ship } from "@/types/urbit"; +import { useEffect, useState } from "react"; + +export default function Thread({ + host, + id, + feed, + profile, +}: { + host: Ship; + id: string; + feed?: FC; + profile?: UserProfile; +}) { + const poast = feed?.feed[id]; + console.log({ poast }); + return ( + <> + <div className="thread-header"> + <div className="thread-nav"> + <button + className="back-btn" + onClick={() => window.history.back()} + title="Go back" + > + <Icon name="reply" size={16} /> + <span>Back to Feed</span> + </button> + </div> + <h2>Thread</h2> + <div className="thread-info"> + <span className="thread-host">~{host}</span> + <span className="thread-separator">•</span> + <span className="thread-id">#{id}</span> + </div> + </div> + <div id="feed-proper"> + {poast && poast.children.length === 0 ? ( + <Head poast={poast} profile={profile} /> + ) : ( + <Loader poast={poast} host={host} id={id} profile={profile} /> + )} + </div> + </> + ); +} +function Loader({ + host, + id, + profile, + poast, +}: { + host: Ship; + id: string; + poast?: Poast; + profile?: UserProfile; +}) { + const api = useLocalState((s) => s.api); + const [data, setData] = useState<FullNode>(); + const [error, setError] = useState(""); + console.log({ data }); + async function fetchThread() { + const res = await api!.scryThread(host, id); + if ("error" in res) setError(res.error); + else setData(res.ok); + } + useEffect(() => { + fetchThread(); + }, [host, id]); + + if (data) + return ( + <> + <Head poast={toFlat(data)} profile={profile} /> + <div id="thread-children"> + <ChildTree node={data} /> + </div> + </> + ); + if (poast) + return ( + <> + <Head poast={poast} profile={profile} /> + <div id="thread-children"> + <h2>Loading Replies...</h2> + <div className="loading-container"> + <img className="x-center" src={spinner} alt="Loading" /> + </div> + </div> + </> + ); + if (error) + return ( + <div className="thread-header"> + <h2>Error Loading Thread</h2> + <p className="error">{error}</p> + </div> + ); + else + return ( + <div id="feed-proper"> + <h2>Loading Thread...</h2> + <div className="loading-container"> + <img className="x-center" src={spinner} alt="Loading" /> + </div> + </div> + ); +} + +function Head({ poast, profile }: { poast: Poast; profile?: UserProfile }) { + return ( + <div id="thread-head"> + <Post user={{ urbit: poast.host }} poast={poast} profile={profile} /> + </div> + ); +} + +function ChildTree({ node }: { node: FullNode }) { + const profiles = useLocalState((s) => s.profiles); + const kids = Object.values(node.children || {}); + kids.sort((a, b) => b.time - a.time); + return ( + <> + {kids.map((k) => { + const profile = profiles.get(k.author); + return ( + <div key={k.id} className="minithread"> + <Post + user={{ urbit: k.author }} + profile={profile} + poast={toFlat(k)} + /> + <Grandchildren node={k} /> + </div> + ); + })} + </> + ); + function Grandchildren({ node }: { node: FullNode }) { + return ( + <div className="tail"> + <ChildTree node={node} /> + </div> + ); + } +} +// function ChildTree({ node }: { node: FullNode }) { +// const { threadChildren, replies } = extractThread(node); +// return ( +// <> +// <div id="tail"> +// {threadChildren.map((n) => { +// return ( +// <Post user={{ urbit: n.author }} key={n.id} poast={toFlat(n)} /> +// ); +// })} +// </div> +// <div id="replies"> +// {replies.map((n) => ( +// <ReplyThread key={n.id} node={n} /> +// ))} +// </div> +// </> +// ); +// } + +// function ReplyThread({ node }: { node: FullNode }) { +// // const { threadChildren, replies } = extractThread(node); +// const { replies } = extractThread(node); +// return ( +// <div className="trill-reply-thread"> +// <div className="head"> +// <Post user={{ urbit: node.author }} poast={toFlat(node)} /> +// </div> +// <div className="tail"> +// {replies.map((r) => ( +// <Post key={r.id} user={{ urbit: r.author }} poast={toFlat(r)} /> +// ))} +// </div> +// </div> +// ); +// } + +// function OwnData(props: Props) { +// const { api } = useLocalState((s) => ({ +// api: s.api, +// })); +// const { host, id } = props; +// async function fetchThread() { +// return await api!.scryThread(host, id); +// } +// const { isLoading, isError, data, refetch } = useQuery({ +// queryKey: ["trill-thread", host, id], +// queryFn: fetchThread, +// }); +// return isLoading ? ( +// <div className={props.className}> +// <div className="x-center not-found"> +// <p className="x-center">Scrying Post, please wait...</p> +// <img src={spinner} className="x-center s-100" alt="" /> +// </div> +// </div> +// ) : null; +// } +// function SomeoneElses(props: Props) { +// // const { api, following } = useLocalState((s) => ({ +// // api: s.api, +// // following: s.following, +// // })); +// return <div>ho</div>; +// } diff --git a/gui/src/components/trill/User.tsx b/gui/src/components/trill/User.tsx index b7b53d6..488b925 100644 --- a/gui/src/components/trill/User.tsx +++ b/gui/src/components/trill/User.tsx @@ -6,19 +6,17 @@ import Icon from "@/components/Icon"; import toast from "react-hot-toast"; import { useEffect, useState } from "react"; import type { FC } from "@/types/trill"; -import type { UserType } from "@/types/nostrill"; +import type { Ship } from "@/types/urbit"; function UserFeed({ - user, - userString, + patp, feed, isFollowLoading, setIsFollowLoading, isAccessLoading, setIsAccessLoading, }: { - user: UserType; - userString: string; + patp: Ship; feed: FC | undefined; isFollowLoading: boolean; setIsFollowLoading: (b: boolean) => void; @@ -40,27 +38,29 @@ function UserFeed({ console.log("fact", lastFact); console.log(isFollowLoading); if (!isFollowLoading) return; - const follow = lastFact?.fols; + if (!lastFact) return; + if (!("fols" in lastFact)) return; + const follow = lastFact.fols; if (!follow) return; if ("new" in follow) { - if (userString !== follow.new.user) return; - toast.success(`Now following ${userString}`); + if (patp !== follow.new.user) return; + toast.success(`Now following ${patp}`); setIsFollowLoading(false); addNotification({ type: "follow", - from: userString, - message: `You are now following ${userString}`, + from: patp, + message: `You are now following ${patp}`, }); } else if ("quit" in follow) { - toast.success(`Unfollowed ${userString}`); + toast.success(`Unfollowed ${patp}`); setIsFollowLoading(false); addNotification({ type: "unfollow", - from: userString, - message: `You unfollowed ${userString}`, + from: patp, + message: `You unfollowed ${patp}`, }); } - }, [lastFact, userString, isFollowLoading]); + }, [lastFact, patp, isFollowLoading]); const handleFollow = async () => { if (!api) return; @@ -68,13 +68,13 @@ function UserFeed({ setIsFollowLoading(true); try { if (!!feed) { - await api.unfollow(user); + await api.unfollow({ urbit: patp }); } else { - await api.follow(user); - toast.success(`Follow request sent to ${userString}`); + await api.follow({ urbit: patp }); + toast.success(`Follow request sent to ${patp}`); } } catch (error) { - toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${userString}`); + toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${patp}`); setIsFollowLoading(false); console.error("Follow error:", error); } @@ -82,31 +82,29 @@ function UserFeed({ const handleRequestAccess = async () => { if (!api) return; - if (!("urbit" in user)) return; - setIsAccessLoading(true); try { - const res = await api.peekFeed(user.urbit); - toast.success(`Access request sent to ${user.urbit}`); + const res = await api.peekFeed(patp); + toast.success(`Access request sent to ${patp}`); addNotification({ type: "access_request", - from: userString, - message: `Access request sent to ${userString}`, + from: patp, + message: `Access request sent to ${patp}`, }); if ("error" in res) toast.error(res.error); else { console.log("peeked", res.ok.feed); setFC(res.ok.feed); - if (res.ok.profile) addProfile(userString, res.ok.profile); + if (res.ok.profile) addProfile(patp, res.ok.profile); } } catch (error) { - toast.error(`Failed to request access from ${user.urbit}`); + toast.error(`Failed to request access from ${patp}`); console.error("Access request error:", error); } finally { setIsAccessLoading(false); } }; - console.log({ user, userString, feed, fc }); + console.log({ patp, feed, fc }); return ( <> @@ -149,15 +147,9 @@ function UserFeed({ </div> {feed && hasFeed ? ( - <div id="feed-proper"> - <Composer /> - <PostList data={feed} refetch={refetch} /> - </div> + <Inner feed={feed} refetch={refetch} /> ) : fc ? ( - <div id="feed-proper"> - <Composer /> - <PostList data={fc} refetch={refetch} /> - </div> + <Inner feed={fc} refetch={refetch} /> ) : null} {!feed && !fc && ( @@ -178,3 +170,12 @@ function UserFeed({ } export default UserFeed; + +export function Inner({ feed, refetch }: { feed: FC; refetch: any }) { + return ( + <div id="feed-proper"> + <Composer /> + <PostList data={feed} refetch={refetch} /> + </div> + ); +} 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 diff --git a/gui/src/pages/Feed.tsx b/gui/src/pages/Feed.tsx index bb001d4..16a5ea1 100644 --- a/gui/src/pages/Feed.tsx +++ b/gui/src/pages/Feed.tsx @@ -8,13 +8,13 @@ import { useState } from "react"; import Composer from "@/components/composer/Composer"; import { ErrorPage } from "@/pages/Error"; import NostrFeed from "@/components/nostr/Feed"; +import { consolidateFeeds, disaggregate } from "@/logic/nostrill"; -type FeedType = "global" | "following" | "nostr"; +type FeedType = "urbit" | "following" | "nostr"; function Loader() { const params = useParams(); - console.log({ params }); if (!params.taip) return <FeedPage t="nostr" />; - if (params.taip === "global") return <FeedPage t={"global"} />; + // if (params.taip === "urbit") return <FeedPage t={"urbit"} />; if (params.taip === "following") return <FeedPage t={"following"} />; if (params.taip === "nostr") return <FeedPage t={"nostr"} />; // else if (param === FeedType.Rumors) return <Rumors />; @@ -27,10 +27,10 @@ function FeedPage({ t }: { t: FeedType }) { <> <div id="top-tabs"> <div - className={active === "global" ? "active" : ""} - onClick={() => setActive("global")} + className={active === "urbit" ? "active" : ""} + onClick={() => setActive("urbit")} > - Global + Urbit </div> <div className={active === "following" ? "active" : ""} @@ -47,8 +47,8 @@ function FeedPage({ t }: { t: FeedType }) { </div> <div id="feed-proper"> <Composer /> - {active === "global" ? ( - <Global /> + {active === "urbit" ? ( + <Urbit /> ) : active === "following" ? ( <Following /> ) : active === "nostr" ? ( @@ -58,40 +58,22 @@ function FeedPage({ t }: { t: FeedType }) { </> ); } -// {active === "global" ? ( -// <Global /> -// ) : active === "following" ? ( -// <Global /> -// ) : ( -// <Global /> -// )} -function Global() { - // const { api } = useLocalState(); - // const { isPending, data, refetch } = useQuery({ - // queryKey: ["globalFeed"], - // queryFn: () => { - // return api!.scryFeed(null, null); - // }, - // }); - // console.log(data, "scry feed data"); - // if (isPending) return <img className="x-center" src={spinner} />; - // else if ("bucun" in data) return <p>Error</p>; - // else return <Inner data={data} refetch={refetch} />; - return <p>Error</p>; +function Urbit() { + const following = useLocalState((s) => s.following); + const feed = disaggregate(following, "urbit"); + return ( + <div> + <PostList data={feed} refetch={() => {}} /> + </div> + ); } function Following() { - const following = useLocalState((s) => s.following2); - console.log({ following }); - - // console.log(data, "scry feed data"); - // if (isPending) return <img className="x-center" src={spinner} />; - // else if ("bucun" in data) return <p>Error</p>; - // else return <Inner data={data} refetch={refetch} />; - + const following = useLocalState((s) => s.following); + const feed = consolidateFeeds(following); return ( <div> - <PostList data={following} refetch={() => {}} /> + <PostList data={feed} refetch={() => {}} /> </div> ); } diff --git a/gui/src/pages/Thread.tsx b/gui/src/pages/Thread.tsx index fc215f2..a3d2234 100644 --- a/gui/src/pages/Thread.tsx +++ b/gui/src/pages/Thread.tsx @@ -1,167 +1,52 @@ import { useParams } from "wouter"; -import { useQuery } from "@tanstack/react-query"; import useLocalState from "@/state/state"; -import Icon from "@/components/Icon"; -import spinner from "@/assets/triangles.svg"; import { ErrorPage } from "@/pages/Error"; import "@/styles/trill.css"; import "@/styles/feed.css"; -import Post from "@/components/post/Post"; -import { extractThread, toFlat } from "@/logic/trill/helpers"; -import type { FullNode } from "@/types/trill"; -import Composer from "@/components/composer/Composer"; +import { stringToUser } from "@/logic/nostrill"; +import TrillThread from "@/components/trill/Thread"; +import NostrThread from "@/components/nostr/Thread"; +import { decodeNostrKey } from "@/logic/nostr"; + +export default function ThreadLoader() { + const { profiles, following } = useLocalState((s) => ({ + profiles: s.profiles, + following: s.following, + })); -export default function Thread() { const params = useParams<{ host: string; id: string }>(); const { host, id } = params; - const { api } = useLocalState((s) => ({ api: s.api })); - - async function fetchThread() { - return await api!.scryThread(host, id); - } - const { isPending, data, error } = useQuery({ - queryKey: ["thread", params.host, params.id], - queryFn: fetchThread, - enabled: !!api && !!params.host && !!params.id, - }); - - console.log({ data }); - if (!params.host || !params.id) { - return <ErrorPage msg="Invalid thread URL" />; - } - if (isPending) { + const uuser = stringToUser(host); + if ("error" in uuser) return <ErrorPage msg={uuser.error} />; + const feed = following.get(host); + const profile = profiles.get(host); + if ("urbit" in uuser.ok) return ( - <main> - <div className="thread-header"> - <h2>Loading Thread...</h2> - </div> - <div className="loading-container"> - <img className="x-center" src={spinner} alt="Loading" /> - </div> - </main> + <TrillThread + feed={feed} + profile={profile} + host={uuser.ok.urbit} + id={id} + /> ); - } - - if (error) { + if ("nostr" in uuser.ok) return ( - <main> - <div className="thread-header"> - <h2>Error Loading Thread</h2> - </div> - <ErrorPage msg={error.message || "Failed to load thread"} /> - </main> + <NostrThread + feed={feed} + profile={profile} + host={uuser.ok.nostr} + id={id} + /> ); - } - - if (!data || "error" in data) { - return ( - <main> - <div className="thread-header"> - <h2>Thread Not Found</h2> - </div> - <ErrorPage - msg={data?.error || "This thread doesn't exist or isn't accessible"} - /> - </main> - ); - } - console.log({ data }); - // TODO make Composer a modal when in Thread mode - return ( - <main> - <div className="thread-header"> - <div className="thread-nav"> - <button - className="back-btn" - onClick={() => window.history.back()} - title="Go back" - > - <Icon name="reply" size={16} /> - <span>Back to Feed</span> - </button> - </div> - <h2>Thread</h2> - <div className="thread-info"> - <span className="thread-host">~{params.host}</span> - <span className="thread-separator">•</span> - <span className="thread-id">#{params.id}</span> - </div> - </div> - - <div id="feed-proper"> - <Composer /> - <div id="thread-head"> - <Post poast={toFlat(data.ok)} /> - </div> - <div id="thread-children"> - <ChildTree node={data.ok} /> - </div> - </div> - </main> - ); + else return <ErrorPage msg="weird" />; } -function ChildTree({ node }: { node: FullNode }) { - const { threadChildren, replies } = extractThread(node); - return ( - <> - <div id="tail"> - {threadChildren.map((n) => { - return <Post key={n.id} poast={toFlat(n)} />; - })} - </div> - <div id="replies"> - {replies.map((n) => ( - <ReplyThread key={n.id} node={n} /> - ))} - </div> - </> - ); +export function NostrThreadLoader() { + const params = useParams<{ id: string }>(); + const { id } = params; + if (!id) return <ErrorPage msg="No thread id passed" />; + const dec = decodeNostrKey(id); + if (!dec) return <ErrorPage msg="Unknown thread id format" />; + return <NostrThread id={dec} host="" />; } - -function ReplyThread({ node }: { node: FullNode }) { - // const { threadChildren, replies } = extractThread(node); - const { replies } = extractThread(node); - return ( - <div className="trill-reply-thread"> - <div className="head"> - <Post poast={toFlat(node)} /> - </div> - <div className="tail"> - {replies.map((r) => ( - <Post key={r.id} poast={toFlat(r)} /> - ))} - </div> - </div> - ); -} - -// function OwnData(props: Props) { -// const { api } = useLocalState((s) => ({ -// api: s.api, -// })); -// const { host, id } = props; -// async function fetchThread() { -// return await api!.scryThread(host, id); -// } -// const { isLoading, isError, data, refetch } = useQuery({ -// queryKey: ["trill-thread", host, id], -// queryFn: fetchThread, -// }); -// return isLoading ? ( -// <div className={props.className}> -// <div className="x-center not-found"> -// <p className="x-center">Scrying Post, please wait...</p> -// <img src={spinner} className="x-center s-100" alt="" /> -// </div> -// </div> -// ) : null; -// } -// function SomeoneElses(props: Props) { -// // const { api, following } = useLocalState((s) => ({ -// // api: s.api, -// // following: s.following, -// // })); -// return <div>ho</div>; -// } diff --git a/gui/src/pages/User.tsx b/gui/src/pages/User.tsx index 1611037..80fff05 100644 --- a/gui/src/pages/User.tsx +++ b/gui/src/pages/User.tsx @@ -1,18 +1,12 @@ -// import spinner from "@/assets/icons/spinner.svg"; -import Composer from "@/components/composer/Composer"; -import PostList from "@/components/feed/PostList"; import Profile from "@/components/profile/Profile"; import useLocalState, { useStore } from "@/state/state"; -import Icon from "@/components/Icon"; -import toast from "react-hot-toast"; -import { useEffect, useState } from "react"; -import type { FC } from "@/types/trill"; +import { useState } from "react"; import type { UserType } from "@/types/nostrill"; import { isValidPatp } from "urbit-ob"; import { ErrorPage } from "@/pages/Error"; import { useParams } from "wouter"; -import { isValidNostrKey } from "@/logic/nostr"; -import TrillFeed from "@/components/trill/User"; +import { decodeNostrKey } from "@/logic/nostr"; +import TrillFeed, { Inner } from "@/components/trill/User"; import NostrFeed from "@/components/nostr/User"; function UserLoader() { @@ -22,9 +16,12 @@ function UserLoader() { if (!userString) return <ErrorPage msg="no such user" />; else if (isValidPatp(userString)) return <UserFeed user={{ urbit: userString }} userString={userString} />; - else if (isValidNostrKey(userString)) - return <UserFeed user={{ nostr: userString }} userString={userString} />; - else return <ErrorPage msg="no such user" />; + else { + const nostrKey = decodeNostrKey(userString); + if (nostrKey) + return <UserFeed user={{ nostr: nostrKey }} userString={userString} />; + else return <ErrorPage msg="no such user" />; + } } function UserFeed({ @@ -49,9 +46,8 @@ function UserFeed({ : false; // auto updating on SSE doesn't work if we do shallow const { following } = useStore(); - const feed = following.get(userString); - const hasFeed = !feed ? false : Object.entries(feed).length > 0; - const refetch = () => feed; + const userString2 = "urbit" in user ? user.urbit : user.nostr; + const feed = following.get(userString2); const [isFollowLoading, setIsFollowLoading] = useState(false); const [isAccessLoading, setIsAccessLoading] = useState(false); @@ -60,11 +56,10 @@ function UserFeed({ <div id="user-page"> <Profile user={user} userString={userString} isMe={isMe} /> {isMe ? ( - <MyFeed /> + <MyFeed our={api!.airlock.our!} /> ) : "urbit" in user ? ( <TrillFeed - user={user} - userString={userString} + patp={user.urbit} feed={feed} isFollowLoading={isFollowLoading} setIsFollowLoading={setIsFollowLoading} @@ -73,7 +68,7 @@ function UserFeed({ /> ) : "nostr" in user ? ( <NostrFeed - user={user} + pubkey={user.nostr} userString={userString} feed={feed} isFollowLoading={isFollowLoading} @@ -88,6 +83,9 @@ function UserFeed({ export default UserLoader; -function MyFeed() { - return <></>; +function MyFeed({ our }: { our: string }) { + const following = useLocalState((s) => s.following); + const feed = following.get(our); + if (!feed) return <ErrorPage msg="Critical error" />; + return <Inner feed={feed} refetch={() => {}} />; } diff --git a/gui/src/state/state.ts b/gui/src/state/state.ts index 69633e3..3b88151 100644 --- a/gui/src/state/state.ts +++ b/gui/src/state/state.ts @@ -3,7 +3,7 @@ import { start } from "@/logic/api"; import IO from "@/logic/requests/nostrill"; import type { ComposerData } from "@/types/ui"; import { create } from "zustand"; -import type { UserProfile } from "@/types/nostrill"; +import type { Fact, Relays, UserProfile } from "@/types/nostrill"; import type { Event } from "@/types/nostr"; import type { FC, Poast } from "@/types/trill"; import type { Notification } from "@/types/notifications"; @@ -22,7 +22,7 @@ export type LocalState = { setComposerData: (c: ComposerData | null) => void; pubkey: string; nostrFeed: Event[]; - relays: Record<string, Event[]>; + relays: Relays; profiles: Map<string, UserProfile>; // pubkey key addProfile: (key: string, u: UserProfile) => void; following: Map<string, FC>; @@ -37,7 +37,7 @@ export type LocalState = { markNotificationRead: (id: string) => void; markAllNotificationsRead: () => void; clearNotifications: () => void; - lastFact: any; + lastFact: Fact | null; }; const creator = create<LocalState>(); @@ -49,8 +49,8 @@ export const useStore = creator((set, get) => ({ const api = new IO(airlock); console.log({ api }); await api.subscribeStore((data) => { - console.log("store sub", data); if ("state" in data) { + console.log("state", data.state); const { feed, nostr, following, following2, relays, profiles, pubkey } = data.state; const flwing = new Map(Object.entries(following as Record<string, FC>)); @@ -64,23 +64,24 @@ export const useStore = creator((set, get) => ({ pubkey, }); } else if ("fact" in data) { - set({ lastFact: data.fact }); - if ("fols" in data.fact) { + const fact: Fact = data.fact; + set({ lastFact: fact }); + if ("fols" in fact) { const { following, profiles } = get(); - if ("new" in data.fact.fols) { - const { user, feed, profile } = data.fact.fols.new; + if ("new" in fact.fols) { + const { user, feed, profile } = fact.fols.new; following.set(user, feed); if (profile) profiles.set(user, profile); set({ following, profiles }); } - if ("quit" in data.fact.fols) { - following.delete(data.fact.fols.quit); + if ("quit" in fact.fols) { + following.delete(fact.fols.quit); set({ following }); } } - if ("post" in data.fact) { - if ("add" in data.fact.post) { - const post: Poast = data.fact.post.add.post; + if ("post" in fact) { + if ("add" in fact.post) { + const post: Poast = fact.post.add.post; const following = get().following; const curr = following.get(post.author); const fc = curr ? curr : { feed: {}, start: null, end: null }; @@ -90,11 +91,26 @@ export const useStore = creator((set, get) => ({ set({ following }); } } - if ("nostr" in data.fact) { - if ("feed" in data.fact.nostr) - set({ nostrFeed: data.fact.nostr.feed }); - if ("relays" in data.fact.nostr) - set({ relays: data.fact.nostr.relays }); + if ("nostr" in fact) { + console.log("nostr fact", fact); + if ("feed" in fact.nostr) set({ nostrFeed: fact.nostr.feed }); + if ("relays" in fact.nostr) set({ relays: fact.nostr.relays }); + if ("event" in fact.nostr) { + // console.log("san event", fact.nostr.event); + const event: Event = fact.nostr.event; + if (event.kind === 1) { + const nostrFeed = get().nostrFeed; + set({ nostrFeed: [...nostrFeed, event] }); + } + if (event.kind === 0) { + const profiles = get().profiles; + const data = JSON.parse(event.content); + const { name, picture, about, ...other } = data; + const prof = { name, picture, about, other }; + const np = profiles.set(event.pubkey, prof); + set({ profiles: np }); + } + } // if ("user" in data.fact.nostr) // if ("thread" in data.fact.nostr) } diff --git a/gui/src/styles/Composer.css b/gui/src/styles/Composer.css new file mode 100644 index 0000000..4fc7739 --- /dev/null +++ b/gui/src/styles/Composer.css @@ -0,0 +1,173 @@ +#composer { + padding: 16px; + display: flex; + gap: 0.75rem; + transition: all 0.3s ease; + border-bottom: 1px solid rgba(128, 128, 128, 0.2); + width: 100%; + + &.expanded { + padding: 20px 16px; + background: linear-gradient(to bottom, rgba(128, 128, 128, 0.05), transparent); + } + + &.has-context { + min-height: 120px; + } + + & .sigil { + width: 48px; + height: 48px; + flex-shrink: 0; + + & img { + width: inherit; + border-radius: 50%; + } + } + + & .composer-content { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 12px; + } + + & .composer-context { + background: rgba(128, 128, 128, 0.08); + border-radius: 12px; + padding: 12px; + position: relative; + animation: slideDown 0.3s ease; + + & .composer-snippet { + /* TODO it overflows to the right */ + max-height: 200px; + overflow-y: auto; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + + + .trill-post { + /* width: 60%; */ + /* margin: 0 auto; */ + } + } + + & #reply { + background: transparent; + padding: 0; + } + } + + & .reply-context { + margin-bottom: 12px; + border-left: 3px solid var(--color-accent, #2a9d8f); + background: rgba(42, 157, 143, 0.08); + } + + & .quote-context { + margin-top: 12px; + border-left: 3px solid var(--color-secondary, #e76f51); + background: rgba(231, 111, 81, 0.08); + } + + & .quote-header { + margin-bottom: 12px; + padding: 8px 12px; + background: rgba(231, 111, 81, 0.08); + border-radius: 8px; + border-left: 3px solid var(--color-secondary, #e76f51); + } + + & .context-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + & .context-type { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--color-text-muted, #888); + font-weight: 500; + } + + & .clear-context { + background: none; + border: none; + color: var(--color-text-muted, #888); + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; + + &:hover { + background: rgba(128, 128, 128, 0.2); + color: var(--color-text); + } + } + } + + & .quote-header .context-header { + margin-bottom: 0; + } + + & .composer-input-row { + display: flex; + gap: 12px; + align-items: center; + } + + & input { + background-color: transparent; + color: var(--color-text); + flex-grow: 1; + border: none; + outline: none; + font-size: 1rem; + padding: 8px 0; + border-bottom: 2px solid transparent; + transition: border-color 0.2s; + + &:focus { + border-bottom-color: var(--color-accent, #2a9d8f); + } + + &::placeholder { + color: var(--color-text-muted, #888); + } + } + + & .post-btn { + padding: 8px 20px; + background: var(--color-accent, #2a9d8f); + color: white; + border: none; + border-radius: 20px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + + &:hover:not(:disabled) { + background: var(--color-accent-hover, #238b7f); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(42, 157, 143, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +}
\ No newline at end of file diff --git a/gui/src/styles/UserModal.css b/gui/src/styles/UserModal.css index e976b38..09fae13 100644 --- a/gui/src/styles/UserModal.css +++ b/gui/src/styles/UserModal.css @@ -4,9 +4,9 @@ display: flex; flex-direction: column; gap: 20px; - min-width: 400px; - max-width: 500px; - padding: 0; + /* min-width: 400px; */ + /* max-width: 500px; */ + padding: 1rem; overflow: hidden; } diff --git a/gui/src/styles/feed.css b/gui/src/styles/feed.css index 02d64db..f4c1050 100644 --- a/gui/src/styles/feed.css +++ b/gui/src/styles/feed.css @@ -88,7 +88,7 @@ border: 1px solid var(--color-border); } -.resync-btn-small { +.btn-small { display: flex; align-items: center; justify-content: center; @@ -101,12 +101,12 @@ color: var(--color-text); } -.resync-btn-small:hover:not(:disabled) { +.btn-small:hover:not(:disabled) { background: var(--color-surface-hover); border-color: var(--color-primary); } -.resync-btn-small:disabled { +.btn-small:disabled { opacity: 0.6; cursor: not-allowed; } diff --git a/gui/src/styles/styles.css b/gui/src/styles/styles.css index 41b3c4d..d9fc06c 100644 --- a/gui/src/styles/styles.css +++ b/gui/src/styles/styles.css @@ -152,8 +152,12 @@ t .red { /* styles */ /* common */ -html { +* { + box-sizing: border-box; +} + +html { color: var(--text-color); background-color: var(--background-color); } @@ -162,7 +166,8 @@ html, body, #root, #mobile-ui { - height: 100%; + height: 100vh; + max-height: 100vh; width: 100vw; overflow: hidden; /* no scrolling!!!*/ @@ -171,7 +176,7 @@ body, *, *:before, *:after { - box-sizing: inherit; + box-sizing: border-box; } body { @@ -201,8 +206,7 @@ h6 { } #root { - margin: 1rem 2rem; - height: 100%; + padding: 1rem 2rem; overflow-y: auto; font-family: "Inter"; @@ -211,6 +215,11 @@ h6 { & #left-menu { margin-right: 1rem; + height: 100%; + position: sticky; + left: 0; + top: 2%; + #logo { display: flex; @@ -242,8 +251,9 @@ h6 { & main { width: 726px; - margin: auto; - height: 100vh; + margin: 0 auto; + height: 95%; + /* i hate this */ & #top-tabs { display: flex; @@ -264,176 +274,8 @@ h6 { margin-top: 1rem; border: 1px solid grey; border-radius: 0.75rem; - - & #composer { - padding: 16px; - display: flex; - gap: 0.75rem; - transition: all 0.3s ease; - border-bottom: 1px solid rgba(128, 128, 128, 0.2); - - &.expanded { - padding: 20px 16px; - background: linear-gradient(to bottom, rgba(128, 128, 128, 0.05), transparent); - } - - &.has-context { - min-height: 120px; - } - - & .sigil { - width: 48px; - height: 48px; - flex-shrink: 0; - - & img { - width: inherit; - border-radius: 50%; - } - } - - & .composer-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 12px; - } - - & .composer-context { - background: rgba(128, 128, 128, 0.08); - border-radius: 12px; - padding: 12px; - position: relative; - animation: slideDown 0.3s ease; - - & .composer-snippet { - max-height: 200px; - overflow-y: auto; - border-radius: 8px; - background: rgba(255, 255, 255, 0.05); - - &>div { - padding: 8px; - } - } - - & #reply { - background: transparent; - padding: 0; - } - } - - & .reply-context { - margin-bottom: 12px; - border-left: 3px solid var(--color-accent, #2a9d8f); - background: rgba(42, 157, 143, 0.08); - } - - & .quote-context { - margin-top: 12px; - border-left: 3px solid var(--color-secondary, #e76f51); - background: rgba(231, 111, 81, 0.08); - } - - & .quote-header { - margin-bottom: 12px; - padding: 8px 12px; - background: rgba(231, 111, 81, 0.08); - border-radius: 8px; - border-left: 3px solid var(--color-secondary, #e76f51); - } - - & .context-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; - - & .context-type { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.85rem; - color: var(--color-text-muted, #888); - font-weight: 500; - } - - & .clear-context { - background: none; - border: none; - color: var(--color-text-muted, #888); - cursor: pointer; - font-size: 1.5rem; - line-height: 1; - padding: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: all 0.2s; - - &:hover { - background: rgba(128, 128, 128, 0.2); - color: var(--color-text); - } - } - } - - & .quote-header .context-header { - margin-bottom: 0; - } - - & .composer-input-row { - display: flex; - gap: 12px; - align-items: center; - } - - & input { - background-color: transparent; - color: var(--color-text); - flex-grow: 1; - border: none; - outline: none; - font-size: 1rem; - padding: 8px 0; - border-bottom: 2px solid transparent; - transition: border-color 0.2s; - - &:focus { - border-bottom-color: var(--color-accent, #2a9d8f); - } - - &::placeholder { - color: var(--color-text-muted, #888); - } - } - - & .post-btn { - padding: 8px 20px; - background: var(--color-accent, #2a9d8f); - color: white; - border: none; - border-radius: 20px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; - - &:hover:not(:disabled) { - background: var(--color-accent-hover, #238b7f); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(42, 157, 143, 0.3); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - } + height: 100%; + overflow-y: auto; @keyframes slideDown { from { @@ -581,8 +423,8 @@ h6 { } .body { - max-height: 300px; - overflow-y: auto; + max-height: 500px; + overflow-y: hidden; } & footer { diff --git a/gui/src/styles/trill.css b/gui/src/styles/trill.css index 0b1650c..dc08f48 100644 --- a/gui/src/styles/trill.css +++ b/gui/src/styles/trill.css @@ -264,6 +264,10 @@ } /* threads */ +#thread-head { + font-size: 1.3rem; +} + .trill-reply-thread { margin-top: 1rem; } @@ -272,6 +276,16 @@ border-top: 1px solid black; } +.minithread { + margin-top: 1rem; + + .tail { + .trill-post { + border: unset !important; + } + } +} + /* footer */ .footer-wrapper { diff --git a/gui/src/types/nostrill.ts b/gui/src/types/nostrill.ts index 5ce033c..13ec942 100644 --- a/gui/src/types/nostrill.ts +++ b/gui/src/types/nostrill.ts @@ -1,13 +1,14 @@ import type { NostrEvent } from "./nostr"; -import type { Poast } from "./trill"; +import type { FC, Poast } from "./trill"; export type UserType = { urbit: string } | { nostr: string }; export type UserProfile = { name: string; picture: string; // URL about: string; - other: Record<string, string>; + other: Record<string, any>; }; +export type DateObj = { month: number; day: number; year?: number }; export type PostWrapper = | { nostr: NostrPost } @@ -18,7 +19,43 @@ export type NostrPost = { post: Poast; }; export type NostrMetadata = { - pubkey?: string; + pubkey: string; eventId: string; relay?: string; + post: Poast; +}; +export type Relays = Record<string, RelayStats>; +export type RelayStats = { + start: number; + wid: number; + reqs: Record<string, number>; }; + +export type Fact = + | { nostr: NostrFact } + | { post: PostFact } + | { enga: EngaFact } + | { fols: FolsFact } + | { hark: Notification }; + +export type NostrFact = + | { feed: NostrEvent[] } + | { user: NostrEvent[] } + | { thread: NostrEvent[] } + | { event: NostrEvent } + | { relays: Relays }; + +export type PostFact = { add: { post: Poast } } | { del: { post: Poast } }; + +export type EngaFact = { add: NostrEvent[] } | { del: NostrEvent[] }; + +export type FolsFact = + | { new: { user: string; feed: FC; profile: UserProfile } } + | { quit: string }; + +export type Notification = + | { prof: NostrEvent[] } + | { fols: NostrEvent[] } + | { beg: NostrEvent[] } + | { fans: NostrEvent[] } + | { post: NostrEvent[] }; diff --git a/gui/src/types/notifications.ts b/gui/src/types/notifications.ts index 760702a..4d0bd3f 100644 --- a/gui/src/types/notifications.ts +++ b/gui/src/types/notifications.ts @@ -1,6 +1,6 @@ import type { Ship } from "./urbit"; -export type NotificationType = +export type NotificationType = | "follow" | "unfollow" | "mention" @@ -8,7 +8,9 @@ export type NotificationType = | "repost" | "react" | "access_request" - | "access_granted"; + | "access_granted" + | "fetching_nostr" + | "nostr_fetch_success"; export interface Notification { id: string; @@ -25,4 +27,4 @@ export interface Notification { export interface NotificationState { notifications: Notification[]; unreadCount: number; -}
\ No newline at end of file +} diff --git a/gui/src/types/trill.ts b/gui/src/types/trill.ts index a2fccc2..6d67b66 100644 --- a/gui/src/types/trill.ts +++ b/gui/src/types/trill.ts @@ -45,6 +45,7 @@ export type Poast = { contents: Content; id: string; time: number; // not in the backend + hash: string; children: ID[]; engagement: Engagement; tlonRumor?: boolean; diff --git a/gui/src/types/ui.ts b/gui/src/types/ui.ts index 4596236..b80b4ca 100644 --- a/gui/src/types/ui.ts +++ b/gui/src/types/ui.ts @@ -1,4 +1,4 @@ -import type { NostrMetadata } from "./nostrill"; +import type { NostrMetadata, NostrPost } from "./nostrill"; import type { Poast } from "./trill"; import type { Tweet } from "./twatter"; import type { Ship } from "./urbit"; |
