diff options
| author | polwex <polwex@sortug.com> | 2025-10-06 10:13:39 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-10-06 10:13:39 +0700 |
| commit | 8751ba26ebf7b7761b9e237f2bf3453623dd1018 (patch) | |
| tree | dc37f12b3fd9b1a1e7a1b54a51c80697f37a04e8 /gui/src/pages | |
| parent | 6704650dcfccf609ccc203308df9004e0b511bb6 (diff) | |
added frontend WS connection for demonstration purposes
Diffstat (limited to 'gui/src/pages')
| -rw-r--r-- | gui/src/pages/Feed.tsx | 182 | ||||
| -rw-r--r-- | gui/src/pages/Settings.tsx | 255 | ||||
| -rw-r--r-- | gui/src/pages/Thread.tsx | 127 | ||||
| -rw-r--r-- | gui/src/pages/User.tsx | 212 |
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; |
