diff options
| author | polwex <polwex@sortug.com> | 2025-11-19 05:47:30 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-11-19 05:47:30 +0700 |
| commit | 74d84cb2f22600b6246343e9ea606cf0db7517f0 (patch) | |
| tree | 0d68285c8e74e6543645e17ab2751d543c1ff9a6 /gui/src/components | |
| parent | e6e657be3a3b1dae426b46f3bc16f9a5cf4861c2 (diff) | |
Big GUI improvements on Nostr rendering and fetchingpolwex/iris
Diffstat (limited to 'gui/src/components')
| -rw-r--r-- | gui/src/components/Avatar.tsx | 4 | ||||
| -rw-r--r-- | gui/src/components/composer/Composer.tsx | 77 | ||||
| -rw-r--r-- | gui/src/components/feed/PostList.tsx | 13 | ||||
| -rw-r--r-- | gui/src/components/layout/Sidebar.tsx | 2 | ||||
| -rw-r--r-- | gui/src/components/modals/UserModal.tsx | 121 | ||||
| -rw-r--r-- | gui/src/components/nostr/Feed.tsx | 53 | ||||
| -rw-r--r-- | gui/src/components/nostr/Thread.tsx | 174 | ||||
| -rw-r--r-- | gui/src/components/nostr/User.tsx | 62 | ||||
| -rw-r--r-- | gui/src/components/post/Body.tsx | 82 | ||||
| -rw-r--r-- | gui/src/components/post/Footer.tsx | 18 | ||||
| -rw-r--r-- | gui/src/components/post/Header.tsx | 7 | ||||
| -rw-r--r-- | gui/src/components/post/Post.tsx | 13 | ||||
| -rw-r--r-- | gui/src/components/post/PostWrapper.tsx | 14 | ||||
| -rw-r--r-- | gui/src/components/post/wrappers/Nostr.tsx | 14 | ||||
| -rw-r--r-- | gui/src/components/profile/Profile.tsx | 15 | ||||
| -rw-r--r-- | gui/src/components/trill/Thread.tsx | 219 | ||||
| -rw-r--r-- | gui/src/components/trill/User.tsx | 71 |
17 files changed, 725 insertions, 234 deletions
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> + ); +} |
