summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-11-18 16:59:24 +0700
committerpolwex <polwex@sortug.com>2025-11-18 16:59:24 +0700
commit420a543f8af3075502b0a7530a0fa06af264eb8b (patch)
tree7446a8c59bd4b66ca2bb5ed04bce40d382e31c36
parent76f99af3c98a689441315b5ed087c4b83c083191 (diff)
refactoring gui too, improvements to Nostr user logic
-rw-r--r--gui/src/App.tsx4
-rw-r--r--gui/src/Router.tsx27
-rw-r--r--gui/src/components/layout/Sidebar.tsx20
-rw-r--r--gui/src/components/modals/UserModal.tsx98
-rw-r--r--gui/src/components/nostr/Feed.tsx1
-rw-r--r--gui/src/components/nostr/User.tsx128
-rw-r--r--gui/src/components/post/Header.tsx4
-rw-r--r--gui/src/components/profile/Profile.tsx52
-rw-r--r--gui/src/components/trill/User.tsx180
-rw-r--r--gui/src/logic/nostr.ts14
-rw-r--r--gui/src/pages/Error.tsx60
-rw-r--r--gui/src/pages/Feed.tsx15
-rw-r--r--gui/src/pages/Thread.tsx2
-rw-r--r--gui/src/pages/User.tsx219
-rw-r--r--gui/src/state/state.ts7
-rw-r--r--gui/src/styles/ErrorPage.css156
-rw-r--r--gui/src/styles/Profile.css17
-rw-r--r--gui/src/styles/UserModal.css46
-rw-r--r--gui/src/styles/styles.css1
19 files changed, 776 insertions, 275 deletions
diff --git a/gui/src/App.tsx b/gui/src/App.tsx
index 415cb66..28398e4 100644
--- a/gui/src/App.tsx
+++ b/gui/src/App.tsx
@@ -14,9 +14,8 @@ const queryClient = new QueryClient();
function App() {
const [loading, setLoading] = useState(true);
console.log("NOSTRILL INIT");
- const { init, modal } = useLocalState((s) => ({
+ const { init } = useLocalState((s) => ({
init: s.init,
- modal: s.modal,
}));
useEffect(() => {
init().then((_res: any) => {
@@ -36,7 +35,6 @@ function App() {
<QueryClientProvider client={queryClient}>
{/* {isMobile ? <MobileUI /> : <DesktopUI />} */}
<Router />
- {modal && modal}
<Toaster position="top-center" />
</QueryClientProvider>
</ThemeProvider>
diff --git a/gui/src/Router.tsx b/gui/src/Router.tsx
index ee3aa0d..5026ef0 100644
--- a/gui/src/Router.tsx
+++ b/gui/src/Router.tsx
@@ -1,12 +1,14 @@
import Sidebar from "@/components/layout/Sidebar";
-
-// new
+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 { Switch, Router, Redirect, Route } from "wouter";
+import { P404 } from "./pages/Error";
export default function r() {
+ const modal = useLocalState((s) => s.modal);
return (
<Switch>
<Router base="/apps/nostrill">
@@ -14,26 +16,17 @@ export default function r() {
<main>
<Route path="/" component={toGlobal} />
<Route path="/sets" component={Settings} />
- <Route path="/feed/:taip" component={Feed} />
- <Route path="/feed/:host/:id" component={Thread} />
+ <Route path="/f" component={Feed} />
+ <Route path="/f/:taip" component={Feed} />
+ <Route path="/u/:user" component={User} />
+ <Route path="/t/:host/:id" component={Thread} />
</main>
+ {modal && modal}
</Router>
<Route component={P404} />
</Switch>
);
}
function toGlobal() {
- return <Redirect to="/feed/nostr" />;
-}
-
-export function P404() {
- return <h1 className="x-center">404</h1>;
-}
-export function ErrorPage({ msg }: { msg: string }) {
- return (
- <div>
- <P404 />
- <h3>{msg}</h3>
- </div>
- );
+ return <Redirect to="/f" />;
}
diff --git a/gui/src/components/layout/Sidebar.tsx b/gui/src/components/layout/Sidebar.tsx
index c267e2f..bc11e48 100644
--- a/gui/src/components/layout/Sidebar.tsx
+++ b/gui/src/components/layout/Sidebar.tsx
@@ -7,16 +7,16 @@ import { ThemeSwitcher } from "@/styles/ThemeSwitcher";
function SlidingMenu() {
const [_, navigate] = useLocation();
- const { api, unreadNotifications, setModal } = useLocalState((s) => ({
+ const { api, unreadNotifications, setModal } = useLocalState((s) => ({
api: s.api,
unreadNotifications: s.unreadNotifications,
- setModal: s.setModal
+ setModal: s.setModal,
}));
-
+
function goto(to: string) {
navigate(to);
}
-
+
function openNotifications() {
// We'll create this component next
import("../NotificationCenter").then(({ default: NotificationCenter }) => {
@@ -30,11 +30,15 @@ function SlidingMenu() {
<h3> Nostrill </h3>
</div>
<h3>Feeds</h3>
- <div className="opt" role="link" onClick={() => goto(`/feed/global`)}>
+ <div className="opt" role="link" onClick={() => goto(`/f/global`)}>
<Icon name="home" size={20} />
<div>Home</div>
</div>
- <div className="opt notification-item" role="link" onClick={openNotifications}>
+ <div
+ className="opt notification-item"
+ role="link"
+ onClick={openNotifications}
+ >
<div className="notification-icon-wrapper">
<Icon name="bell" size={20} />
{unreadNotifications > 0 && (
@@ -50,7 +54,7 @@ function SlidingMenu() {
<div
className="opt tbd"
role="link"
- // onClick={() => goto("/chat")}
+ // onClick={() => setModal(<p>lmao</p>)}
>
<Icon name="messages" size={20} />
<div>Messages</div>
@@ -63,7 +67,7 @@ function SlidingMenu() {
<div
className="opt"
role="link"
- onClick={() => goto(`/feed/${api!.airlock.our}`)}
+ onClick={() => goto(`/u/${api!.airlock.our}`)}
>
<Icon name="profile" size={20} />
<div>Profile</div>
diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx
index 0694f1e..aeffc95 100644
--- a/gui/src/components/modals/UserModal.tsx
+++ b/gui/src/components/modals/UserModal.tsx
@@ -1,3 +1,5 @@
+import "@/styles/Profile.css";
+import "@/styles/UserModal.css";
import Modal from "./Modal";
import Avatar from "../Avatar";
import Icon from "@/components/Icon";
@@ -10,14 +12,15 @@ import { generateNprofile } from "@/logic/nostr";
import { useState } from "react";
export default function ({ userString }: { userString: string }) {
- const { setModal, api, pubkey, profiles, following, followers } = useLocalState((s) => ({
- setModal: s.setModal,
- api: s.api,
- pubkey: s.pubkey,
- profiles: s.profiles,
- following: s.following,
- followers: s.followers,
- }));
+ const { setModal, api, pubkey, profiles, following, followers } =
+ useLocalState((s) => ({
+ setModal: s.setModal,
+ api: s.api,
+ pubkey: s.pubkey,
+ profiles: s.profiles,
+ following: s.following,
+ followers: s.followers,
+ }));
const [_, navigate] = useLocation();
const [loading, setLoading] = useState(false);
@@ -64,25 +67,28 @@ export default function ({ userString }: { userString: string }) {
}
async function handleFollow(e: React.MouseEvent) {
+ if ("error" in user) return;
e.stopPropagation();
if (!api) return;
setLoading(true);
try {
if (isFollowing) {
- const result = await api.unfollow(userString);
- if ("ok" in result) {
- toast.success(`Unfollowed ${profile?.name || userString}`);
- } else {
- toast.error(result.error);
- }
+ const result = await api.unfollow(user);
+ console.log(result);
+ // if ("ok" in result) {
+ // toast.success(`Unfollowed ${profile?.name || userString}`);
+ // } else {
+ // toast.error(result.error);
+ // }
} else {
- const result = await api.follow(userString);
- if ("ok" in result) {
- toast.success(`Following ${profile?.name || userString}`);
- } else {
- toast.error(result.error);
- }
+ const result = await api.follow(user);
+ console.log(result);
+ // if ("ok" in result) {
+ // toast.success(`Following ${profile?.name || userString}`);
+ // } else {
+ // toast.error(result.error);
+ // }
}
} catch (err) {
toast.error("Action failed");
@@ -101,9 +107,10 @@ export default function ({ userString }: { userString: string }) {
}
const displayName = profile?.name || ("urbit" in user ? user.urbit : "Anon");
- const truncatedId = userString.length > 20
- ? `${userString.slice(0, 10)}...${userString.slice(-8)}`
- : userString;
+ const truncatedId =
+ userString.length > 20
+ ? `${userString.slice(0, 10)}...${userString.slice(-8)}`
+ : userString;
// Check if a string is a URL
const isURL = (str: string): boolean => {
@@ -111,7 +118,7 @@ export default function ({ userString }: { userString: string }) {
new URL(str);
return true;
} catch {
- return str.startsWith('http://') || str.startsWith('https://');
+ return str.startsWith("http://") || str.startsWith("https://");
}
};
@@ -121,7 +128,7 @@ export default function ({ userString }: { userString: string }) {
// Filter out banner from other fields since we display it separately
const otherFields = profile?.other
? Object.entries(profile.other).filter(
- ([key]) => key.toLowerCase() !== 'banner'
+ ([key]) => key.toLowerCase() !== "banner",
)
: [];
@@ -130,7 +137,7 @@ export default function ({ userString }: { userString: string }) {
<div className="user-modal">
{/* Banner Image */}
{bannerImage && (
- <div className="user-modal-banner">
+ <div className="user-banner">
<img src={bannerImage} alt="Profile banner" />
</div>
)}
@@ -174,7 +181,9 @@ export default function ({ userString }: { userString: string }) {
<span className="badge badge-nostr">Nostr</span>
)}
{itsMe && <span className="badge badge-me">You</span>}
- {isFollower && !itsMe && <span className="badge badge-follows">Follows you</span>}
+ {isFollower && !itsMe && (
+ <span className="badge badge-follows">Follows you</span>
+ )}
</div>
</div>
</div>
@@ -213,7 +222,11 @@ export default function ({ userString }: { userString: string }) {
onClick={(e) => e.stopPropagation()}
>
{value}
- <Icon name="nostr" size={12} className="external-link-icon" />
+ <Icon
+ name="nostr"
+ size={12}
+ className="external-link-icon"
+ />
</a>
) : (
<span className="field-value">{value}</span>
@@ -235,21 +248,20 @@ export default function ({ userString }: { userString: string }) {
{loading ? "..." : isFollowing ? "Following" : "Follow"}
</button>
)}
+ <>
+ <button
+ className="action-btn secondary"
+ onClick={() => {
+ navigate(`/u/${userString}`);
+ close();
+ }}
+ >
+ <Icon name="home" size={16} />
+ View Feed
+ </button>
+ </>
- {"urbit" in user ? (
- <>
- <button
- className="action-btn secondary"
- onClick={() => {
- navigate(`/feed/${userString}`);
- close();
- }}
- >
- <Icon name="home" size={16} />
- View Feed
- </button>
- </>
- ) : (
+ {"nostr" in user ? (
<button
className="action-btn secondary"
onClick={handleAvatarClick}
@@ -257,7 +269,7 @@ export default function ({ userString }: { userString: string }) {
<Icon name="nostr" size={16} />
View on Primal
</button>
- )}
+ ) : null}
</div>
</div>
</Modal>
diff --git a/gui/src/components/nostr/Feed.tsx b/gui/src/components/nostr/Feed.tsx
index 0e74cea..d21307b 100644
--- a/gui/src/components/nostr/Feed.tsx
+++ b/gui/src/components/nostr/Feed.tsx
@@ -14,6 +14,7 @@ export default function Nostr() {
}));
console.log({ relays });
const [isSyncing, setIsSyncing] = useState(false);
+ console.log({ nostrFeed });
const feed = eventsToFc(nostrFeed);
console.log({ feed });
const refetch = () => feed;
diff --git a/gui/src/components/nostr/User.tsx b/gui/src/components/nostr/User.tsx
new file mode 100644
index 0000000..a9e9e2f
--- /dev/null
+++ b/gui/src/components/nostr/User.tsx
@@ -0,0 +1,128 @@
+import useLocalState from "@/state/state";
+import { 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";
+
+export default function NostrUser({
+ user,
+ userString,
+ feed,
+ isFollowLoading,
+ setIsFollowLoading,
+ isAccessLoading,
+ setIsAccessLoading,
+}: {
+ user: UserType;
+ userString: string;
+ feed: FC | undefined;
+ isFollowLoading: boolean;
+ setIsFollowLoading: (b: boolean) => void;
+ isAccessLoading: boolean;
+ setIsAccessLoading: (b: boolean) => void;
+}) {
+ const { api } = useLocalState((s) => ({
+ api: s.api,
+ }));
+ const [fc, setFC] = useState<FC>();
+
+ // Show empty state with resync option when no feed data
+
+ async function refetch() {
+ //
+ }
+ async function handleFollow() {
+ if (!api) return;
+
+ setIsFollowLoading(true);
+ try {
+ if (feed) {
+ await api.unfollow(user);
+ } else {
+ await api.follow(user);
+ toast.success(`Follow request sent to ${userString}`);
+ }
+ } catch (error) {
+ toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${userString}`);
+ setIsFollowLoading(false);
+ console.error("Follow error:", error);
+ }
+ }
+ async function handleRequestAccess() {
+ 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);
+ // }
+ }
+ return (
+ <>
+ <div className="user-actions">
+ <button
+ onClick={handleFollow}
+ disabled={isFollowLoading}
+ className={`action-btn ${!!feed ? "" : "follow"}`}
+ >
+ {isFollowLoading ? (
+ <>
+ <Icon name="settings" size={16} />
+ {!!feed ? "Unfollowing..." : "Following..."}
+ </>
+ ) : (
+ <>
+ <Icon name={!!feed ? "bell" : "pals"} size={16} />
+ {!!feed ? "Unfollow" : "Follow"}
+ </>
+ )}
+ </button>
+
+ {(!feed || !feed.feed || Object.keys(feed.feed).length === 0) && (
+ <button
+ onClick={handleRequestAccess}
+ disabled={isAccessLoading}
+ className="action-btn access"
+ >
+ {isAccessLoading ? (
+ <>
+ <Icon name="settings" size={16} />
+ Fetching...
+ </>
+ ) : (
+ <>
+ <Icon name="key" size={16} />
+ Fetch Feed
+ </>
+ )}
+ </button>
+ )}
+ </div>
+ {(feed || fc) && (
+ <div id="feed-proper">
+ <Composer />
+ <PostList data={(feed || fc)!} refetch={refetch} />
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/gui/src/components/post/Header.tsx b/gui/src/components/post/Header.tsx
index b0822b4..5898eba 100644
--- a/gui/src/components/post/Header.tsx
+++ b/gui/src/components/post/Header.tsx
@@ -8,12 +8,12 @@ function Header(props: PostProps) {
// console.log(props.poast.author.length, "length");
function go(e: React.MouseEvent) {
e.stopPropagation();
- navigate(`/feed/${poast.host}`);
+ navigate(`/u/${poast.host}`);
}
function openThread(e: React.MouseEvent) {
e.stopPropagation();
const sel = window.getSelection()?.toString();
- if (!sel) navigate(`/feed/${poast.host}/${poast.id}`);
+ if (!sel) navigate(`/t/${poast.host}/${poast.id}`);
}
const { poast } = props;
const name = profile ? (
diff --git a/gui/src/components/profile/Profile.tsx b/gui/src/components/profile/Profile.tsx
index b5f22e9..ab65a7b 100644
--- a/gui/src/components/profile/Profile.tsx
+++ b/gui/src/components/profile/Profile.tsx
@@ -16,6 +16,7 @@ const Loader: React.FC<Props> = (props) => {
profiles: s.profiles,
}));
const profile = profiles.get(props.userString);
+ console.log({ profiles });
if (props.isMe) return <ProfileEditor {...props} profile={profile} />;
else return <Profile profile={profile} {...props} />;
@@ -32,31 +33,50 @@ function Profile({
// Initialize state with existing profile or defaults
// View-only mode for other users' profiles - no editing allowed
+ const bannerImage = profile?.other?.banner || profile?.other?.Banner;
const customFields = profile?.other ? Object.entries(profile.other) : [];
return (
- <div className="profile view-mode">
- <div className="profile-picture">
- <Avatar
- user={user}
- userString={userString}
- size={120}
- picOnly={true}
- profile={profile}
- />
+ <div className="profile">
+ {bannerImage && (
+ <div className="user-banner">
+ <img src={bannerImage} alt="Profile banner" />
+ </div>
+ )}
+ <div className="flex items-center gap-4">
+ <div className="profile-picture">
+ <Avatar
+ user={user}
+ userString={userString}
+ size={120}
+ picOnly={true}
+ profile={profile}
+ />
+ </div>
+ <h2 className="text-4xl">{profile?.name || userString}</h2>
</div>
<div className="profile-info">
- <h2>{profile?.name || userString}</h2>
{profile?.about && <p className="profile-about">{profile.about}</p>}
{customFields.length > 0 && (
<div className="profile-custom-fields">
<h4>Additional Info</h4>
- {customFields.map(([key, value], index) => (
- <div key={index} className="custom-field-view">
- <span className="field-key">{key}:</span>
- <span className="field-value">{value}</span>
- </div>
- ))}
+
+ {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>
+ )}
+ </div>
+ );
+ })}
</div>
)}
</div>
diff --git a/gui/src/components/trill/User.tsx b/gui/src/components/trill/User.tsx
new file mode 100644
index 0000000..b7b53d6
--- /dev/null
+++ b/gui/src/components/trill/User.tsx
@@ -0,0 +1,180 @@
+// import spinner from "@/assets/icons/spinner.svg";
+import Composer from "@/components/composer/Composer";
+import PostList from "@/components/feed/PostList";
+import useLocalState 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";
+
+function UserFeed({
+ user,
+ userString,
+ feed,
+ isFollowLoading,
+ setIsFollowLoading,
+ isAccessLoading,
+ setIsAccessLoading,
+}: {
+ user: UserType;
+ userString: string;
+ feed: FC | undefined;
+ isFollowLoading: boolean;
+ setIsFollowLoading: (b: boolean) => void;
+ isAccessLoading: boolean;
+ setIsAccessLoading: (b: boolean) => void;
+}) {
+ const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({
+ api: s.api,
+ addProfile: s.addProfile,
+ addNotification: s.addNotification,
+ lastFact: s.lastFact,
+ }));
+ const hasFeed = !feed ? false : Object.entries(feed).length > 0;
+ const refetch = () => feed;
+
+ 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 (!!feed) {
+ await api.unfollow(user);
+ } else {
+ await api.follow(user);
+ toast.success(`Follow request sent to ${userString}`);
+ }
+ } catch (error) {
+ toast.error(`Failed to ${!!feed ? "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 className="user-actions">
+ <button
+ onClick={handleFollow}
+ disabled={isFollowLoading}
+ className={`action-btn ${!!feed ? "" : "follow"}`}
+ >
+ {isFollowLoading ? (
+ <>
+ <Icon name="settings" size={16} />
+ {!!feed ? "Unfollowing..." : "Following..."}
+ </>
+ ) : (
+ <>
+ <Icon name={!!feed ? "bell" : "pals"} size={16} />
+ {!!feed ? "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}
+
+ {!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.
+ {!!feed && " Try following them"} or request temporary access to
+ see their content.
+ </p>
+ </div>
+ </div>
+ )}
+ </>
+ );
+}
+
+export default UserFeed;
diff --git a/gui/src/logic/nostr.ts b/gui/src/logic/nostr.ts
index 7da9b91..3a9a586 100644
--- a/gui/src/logic/nostr.ts
+++ b/gui/src/logic/nostr.ts
@@ -21,6 +21,20 @@ export function generateNprofile(pubkey: string) {
const nprofile = nip19.nprofileEncode(prof);
return nprofile;
}
+export function isValidNostrKey(key: string): boolean {
+ try {
+ nip19.decode(key);
+ return true;
+ } catch (e) {
+ try {
+ nip19.npubEncode(key);
+ return true;
+ } catch (e2) {
+ console.error(e2, "not valid nostr key");
+ return false;
+ }
+ }
+}
// let sk = generateSecretKey()
// let nsec = nip19.nsecEncode(sk)
diff --git a/gui/src/pages/Error.tsx b/gui/src/pages/Error.tsx
new file mode 100644
index 0000000..c29e6a6
--- /dev/null
+++ b/gui/src/pages/Error.tsx
@@ -0,0 +1,60 @@
+import "@/styles/ErrorPage.css";
+import Icon from "@/components/Icon";
+import { Link } from "wouter";
+export function P404() {
+ return (
+ <div className="error-page">
+ <div className="error-content">
+ <div className="error-icon-wrapper">
+ <Icon name="crow" size={80} />
+ </div>
+ <h1 className="error-title">404</h1>
+ <h2 className="error-subtitle">Page Not Found</h2>
+ <p className="error-message">
+ The page you're looking for doesn't exist or has been moved.
+ </p>
+ <div className="error-actions">
+ <Link href="/apps/nostrill/f/nostr">
+ <button className="error-btn primary">
+ <Icon name="home" size={18} />
+ Go to Feed
+ </button>
+ </Link>
+ <Link href="/apps/nostrill/sets">
+ <button className="error-btn secondary">
+ <Icon name="settings" size={18} />
+ Settings
+ </button>
+ </Link>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function ErrorPage({ msg }: { msg: string }) {
+ return (
+ <div>
+ <P404 />
+ <h3>{msg}</h3>
+ <div className="error-page">
+ <div className="error-content">
+ <div className="error-icon-wrapper">
+ <Icon name="crow" size={80} />
+ </div>
+ <h1 className="error-title">Oops!</h1>
+ <h2 className="error-subtitle">Something went wrong</h2>
+ <p className="error-message">{msg}</p>
+ <div className="error-actions">
+ <Link href="/apps/nostrill/f/nostr">
+ <button className="error-btn primary">
+ <Icon name="home" size={18} />
+ Go to Feed
+ </button>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/gui/src/pages/Feed.tsx b/gui/src/pages/Feed.tsx
index 02f7b1a..bb001d4 100644
--- a/gui/src/pages/Feed.tsx
+++ b/gui/src/pages/Feed.tsx
@@ -1,35 +1,30 @@
-// 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 { ErrorPage } from "@/Router";
+import { ErrorPage } from "@/pages/Error";
import NostrFeed from "@/components/nostr/Feed";
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) return <FeedPage t="nostr" />;
if (params.taip === "global") return <FeedPage t={"global"} />;
+ if (params.taip === "following") return <FeedPage t={"following"} />;
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" : ""}
@@ -60,7 +55,7 @@ function FeedPage({ t }: { t: FeedType }) {
<NostrFeed />
) : null}
</div>
- </main>
+ </>
);
}
// {active === "global" ? (
diff --git a/gui/src/pages/Thread.tsx b/gui/src/pages/Thread.tsx
index dec8946..fc215f2 100644
--- a/gui/src/pages/Thread.tsx
+++ b/gui/src/pages/Thread.tsx
@@ -3,7 +3,7 @@ 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 "@/Router";
+import { ErrorPage } from "@/pages/Error";
import "@/styles/trill.css";
import "@/styles/feed.css";
import Post from "@/components/post/Post";
diff --git a/gui/src/pages/User.tsx b/gui/src/pages/User.tsx
index b73cd96..1611037 100644
--- a/gui/src/pages/User.tsx
+++ b/gui/src/pages/User.tsx
@@ -9,204 +9,85 @@ 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} />;
+import { ErrorPage } from "@/pages/Error";
+import { useParams } from "wouter";
+import { isValidNostrKey } from "@/logic/nostr";
+import TrillFeed from "@/components/trill/User";
+import NostrFeed from "@/components/nostr/User";
+
+function UserLoader() {
+ const params = useParams();
+ console.log({ params });
+ const userString = params.user;
+ 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" />;
}
function UserFeed({
user,
userString,
- isMe,
}: {
user: UserType;
userString: string;
- isMe: boolean;
}) {
- const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({
+ const { api, pubkey } = useLocalState((s) => ({
api: s.api,
addProfile: s.addProfile,
addNotification: s.addNotification,
lastFact: s.lastFact,
+ pubkey: s.pubkey,
}));
+ const isMe =
+ "urbit" in user
+ ? user.urbit === api?.airlock.our
+ : "nostr" in user
+ ? pubkey === user.nostr
+ : 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 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>
+ {isMe ? (
+ <MyFeed />
+ ) : "urbit" in user ? (
+ <TrillFeed
+ user={user}
+ userString={userString}
+ feed={feed}
+ isFollowLoading={isFollowLoading}
+ setIsFollowLoading={setIsFollowLoading}
+ isAccessLoading={isAccessLoading}
+ setIsAccessLoading={setIsAccessLoading}
+ />
+ ) : "nostr" in user ? (
+ <NostrFeed
+ user={user}
+ userString={userString}
+ feed={feed}
+ isFollowLoading={isFollowLoading}
+ setIsFollowLoading={setIsFollowLoading}
+ isAccessLoading={isAccessLoading}
+ setIsAccessLoading={setIsAccessLoading}
+ />
) : 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;
+
+function MyFeed() {
+ return <></>;
+}
diff --git a/gui/src/state/state.ts b/gui/src/state/state.ts
index 7d433f4..69633e3 100644
--- a/gui/src/state/state.ts
+++ b/gui/src/state/state.ts
@@ -91,7 +91,12 @@ export const useStore = creator((set, get) => ({
}
}
if ("nostr" in data.fact) {
- set({ nostrFeed: data.fact.nostr });
+ 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 ("user" in data.fact.nostr)
+ // if ("thread" in data.fact.nostr)
}
}
});
diff --git a/gui/src/styles/ErrorPage.css b/gui/src/styles/ErrorPage.css
new file mode 100644
index 0000000..0d04810
--- /dev/null
+++ b/gui/src/styles/ErrorPage.css
@@ -0,0 +1,156 @@
+/* Error Page Styles */
+
+.error-page {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ width: 100%;
+ padding: 40px 20px;
+ background: var(--color-background);
+}
+
+.error-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ max-width: 600px;
+ width: 100%;
+ padding: 60px 40px;
+ background: var(--color-surface);
+ border-radius: 16px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ animation: fadeInUp 0.5s ease-out;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.error-icon-wrapper {
+ margin-bottom: 32px;
+ opacity: 0.6;
+ animation: float 3s ease-in-out infinite;
+}
+
+@keyframes float {
+ 0%, 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+.error-title {
+ margin: 0 0 16px 0;
+ font-size: 72px;
+ font-weight: 700;
+ color: var(--color-primary);
+ line-height: 1;
+ letter-spacing: -2px;
+}
+
+.error-subtitle {
+ margin: 0 0 16px 0;
+ font-size: 28px;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.error-message {
+ margin: 0 0 40px 0;
+ font-size: 16px;
+ line-height: 1.6;
+ color: var(--color-text-secondary);
+ max-width: 400px;
+}
+
+.error-actions {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.error-actions a {
+ text-decoration: none;
+}
+
+.error-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 28px;
+ border: none;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.error-btn.primary {
+ background: var(--color-primary);
+ color: white;
+}
+
+.error-btn.primary:hover {
+ background: var(--color-primary-hover);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
+}
+
+.error-btn.secondary {
+ background: transparent;
+ color: var(--color-text);
+ border: 2px solid var(--color-border);
+}
+
+.error-btn.secondary:hover {
+ background: var(--color-surface-hover);
+ border-color: var(--color-text-secondary);
+ transform: translateY(-2px);
+}
+
+.error-btn:active {
+ transform: translateY(0);
+}
+
+/* Responsive adjustments */
+@media (max-width: 480px) {
+ .error-content {
+ padding: 40px 24px;
+ }
+
+ .error-title {
+ font-size: 56px;
+ }
+
+ .error-subtitle {
+ font-size: 22px;
+ }
+
+ .error-message {
+ font-size: 14px;
+ }
+
+ .error-actions {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .error-btn {
+ width: 100%;
+ justify-content: center;
+ }
+}
diff --git a/gui/src/styles/Profile.css b/gui/src/styles/Profile.css
index 624cb12..58aefb8 100644
--- a/gui/src/styles/Profile.css
+++ b/gui/src/styles/Profile.css
@@ -322,4 +322,21 @@
color: var(--color-text-secondary);
line-height: 1.5;
max-width: 400px;
+}
+
+
+/* Banner Image */
+.user-banner {
+ width: 100%;
+ height: 160px;
+ overflow: hidden;
+ margin: 0;
+ flex-shrink: 0;
+}
+
+.user-banner img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
} \ No newline at end of file
diff --git a/gui/src/styles/UserModal.css b/gui/src/styles/UserModal.css
index bf4ff56..e976b38 100644
--- a/gui/src/styles/UserModal.css
+++ b/gui/src/styles/UserModal.css
@@ -6,7 +6,8 @@
gap: 20px;
min-width: 400px;
max-width: 500px;
- padding: 24px;
+ padding: 0;
+ overflow: hidden;
}
.user-modal-error {
@@ -25,6 +26,17 @@
display: flex;
gap: 16px;
align-items: flex-start;
+ padding: 24px 24px 0 24px;
+ margin-top: -40px;
+ /* Pull avatar up over banner */
+ position: relative;
+ z-index: 1;
+}
+
+/* Reset margin if no banner */
+.user-modal>.user-modal-header:first-child {
+ margin-top: 0;
+ padding: 24px;
}
.user-modal-avatar-wrapper {
@@ -36,8 +48,10 @@
height: 80px;
border-radius: 50%;
overflow: hidden;
- border: 3px solid var(--color-border);
+ border: 4px solid var(--color-background);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease, border-color 0.2s ease;
+ background: var(--color-background);
}
.user-modal-avatar-wrapper .avatar:hover {
@@ -135,6 +149,7 @@
/* About Section */
.user-modal-about {
padding: 16px;
+ margin: 0 24px;
background: var(--color-surface);
border-radius: 8px;
border-left: 3px solid var(--color-primary);
@@ -153,6 +168,7 @@
display: flex;
gap: 24px;
padding: 16px;
+ margin: 0 24px;
background: var(--color-surface);
border-radius: 8px;
}
@@ -180,6 +196,7 @@
/* Custom Fields */
.user-modal-custom-fields {
padding: 16px;
+ margin: 0 24px;
background: var(--color-surface);
border-radius: 8px;
}
@@ -216,11 +233,32 @@
flex: 1;
}
+.custom-field-item .field-link {
+ color: var(--color-primary);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ transition: all 0.2s ease;
+ word-break: break-all;
+}
+
+.custom-field-item .field-link:hover {
+ color: var(--color-primary-hover);
+ text-decoration: underline;
+}
+
+.external-link-icon {
+ flex-shrink: 0;
+ opacity: 0.7;
+}
+
/* Action Buttons */
.user-modal-actions {
display: flex;
gap: 12px;
- padding-top: 8px;
+ padding: 16px 24px 24px 24px;
+ margin-top: 8px;
border-top: 1px solid var(--color-border);
}
@@ -313,4 +351,4 @@
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-height: 90vh;
overflow-y: auto;
-}
+} \ No newline at end of file
diff --git a/gui/src/styles/styles.css b/gui/src/styles/styles.css
index 5772c40..41b3c4d 100644
--- a/gui/src/styles/styles.css
+++ b/gui/src/styles/styles.css
@@ -1,5 +1,4 @@
@import "tailwindcss";
-@import "./UserModal.css";
/* assets */
/* fonts */