summaryrefslogtreecommitdiff
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
parente6e657be3a3b1dae426b46f3bc16f9a5cf4861c2 (diff)
Big GUI improvements on Nostr rendering and fetchingpolwex/iris
-rw-r--r--gui/bun.lock3
-rw-r--r--gui/package.json1
-rw-r--r--gui/src/Router.tsx7
-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
-rw-r--r--gui/src/logic/cache.ts222
-rw-r--r--gui/src/logic/constants.ts12
-rw-r--r--gui/src/logic/hooks.ts173
-rw-r--r--gui/src/logic/nostr.ts30
-rw-r--r--gui/src/logic/nostrill.ts155
-rw-r--r--gui/src/logic/requests/nostrill.ts65
-rw-r--r--gui/src/logic/trill/helpers.ts186
-rw-r--r--gui/src/pages/Feed.tsx56
-rw-r--r--gui/src/pages/Thread.tsx187
-rw-r--r--gui/src/pages/User.tsx40
-rw-r--r--gui/src/state/state.ts52
-rw-r--r--gui/src/styles/Composer.css173
-rw-r--r--gui/src/styles/UserModal.css6
-rw-r--r--gui/src/styles/feed.css6
-rw-r--r--gui/src/styles/styles.css200
-rw-r--r--gui/src/styles/trill.css14
-rw-r--r--gui/src/types/nostrill.ts43
-rw-r--r--gui/src/types/notifications.ts8
-rw-r--r--gui/src/types/trill.ts1
-rw-r--r--gui/src/types/ui.ts2
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";