summaryrefslogtreecommitdiff
path: root/gui/src/components
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-11-19 05:47:30 +0700
committerpolwex <polwex@sortug.com>2025-11-19 05:47:30 +0700
commit74d84cb2f22600b6246343e9ea606cf0db7517f0 (patch)
tree0d68285c8e74e6543645e17ab2751d543c1ff9a6 /gui/src/components
parente6e657be3a3b1dae426b46f3bc16f9a5cf4861c2 (diff)
Big GUI improvements on Nostr rendering and fetchingpolwex/iris
Diffstat (limited to 'gui/src/components')
-rw-r--r--gui/src/components/Avatar.tsx4
-rw-r--r--gui/src/components/composer/Composer.tsx77
-rw-r--r--gui/src/components/feed/PostList.tsx13
-rw-r--r--gui/src/components/layout/Sidebar.tsx2
-rw-r--r--gui/src/components/modals/UserModal.tsx121
-rw-r--r--gui/src/components/nostr/Feed.tsx53
-rw-r--r--gui/src/components/nostr/Thread.tsx174
-rw-r--r--gui/src/components/nostr/User.tsx62
-rw-r--r--gui/src/components/post/Body.tsx82
-rw-r--r--gui/src/components/post/Footer.tsx18
-rw-r--r--gui/src/components/post/Header.tsx7
-rw-r--r--gui/src/components/post/Post.tsx13
-rw-r--r--gui/src/components/post/PostWrapper.tsx14
-rw-r--r--gui/src/components/post/wrappers/Nostr.tsx14
-rw-r--r--gui/src/components/profile/Profile.tsx15
-rw-r--r--gui/src/components/trill/Thread.tsx219
-rw-r--r--gui/src/components/trill/User.tsx71
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>
+ );
+}