summaryrefslogtreecommitdiff
path: root/gui/src/pages
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-10-06 10:13:39 +0700
committerpolwex <polwex@sortug.com>2025-10-06 10:13:39 +0700
commit8751ba26ebf7b7761b9e237f2bf3453623dd1018 (patch)
treedc37f12b3fd9b1a1e7a1b54a51c80697f37a04e8 /gui/src/pages
parent6704650dcfccf609ccc203308df9004e0b511bb6 (diff)
added frontend WS connection for demonstration purposes
Diffstat (limited to 'gui/src/pages')
-rw-r--r--gui/src/pages/Feed.tsx182
-rw-r--r--gui/src/pages/Settings.tsx255
-rw-r--r--gui/src/pages/Thread.tsx127
-rw-r--r--gui/src/pages/User.tsx212
4 files changed, 776 insertions, 0 deletions
diff --git a/gui/src/pages/Feed.tsx b/gui/src/pages/Feed.tsx
new file mode 100644
index 0000000..66acc66
--- /dev/null
+++ b/gui/src/pages/Feed.tsx
@@ -0,0 +1,182 @@
+// import spinner from "@/assets/icons/spinner.svg";
+import "@/styles/trill.css";
+import "@/styles/feed.css";
+import UserLoader from "./User";
+import PostList from "@/components/feed/PostList";
+import useLocalState from "@/state/state";
+import { useParams } from "wouter";
+import spinner from "@/assets/triangles.svg";
+import { useState } from "react";
+import Composer from "@/components/composer/Composer";
+import Icon from "@/components/Icon";
+import toast from "react-hot-toast";
+import { eventsToFc } from "@/logic/nostrill";
+import { ErrorPage } from "@/Router";
+
+type FeedType = "global" | "following" | "nostr";
+function Loader() {
+ // const { api } = useLocalState();
+ const params = useParams();
+ console.log({ params });
+ // const [loc, navigate] = useLocation();
+ // console.log({ loc });
+ // const our = api!.airlock.ship;
+ if (params.taip === "global") return <FeedPage t={"global"} />;
+ if (params.taip === "nostr") return <FeedPage t={"nostr"} />;
+ // else if (param === FeedType.Rumors) return <Rumors />;
+ // else if (param === FeedType.Home) return <UserFeed p={our} />;
+ else if (params.taip) return <UserLoader userString={params.taip!} />;
+ else return <ErrorPage msg="No such page" />;
+}
+function FeedPage({ t }: { t: FeedType }) {
+ const [active, setActive] = useState<FeedType>(t);
+ return (
+ <main>
+ <div id="top-tabs">
+ <div
+ className={active === "global" ? "active" : ""}
+ onClick={() => setActive("global")}
+ >
+ Global
+ </div>
+ <div
+ className={active === "following" ? "active" : ""}
+ onClick={() => setActive("following")}
+ >
+ Following
+ </div>
+ <div
+ className={active === "nostr" ? "active" : ""}
+ onClick={() => setActive("nostr")}
+ >
+ Nostr
+ </div>
+ </div>
+ <div id="feed-proper">
+ <Composer />
+ {active === "global" ? (
+ <Global />
+ ) : active === "following" ? (
+ <Global />
+ ) : active === "nostr" ? (
+ <Nostr />
+ ) : null}
+ </div>
+ </main>
+ );
+}
+// {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 Nostr() {
+ const { nostrFeed, api } = useLocalState((s) => ({
+ nostrFeed: s.nostrFeed,
+ api: s.api,
+ }));
+ const [isSyncing, setIsSyncing] = useState(false);
+ const feed = eventsToFc(nostrFeed);
+ console.log({ feed });
+ const refetch = () => feed;
+
+ const handleResync = async () => {
+ 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);
+ }
+ };
+
+ // Show empty state with resync option when no feed data
+ if (!feed || !feed.feed || Object.keys(feed.feed).length === 0) {
+ return (
+ <div className="nostr-empty-state">
+ <div className="empty-content">
+ <Icon name="nostr" size={48} color="textMuted" />
+ <h3>No Nostr Posts</h3>
+ <p>
+ Your Nostr feed appears to be empty. Try syncing with your relays to
+ fetch the latest posts.
+ </p>
+ <button
+ onClick={handleResync}
+ disabled={isSyncing}
+ className="resync-btn"
+ >
+ {isSyncing ? (
+ <>
+ <img src={spinner} alt="Loading" className="btn-spinner" />
+ Syncing...
+ </>
+ ) : (
+ <>
+ <Icon name="settings" size={16} />
+ Sync Relays
+ </>
+ )}
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ // Show feed with resync button in header
+ return (
+ <div className="nostr-feed">
+ <div className="nostr-header">
+ <div className="feed-info">
+ <h4>Nostr Feed</h4>
+ <span className="post-count">
+ {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>
+ <PostList data={feed} refetch={refetch} />
+ </div>
+ );
+}
+
+export default Loader;
+// TODO
+type MixFeed = any;
+
+function Inner({ data, refetch }: { data: MixFeed; refetch: Function }) {
+ return <PostList data={data.mix.fc} refetch={refetch} />;
+}
diff --git a/gui/src/pages/Settings.tsx b/gui/src/pages/Settings.tsx
new file mode 100644
index 0000000..abf0022
--- /dev/null
+++ b/gui/src/pages/Settings.tsx
@@ -0,0 +1,255 @@
+import useLocalState from "@/state/state";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { ThemeSwitcher } from "@/styles/ThemeSwitcher";
+import Icon from "@/components/Icon";
+import "@/styles/Settings.css";
+import WebSocketWidget from "@/components/WsWidget";
+
+function Settings() {
+ const { key, relays, api, addNotification } = useLocalState((s) => ({
+ key: s.pubkey,
+ relays: s.relays,
+ api: s.api,
+ addNotification: s.addNotification,
+ }));
+ const [newRelay, setNewRelay] = useState("");
+ const [isAddingRelay, setIsAddingRelay] = useState(false);
+ const [isCyclingKey, setIsCyclingKey] = useState(false);
+
+ async function removeRelay(url: string) {
+ try {
+ await api?.deleteRelay(url);
+ toast.success("Relay removed");
+ } catch (error) {
+ toast.error("Failed to remove relay");
+ console.error("Remove relay error:", error);
+ }
+ }
+
+ async function addNewRelay() {
+ if (!newRelay.trim()) {
+ toast.error("Please enter a relay URL");
+ return;
+ }
+
+ setIsAddingRelay(true);
+ try {
+ const valid = ["wss:", "ws:"];
+ const url = new URL(newRelay);
+ if (!valid.includes(url.protocol)) {
+ toast.error("Invalid Relay URL - must use wss:// or ws://");
+ return;
+ }
+
+ await api?.addRelay(newRelay);
+ toast.success("Relay added");
+ setNewRelay("");
+ } catch (error) {
+ toast.error("Invalid relay URL or failed to add relay");
+ console.error("Add relay error:", error);
+ } finally {
+ setIsAddingRelay(false);
+ }
+ }
+
+ async function cycleKey() {
+ setIsCyclingKey(true);
+ try {
+ await api?.cycleKeys();
+ toast.success("Key cycled successfully");
+ } catch (error) {
+ toast.error("Failed to cycle key");
+ console.error("Cycle key error:", error);
+ } finally {
+ setIsCyclingKey(false);
+ }
+ }
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ addNewRelay();
+ }
+ };
+
+ return (
+ <div className="settings-page">
+ <div className="settings-header">
+ <h1>Settings</h1>
+ <p>Manage your Nostrill configuration and preferences</p>
+ </div>
+
+ <div className="settings-content">
+ <WebSocketWidget url="ws://localhost:8090/nostrill-ui" />
+ {/* Notifications Test Section - Remove in production */}
+ <div className="settings-section">
+ <div className="section-header">
+ <Icon name="bell" size={20} />
+ <h2>Test Notifications</h2>
+ </div>
+ <div className="section-content">
+ <div className="setting-item">
+ <div className="setting-info">
+ <label>Test Notification System</label>
+ <p>Generate test notifications to see how they work</p>
+ </div>
+ <div className="setting-control">
+ <button
+ className="test-notification-btn"
+ onClick={() => {
+ const types = [
+ "follow",
+ "reply",
+ "react",
+ "mention",
+ "access_request",
+ ];
+ const randomType = types[
+ Math.floor(Math.random() * types.length)
+ ] as any;
+ addNotification({
+ type: randomType,
+ from: "~sampel-palnet",
+ message: "This is a test notification",
+ reaction: randomType === "react" ? "👍" : undefined,
+ });
+ toast.success("Test notification sent!");
+ }}
+ >
+ <Icon name="bell" size={16} />
+ Send Test Notification
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Appearance Section */}
+ <div className="settings-section">
+ <div className="section-header">
+ <Icon name="settings" size={20} />
+ <h2>Appearance</h2>
+ </div>
+ <div className="section-content">
+ <div className="setting-item">
+ <div className="setting-info">
+ <label>Theme</label>
+ <p>Choose your preferred color theme</p>
+ </div>
+ <div className="setting-control">
+ <ThemeSwitcher />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Identity Section */}
+ <div className="settings-section">
+ <div className="section-header">
+ <Icon name="key" size={20} />
+ <h2>Identity</h2>
+ </div>
+ <div className="section-content">
+ <div className="setting-item">
+ <div className="setting-info">
+ <label>Nostr Public Key</label>
+ <p>Your unique identifier on the Nostr network</p>
+ </div>
+ <div className="setting-control">
+ <div className="key-display">
+ <code className="pubkey">{key || "No key generated"}</code>
+ <button
+ onClick={cycleKey}
+ disabled={isCyclingKey}
+ className="cycle-btn"
+ title="Generate new key pair"
+ >
+ {isCyclingKey ? (
+ <Icon name="settings" size={16} />
+ ) : (
+ <Icon name="settings" size={16} />
+ )}
+ {isCyclingKey ? "Cycling..." : "Cycle Key"}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Nostr Relays Section */}
+ <div className="settings-section">
+ <div className="section-header">
+ <Icon name="nostr" size={20} />
+ <h2>Nostr Relays</h2>
+ </div>
+ <div className="section-content">
+ <div className="setting-item">
+ <div className="setting-info">
+ <label>Connected Relays</label>
+ <p>Manage your Nostr relay connections</p>
+ </div>
+ <div className="setting-control">
+ <div className="relay-list">
+ {Object.keys(relays).length === 0 ? (
+ <div className="no-relays">
+ <Icon name="nostr" size={24} color="textMuted" />
+ <p>No relays configured</p>
+ </div>
+ ) : (
+ Object.keys(relays).map((url) => (
+ <div key={url} className="relay-item">
+ <div className="relay-info">
+ <span className="relay-url">{url}</span>
+ <span className="relay-status">Connected</span>
+ </div>
+ <button
+ onClick={() => removeRelay(url)}
+ className="remove-relay-btn"
+ title="Remove relay"
+ >
+ ×
+ </button>
+ </div>
+ ))
+ )}
+
+ <div className="add-relay-form">
+ <div className="relay-input-group">
+ <input
+ type="text"
+ value={newRelay}
+ onChange={(e) => setNewRelay(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="wss://relay.example.com"
+ className="relay-input"
+ />
+ <button
+ onClick={addNewRelay}
+ disabled={isAddingRelay || !newRelay.trim()}
+ className="add-relay-btn"
+ >
+ {isAddingRelay ? (
+ <>
+ <Icon name="settings" size={16} />
+ Adding...
+ </>
+ ) : (
+ <>
+ <Icon name="settings" size={16} />
+ Add Relay
+ </>
+ )}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+export default Settings;
diff --git a/gui/src/pages/Thread.tsx b/gui/src/pages/Thread.tsx
new file mode 100644
index 0000000..8296f07
--- /dev/null
+++ b/gui/src/pages/Thread.tsx
@@ -0,0 +1,127 @@
+import { useParams } from "wouter";
+import { useQuery } from "@tanstack/react-query";
+import useLocalState from "@/state/state";
+import PostList from "@/components/feed/PostList";
+import Composer from "@/components/composer/Composer";
+import Icon from "@/components/Icon";
+import spinner from "@/assets/triangles.svg";
+import { ErrorPage } from "@/Router";
+import "@/styles/trill.css";
+import "@/styles/feed.css";
+import Post from "@/components/post/Post";
+import { toFlat } from "@/logic/trill/helpers";
+
+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, refetch } = 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) {
+ 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>
+ );
+ }
+
+ if (error) {
+ return (
+ <main>
+ <div className="thread-header">
+ <h2>Error Loading Thread</h2>
+ </div>
+ <ErrorPage msg={error.message || "Failed to load thread"} />
+ </main>
+ );
+ }
+
+ 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>
+ );
+ }
+
+ 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 className="thread-content">
+ <Post poast={toFlat(data.ok)} />
+ </div>
+ </div>
+ </main>
+ );
+}
+// 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
new file mode 100644
index 0000000..b73cd96
--- /dev/null
+++ b/gui/src/pages/User.tsx
@@ -0,0 +1,212 @@
+// 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 type { UserType } from "@/types/nostrill";
+import { isValidPatp } from "urbit-ob";
+import { isValidNostrPubkey } from "@/logic/nostrill";
+import { ErrorPage } from "@/Router";
+
+function UserLoader({ userString }: { userString: string }) {
+ const { api, pubkey } = useLocalState((s) => ({
+ api: s.api,
+ pubkey: s.pubkey,
+ }));
+ // auto updating on SSE doesn't work if we do shallow
+
+ const user = isValidPatp(userString)
+ ? { urbit: userString }
+ : isValidNostrPubkey(userString)
+ ? { nostr: userString }
+ : { error: "" };
+
+ const isOwnProfile =
+ "urbit" in user
+ ? user.urbit === api?.airlock.our
+ : "nostr" in user
+ ? pubkey === user.nostr
+ : false;
+ if ("error" in user) return <ErrorPage msg={"Invalid user"} />;
+ else
+ return <UserFeed user={user} userString={userString} isMe={isOwnProfile} />;
+}
+
+function UserFeed({
+ user,
+ userString,
+ isMe,
+}: {
+ user: UserType;
+ userString: string;
+ isMe: boolean;
+}) {
+ const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({
+ api: s.api,
+ addProfile: s.addProfile,
+ addNotification: s.addNotification,
+ lastFact: s.lastFact,
+ }));
+ // 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 isFollowing = following.has(userString);
+
+ const [isFollowLoading, setIsFollowLoading] = useState(false);
+ const [isAccessLoading, setIsAccessLoading] = useState(false);
+ const [fc, setFC] = useState<FC>();
+
+ useEffect(() => {
+ console.log("fact", lastFact);
+ console.log(isFollowLoading);
+ if (!isFollowLoading) return;
+ const follow = lastFact?.fols;
+ if (!follow) return;
+ if ("new" in follow) {
+ if (userString !== follow.new.user) return;
+ toast.success(`Now following ${userString}`);
+ setIsFollowLoading(false);
+ addNotification({
+ type: "follow",
+ from: userString,
+ message: `You are now following ${userString}`,
+ });
+ } else if ("quit" in follow) {
+ toast.success(`Unfollowed ${userString}`);
+ setIsFollowLoading(false);
+ addNotification({
+ type: "unfollow",
+ from: userString,
+ message: `You unfollowed ${userString}`,
+ });
+ }
+ }, [lastFact, userString, isFollowLoading]);
+
+ const handleFollow = async () => {
+ if (!api) return;
+
+ setIsFollowLoading(true);
+ try {
+ if (isFollowing) {
+ await api.unfollow(user);
+ } else {
+ await api.follow(user);
+ toast.success(`Follow request sent to ${userString}`);
+ }
+ } catch (error) {
+ toast.error(
+ `Failed to ${isFollowing ? "unfollow" : "follow"} ${userString}`,
+ );
+ setIsFollowLoading(false);
+ console.error("Follow error:", error);
+ }
+ };
+
+ 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}`);
+ 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);
+ }
+ };
+ console.log({ user, userString, feed, fc });
+
+ return (
+ <div id="user-page">
+ <Profile user={user} userString={userString} isMe={isMe} />
+
+ {!isMe && (
+ <div className="user-actions">
+ <button
+ onClick={handleFollow}
+ disabled={isFollowLoading}
+ className={`action-btn ${isFollowing ? "" : "follow"}`}
+ >
+ {isFollowLoading ? (
+ <>
+ <Icon name="settings" size={16} />
+ {isFollowing ? "Unfollowing..." : "Following..."}
+ </>
+ ) : (
+ <>
+ <Icon name={isFollowing ? "bell" : "pals"} size={16} />
+ {isFollowing ? "Unfollow" : "Follow"}
+ </>
+ )}
+ </button>
+
+ <button
+ onClick={handleRequestAccess}
+ disabled={isAccessLoading}
+ className="action-btn access"
+ >
+ {isAccessLoading ? (
+ <>
+ <Icon name="settings" size={16} />
+ Requesting...
+ </>
+ ) : (
+ <>
+ <Icon name="key" size={16} />
+ Request Access
+ </>
+ )}
+ </button>
+ </div>
+ )}
+
+ {feed && hasFeed ? (
+ <div id="feed-proper">
+ <Composer />
+ <PostList data={feed} refetch={refetch} />
+ </div>
+ ) : fc ? (
+ <div id="feed-proper">
+ <Composer />
+ <PostList data={fc} refetch={refetch} />
+ </div>
+ ) : null}
+
+ {!isMe && !feed && !fc && (
+ <div id="other-user-feed">
+ <div className="empty-feed-message">
+ <Icon name="messages" size={48} color="textMuted" />
+ <h3>No Posts Available</h3>
+ <p>
+ This user's posts are not publicly visible.
+ {!isFollowing && " Try following them"} or request temporary
+ access to see their content.
+ </p>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default UserLoader;