summaryrefslogtreecommitdiff
path: root/gui/src/components
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/components
parent6704650dcfccf609ccc203308df9004e0b511bb6 (diff)
added frontend WS connection for demonstration purposes
Diffstat (limited to 'gui/src/components')
-rw-r--r--gui/src/components/Avatar.tsx62
-rw-r--r--gui/src/components/Icon.tsx137
-rw-r--r--gui/src/components/NotificationCenter.tsx192
-rw-r--r--gui/src/components/Sigil.tsx50
-rw-r--r--gui/src/components/WsWidget.tsx123
-rw-r--r--gui/src/components/composer/Composer.tsx205
-rw-r--r--gui/src/components/composer/Snippets.tsx86
-rw-r--r--gui/src/components/feed/PostList.tsx33
-rw-r--r--gui/src/components/layout/Sidebar.tsx80
-rw-r--r--gui/src/components/modals/Modal.tsx72
-rw-r--r--gui/src/components/modals/ShipModal.tsx48
-rw-r--r--gui/src/components/modals/UserModal.tsx65
-rw-r--r--gui/src/components/post/Body.tsx174
-rw-r--r--gui/src/components/post/Card.tsx12
-rw-r--r--gui/src/components/post/External.tsx40
-rw-r--r--gui/src/components/post/Footer.tsx260
-rw-r--r--gui/src/components/post/Header.tsx40
-rw-r--r--gui/src/components/post/Loader.tsx148
-rw-r--r--gui/src/components/post/Media.tsx35
-rw-r--r--gui/src/components/post/Post.tsx85
-rw-r--r--gui/src/components/post/PostWrapper.tsx14
-rw-r--r--gui/src/components/post/Quote.tsx64
-rw-r--r--gui/src/components/post/RP.tsx47
-rw-r--r--gui/src/components/post/Reactions.tsx134
-rw-r--r--gui/src/components/post/StatsModal.tsx106
-rw-r--r--gui/src/components/post/wrappers/Nostr.tsx15
-rw-r--r--gui/src/components/post/wrappers/NostrIcon.tsx25
-rw-r--r--gui/src/components/profile/Editor.tsx262
-rw-r--r--gui/src/components/profile/Profile.tsx67
29 files changed, 2681 insertions, 0 deletions
diff --git a/gui/src/components/Avatar.tsx b/gui/src/components/Avatar.tsx
new file mode 100644
index 0000000..a071655
--- /dev/null
+++ b/gui/src/components/Avatar.tsx
@@ -0,0 +1,62 @@
+import useLocalState from "@/state/state";
+import Sigil from "./Sigil";
+import { isValidPatp } from "urbit-ob";
+import type { UserProfile, UserType } from "@/types/nostrill";
+import Icon from "@/components/Icon";
+import UserModal from "./modals/UserModal";
+
+export default function ({
+ user,
+ userString,
+ size,
+ color,
+ noClickOnName,
+ profile,
+ picOnly = false,
+}: {
+ user: UserType;
+ userString: string;
+ size: number;
+ color?: string;
+ noClickOnName?: boolean;
+ profile?: UserProfile;
+ picOnly?: boolean;
+}) {
+ 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) ? (
+ <Sigil patp={user.urbit} size={size} bg={color} />
+ ) : (
+ <Icon name="comet" />
+ );
+ const avatar = (
+ <div className="avatar cp" onClick={openModal}>
+ {avatarInner}
+ </div>
+ );
+ if (picOnly) return avatar;
+
+ const tooLong = (s: string) => (s.length > 15 ? " too-long" : "");
+ function openModal(e: React.MouseEvent) {
+ if (noClickOnName) return;
+ e.stopPropagation();
+ setModal(<UserModal user={user} userString={userString} />);
+ }
+ const name = (
+ <div className="name cp" role="link" onMouseUp={openModal}>
+ {profile ? (
+ <p>{profile.name}</p>
+ ) : "urbit" in user ? (
+ <p className={"p-only" + tooLong(user.urbit)}>
+ {user.urbit.length > 28 ? "Anon" : user.urbit}
+ </p>
+ ) : (
+ <p className={"p-only" + tooLong(user.nostr)}>{user.nostr}</p>
+ )}
+ </div>
+ );
+ return <div className="ship-avatar">{name}</div>;
+}
diff --git a/gui/src/components/Icon.tsx b/gui/src/components/Icon.tsx
new file mode 100644
index 0000000..797a87b
--- /dev/null
+++ b/gui/src/components/Icon.tsx
@@ -0,0 +1,137 @@
+import { useTheme } from "@/styles/ThemeProvider";
+
+import bellSvg from "@/assets/icons/bell.svg";
+import cometSvg from "@/assets/icons/comet.svg";
+import copySvg from "@/assets/icons/copy.svg";
+import crowSvg from "@/assets/icons/crow.svg";
+import emojiSvg from "@/assets/icons/emoji.svg";
+import homeSvg from "@/assets/icons/home.svg";
+import keySvg from "@/assets/icons/key.svg";
+import messagesSvg from "@/assets/icons/messages.svg";
+import nostrSvg from "@/assets/icons/nostr.svg";
+import palsSvg from "@/assets/icons/pals.svg";
+import profileSvg from "@/assets/icons/profile.svg";
+import quoteSvg from "@/assets/icons/quote.svg";
+import radioSvg from "@/assets/icons/radio.svg";
+import replySvg from "@/assets/icons/reply.svg";
+import repostSvg from "@/assets/icons/rt.svg";
+import rumorsSvg from "@/assets/icons/rumors.svg";
+import settingsSvg from "@/assets/icons/settings.svg";
+import youtubeSvg from "@/assets/icons/youtube.svg";
+
+export type IconName =
+ | "bell"
+ | "comet"
+ | "copy"
+ | "crow"
+ | "emoji"
+ | "home"
+ | "key"
+ | "messages"
+ | "nostr"
+ | "pals"
+ | "profile"
+ | "quote"
+ | "radio"
+ | "reply"
+ | "repost"
+ | "rumors"
+ | "settings"
+ | "youtube";
+
+const iconMap: Record<IconName, string> = {
+ bell: bellSvg,
+ comet: cometSvg,
+ copy: copySvg,
+ crow: crowSvg,
+ emoji: emojiSvg,
+ home: homeSvg,
+ key: keySvg,
+ messages: messagesSvg,
+ nostr: nostrSvg,
+ pals: palsSvg,
+ profile: profileSvg,
+ quote: quoteSvg,
+ radio: radioSvg,
+ reply: replySvg,
+ repost: repostSvg,
+ rumors: rumorsSvg,
+ settings: settingsSvg,
+ youtube: youtubeSvg,
+};
+
+interface IconProps {
+ name: IconName;
+ size?: number;
+ className?: string;
+ title?: string;
+ onClick?: (e: React.MouseEvent) => any;
+ color?: "primary" | "text" | "textSecondary" | "textMuted" | "custom";
+ customColor?: string;
+}
+
+const Icon: React.FC<IconProps> = ({
+ name,
+ size = 20,
+ className = "",
+ title,
+ onClick,
+ color = "text",
+ customColor,
+}) => {
+ const { theme } = useTheme();
+
+ // Simple filter based on theme - icons should match text
+ const getFilter = () => {
+ // For dark themes, invert the black SVGs to white
+ if (
+ theme.name === "dark" ||
+ theme.name === "noir" ||
+ theme.name === "gruvbox"
+ ) {
+ return "invert(1)";
+ }
+ // For light themes with dark text, keep as is
+ if (theme.name === "light") {
+ return "none";
+ }
+ // For colored themes, adjust brightness/contrast
+ if (theme.name === "sepia") {
+ return "sepia(1) saturate(2) hue-rotate(20deg) brightness(0.8)";
+ }
+ if (theme.name === "ocean") {
+ return "brightness(0) saturate(100%) invert(13%) sepia(95%) saturate(3207%) hue-rotate(195deg) brightness(94%) contrast(106%)";
+ }
+ if (theme.name === "forest") {
+ return "brightness(0) saturate(100%) invert(24%) sepia(95%) saturate(1352%) hue-rotate(87deg) brightness(92%) contrast(96%)";
+ }
+ return "none";
+ };
+
+ const iconUrl = iconMap[name];
+
+ if (!iconUrl) {
+ console.error(`Icon "${name}" not found`);
+ return null;
+ }
+
+ return (
+ <img
+ src={iconUrl}
+ className={`icon ${className}`}
+ onClick={onClick}
+ title={title}
+ alt={title || name}
+ style={{
+ width: size,
+ height: size,
+ display: "inline-block",
+ cursor: onClick ? "pointer" : "default",
+ filter: getFilter(),
+ transition: "filter 0.2s ease",
+ }}
+ />
+ );
+};
+
+export default Icon;
diff --git a/gui/src/components/NotificationCenter.tsx b/gui/src/components/NotificationCenter.tsx
new file mode 100644
index 0000000..44a6799
--- /dev/null
+++ b/gui/src/components/NotificationCenter.tsx
@@ -0,0 +1,192 @@
+import { useState } from "react";
+import useLocalState from "@/state/state";
+import Modal from "./modals/Modal";
+import Icon from "./Icon";
+import Avatar from "./Avatar";
+import { useLocation } from "wouter";
+import type { Notification, NotificationType } from "@/types/notifications";
+import "@/styles/NotificationCenter.css";
+
+const NotificationCenter = () => {
+ const [_, navigate] = useLocation();
+ const {
+ notifications,
+ unreadNotifications,
+ markNotificationRead,
+ markAllNotificationsRead,
+ clearNotifications,
+ setModal
+ } = useLocalState((s) => ({
+ notifications: s.notifications,
+ unreadNotifications: s.unreadNotifications,
+ markNotificationRead: s.markNotificationRead,
+ markAllNotificationsRead: s.markAllNotificationsRead,
+ clearNotifications: s.clearNotifications,
+ setModal: s.setModal
+ }));
+
+ const [filter, setFilter] = useState<"all" | "unread">("all");
+
+ const filteredNotifications = filter === "unread"
+ ? notifications.filter(n => !n.read)
+ : notifications;
+
+ const handleNotificationClick = (notification: Notification) => {
+ // Mark as read
+ if (!notification.read) {
+ markNotificationRead(notification.id);
+ }
+
+ // Navigate based on notification type
+ if (notification.postId) {
+ // Navigate to post
+ navigate(`/post/${notification.postId}`);
+ setModal(null);
+ } else if (notification.type === "follow" || notification.type === "access_request") {
+ // Navigate to user profile
+ navigate(`/feed/${notification.from}`);
+ setModal(null);
+ }
+ };
+
+ const getNotificationIcon = (type: NotificationType) => {
+ switch (type) {
+ case "follow":
+ case "unfollow":
+ return "pals";
+ case "mention":
+ case "reply":
+ return "messages";
+ case "repost":
+ return "repost";
+ case "react":
+ return "emoji";
+ case "access_request":
+ case "access_granted":
+ return "key";
+ default:
+ return "bell";
+ }
+ };
+
+ const getNotificationText = (notification: Notification) => {
+ switch (notification.type) {
+ case "follow":
+ return `${notification.from} started following you`;
+ case "unfollow":
+ return `${notification.from} unfollowed you`;
+ case "mention":
+ return `${notification.from} mentioned you in a post`;
+ case "reply":
+ return `${notification.from} replied to your post`;
+ case "repost":
+ return `${notification.from} reposted your post`;
+ case "react":
+ return `${notification.from} reacted ${notification.reaction || ""} to your post`;
+ case "access_request":
+ return `${notification.from} requested access to your feed`;
+ case "access_granted":
+ return `${notification.from} granted you access to their feed`;
+ default:
+ return notification.message || "New notification";
+ }
+ };
+
+ const formatTimestamp = (date: Date) => {
+ const now = new Date();
+ const diff = now.getTime() - new Date(date).getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (minutes < 1) return "Just now";
+ if (minutes < 60) return `${minutes}m ago`;
+ if (hours < 24) return `${hours}h ago`;
+ if (days < 7) return `${days}d ago`;
+ return new Date(date).toLocaleDateString();
+ };
+
+ return (
+ <Modal close={() => setModal(null)}>
+ <div className="notification-center">
+ <div className="notification-header">
+ <h2>Notifications</h2>
+ <div className="notification-actions">
+ {unreadNotifications > 0 && (
+ <button
+ className="mark-all-read-btn"
+ onClick={markAllNotificationsRead}
+ >
+ Mark all as read
+ </button>
+ )}
+ {notifications.length > 0 && (
+ <button
+ className="clear-all-btn"
+ onClick={clearNotifications}
+ >
+ Clear all
+ </button>
+ )}
+ </div>
+ </div>
+
+ <div className="notification-filters">
+ <button
+ className={`filter-btn ${filter === "all" ? "active" : ""}`}
+ onClick={() => setFilter("all")}
+ >
+ All ({notifications.length})
+ </button>
+ <button
+ className={`filter-btn ${filter === "unread" ? "active" : ""}`}
+ onClick={() => setFilter("unread")}
+ >
+ Unread ({unreadNotifications})
+ </button>
+ </div>
+
+ <div className="notification-list">
+ {filteredNotifications.length === 0 ? (
+ <div className="no-notifications">
+ <Icon name="bell" size={48} color="textMuted" />
+ <p>No {filter === "unread" ? "unread " : ""}notifications</p>
+ </div>
+ ) : (
+ filteredNotifications.map((notification) => (
+ <div
+ key={notification.id}
+ className={`notification-item ${!notification.read ? "unread" : ""}`}
+ onClick={() => handleNotificationClick(notification)}
+ >
+ <div className="notification-icon">
+ <Icon
+ name={getNotificationIcon(notification.type)}
+ size={20}
+ color={!notification.read ? "primary" : "textSecondary"}
+ />
+ </div>
+
+ <div className="notification-content">
+ <div className="notification-user">
+ <Avatar p={notification.from} size={32} />
+ <div className="notification-text">
+ <p>{getNotificationText(notification)}</p>
+ <span className="notification-time">
+ {formatTimestamp(notification.timestamp)}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {!notification.read && <div className="unread-indicator" />}
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ </Modal>
+ );
+};
+
+export default NotificationCenter; \ No newline at end of file
diff --git a/gui/src/components/Sigil.tsx b/gui/src/components/Sigil.tsx
new file mode 100644
index 0000000..cbc2e57
--- /dev/null
+++ b/gui/src/components/Sigil.tsx
@@ -0,0 +1,50 @@
+import Icon from "@/components/Icon";
+import { auraToHex } from "@/logic/utils";
+import { isValidPatp } from "urbit-ob";
+import { sigil } from "urbit-sigils";
+import { reactRenderer } from "urbit-sigils";
+
+interface SigilProps {
+ patp: string;
+ size: number;
+ bg?: string;
+ fg?: string;
+}
+
+const Sigil = (props: SigilProps) => {
+ const bg = props.bg ? auraToHex(props.bg) : "var(--color-background)";
+ const fg = props.fg ? auraToHex(props.fg) : "var(--color-primary)";
+ if (props.patp.length > 28)
+ return (
+ <Icon
+ name="comet"
+ size={props.size}
+ className="comet-icon"
+ />
+ );
+ else if (props.patp.length > 15)
+ // moons
+ return (
+ <>
+ {sigil({
+ patp: props.patp.substring(props.patp.length - 13),
+ renderer: reactRenderer,
+ size: props.size,
+ colors: ["grey", "white"],
+ })}
+ </>
+ );
+ else
+ return (
+ <>
+ {sigil({
+ patp: props.patp,
+ renderer: reactRenderer,
+ size: props.size,
+ colors: [bg, fg],
+ })}
+ </>
+ );
+};
+
+export default Sigil;
diff --git a/gui/src/components/WsWidget.tsx b/gui/src/components/WsWidget.tsx
new file mode 100644
index 0000000..75c773d
--- /dev/null
+++ b/gui/src/components/WsWidget.tsx
@@ -0,0 +1,123 @@
+import { useWebSocket } from "@/hooks/useWs";
+import { useState } from "react";
+
+type WidgetProps = {
+ url: string;
+ protocols?: string | string[];
+};
+
+export default function WebSocketWidget({ url, protocols }: WidgetProps) {
+ const {
+ status,
+ retryCount,
+ lastMessage,
+ error,
+ bufferedAmount,
+ send,
+ reconnectNow,
+ close,
+ } = useWebSocket({
+ url,
+ protocols,
+ onMessage: (ev) => {
+ // Example: auto reply to pings
+ console.log(ev.data, "ws event");
+ if (
+ typeof ev.data === "string" &&
+ // ev.data.toLowerCase().includes("ping")
+ ev.data.toLowerCase().trim() == "ping"
+ ) {
+ try {
+ console.log("sending pong");
+ send("pong");
+ } catch {}
+ }
+ },
+ });
+
+ const [outbound, setOutbound] = useState("");
+
+ return (
+ <div className="w-full max-w-xl mx-auto p-4 grid gap-3">
+ <header className="flex items-center justify-between">
+ <h1 className="text-xl font-semibold">WebSocketWidget</h1>
+ <span className="text-sm px-2 py-1 rounded-full border">
+ {status.toUpperCase()} {retryCount ? `(retry ${retryCount})` : ""}
+ </span>
+ </header>
+
+ <div className="text-sm text-gray-600">
+ <div>
+ <b>URL:</b> {url}
+ </div>
+ <div>
+ <b>Buffered:</b> {bufferedAmount} bytes
+ </div>
+ {error && (
+ <div className="text-red-600">
+ <b>Error:</b>{" "}
+ {"message" in error
+ ? (error as any).message
+ : String(error.type || "error")}
+ </div>
+ )}
+ </div>
+
+ <div className="p-3 rounded-2xl border bg-gray-50 min-h-[4rem] font-mono text-sm break-words">
+ <div className="opacity-70">Last message:</div>
+ <div>
+ {lastMessage
+ ? typeof lastMessage.data === "string"
+ ? lastMessage.data
+ : "(binary)"
+ : "—"}
+ </div>
+ </div>
+
+ <form
+ className="flex gap-2"
+ onSubmit={(e) => {
+ e.preventDefault();
+ if (!outbound) return;
+ send(outbound);
+ setOutbound("");
+ }}
+ >
+ <input
+ className="flex-1 px-3 py-2 rounded-xl border"
+ placeholder="Type message…"
+ value={outbound}
+ onChange={(e) => setOutbound(e.target.value)}
+ />
+ <button type="submit" className="px-3 py-2 rounded-xl border">
+ Send
+ </button>
+ </form>
+
+ <div className="flex gap-2">
+ <button className="px-3 py-2 rounded-xl border" onClick={reconnectNow}>
+ Reconnect
+ </button>
+ <button className="px-3 py-2 rounded-xl border" onClick={() => close()}>
+ Close
+ </button>
+ </div>
+
+ <details className="mt-2">
+ <summary className="cursor-pointer">Usage</summary>
+ <pre className="text-xs bg-gray-100 p-2 rounded-xl overflow-auto">
+ {`import WebSocketWidget from "./WebSocketWidget";
+
+export default function App() {
+ return (
+ <div className="p-6">
+ <WebSocketWidget url="wss://echo.websocket.events" />
+ </div>
+ );
+}
+`}
+ </pre>
+ </details>
+ </div>
+ );
+}
diff --git a/gui/src/components/composer/Composer.tsx b/gui/src/components/composer/Composer.tsx
new file mode 100644
index 0000000..81d0358
--- /dev/null
+++ b/gui/src/components/composer/Composer.tsx
@@ -0,0 +1,205 @@
+import useLocalState from "@/state/state";
+import type { Poast } from "@/types/trill";
+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";
+
+function Composer({ isAnon }: { isAnon?: boolean }) {
+ const { api, composerData, addNotification, setComposerData } = useLocalState(
+ (s) => ({
+ api: s.api,
+ composerData: s.composerData,
+ addNotification: s.addNotification,
+ 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);
+
+ useEffect(() => {
+ if (composerData) {
+ setIsExpanded(true);
+ if (
+ composerData.type === "reply" &&
+ composerData.post &&
+ "trill" in composerData.post
+ ) {
+ const author = composerData.post.trill.author;
+ setInput(`${author} `);
+ }
+ // Auto-focus input when composer opens
+ setTimeout(() => {
+ inputRef.current?.focus();
+ }, 100); // Small delay to ensure the composer is rendered
+ }
+ }, [composerData]);
+ async function poast(e: FormEvent<HTMLFormElement>) {
+ e.preventDefault();
+ // TODO
+ // const parent = replying ? replying : null;
+ // const tokens = tokenize(input);
+ // const post: SentPoast = {
+ // host: parent ? parent.host : our,
+ // author: our,
+ // thread: parent ? parent.thread : null,
+ // parent: parent ? parent.id : null,
+ // contents: input,
+ // read: openLock,
+ // write: openLock,
+ // tags: input.match(HASHTAGS_REGEX) || [],
+ // };
+ // TODO make it user choosable
+ 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 ares = await res;
+ if (ares) {
+ // // Check for mentions in the post (ship names starting with ~)
+ const mentions = input.match(/~[a-z-]+/g);
+ if (mentions) {
+ mentions.forEach((mention) => {
+ if (mention !== our) {
+ // Don't notify self-mentions
+ addNotification({
+ type: "mention",
+ from: our,
+ message: `You mentioned ${mention} in a post`,
+ });
+ }
+ });
+ }
+
+ // If this is a reply, add notification
+ if (
+ composerData?.type === "reply" &&
+ composerData.post &&
+ "trill" in composerData.post
+ ) {
+ if (composerData.post.trill.author !== our) {
+ addNotification({
+ type: "reply",
+ from: our,
+ message: `You replied to ${composerData.post.trill.author}'s post`,
+ postId: composerData.post.trill.id,
+ });
+ }
+ }
+
+ setInput("");
+ setComposerData(null); // Clear composer data after successful post
+ toast.success("post sent");
+ setIsExpanded(false);
+ }
+ }
+ const placeHolder =
+ composerData?.type === "reply"
+ ? "Write your reply..."
+ : composerData?.type === "quote"
+ ? "Add your thoughts..."
+ : isAnon
+ ? "> be me"
+ : "What's going on in Urbit";
+
+ const clearComposer = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setComposerData(null);
+ setInput("");
+ setIsExpanded(false);
+ };
+
+ return (
+ <form
+ id="composer"
+ className={`${isExpanded ? "expanded" : ""} ${composerData ? "has-context" : ""}`}
+ onSubmit={poast}
+ >
+ <div className="sigil avatar">
+ <Sigil patp={our} size={46} />
+ </div>
+
+ <div className="composer-content">
+ {/* Reply snippets appear above input */}
+ {composerData && composerData.type === "reply" && (
+ <div className="composer-context reply-context">
+ <div className="context-header">
+ <span className="context-type">
+ <Icon name="reply" size={14} /> Replying to
+ </span>
+ <button
+ className="clear-context"
+ onClick={clearComposer}
+ title="Clear"
+ type="button"
+ >
+ ×
+ </button>
+ </div>
+ <ReplySnippet post={composerData.post} />
+ </div>
+ )}
+
+ {/* Quote context header above input (without snippet) */}
+ {composerData && composerData.type === "quote" && (
+ <div className="quote-header">
+ <div className="context-header">
+ <span className="context-type">
+ <Icon name="quote" size={14} /> Quote posting
+ </span>
+ <button
+ className="clear-context"
+ onClick={clearComposer}
+ title="Clear"
+ type="button"
+ >
+ ×
+ </button>
+ </div>
+ </div>
+ )}
+
+ <div className="composer-input-row">
+ <input
+ ref={inputRef}
+ value={input}
+ onInput={(e) => setInput(e.currentTarget.value)}
+ onFocus={() => setIsExpanded(true)}
+ placeholder={placeHolder}
+ />
+ <button type="submit" disabled={!input.trim()} className="post-btn">
+ Post
+ </button>
+ </div>
+
+ {/* Quote snippets appear below input */}
+ {composerData && composerData.type === "quote" && (
+ <div className="composer-context quote-context">
+ <Snippets post={composerData.post} />
+ </div>
+ )}
+ </div>
+ </form>
+ );
+}
+
+export default Composer;
diff --git a/gui/src/components/composer/Snippets.tsx b/gui/src/components/composer/Snippets.tsx
new file mode 100644
index 0000000..49d9b88
--- /dev/null
+++ b/gui/src/components/composer/Snippets.tsx
@@ -0,0 +1,86 @@
+import Quote from "@/components/post/Quote";
+import type { SPID } from "@/types/ui";
+import { NostrSnippet } from "../post/wrappers/Nostr";
+
+export default Snippets;
+function Snippets({ post }: { post: SPID }) {
+ return (
+ <ComposerSnippet>
+ <PostSnippet post={post} />
+ </ComposerSnippet>
+ );
+}
+
+export function ComposerSnippet({
+ onClick,
+ children,
+}: {
+ onClick?: any;
+ children: any;
+}) {
+ function onc(e: React.MouseEvent) {
+ e.stopPropagation();
+ if (onClick) onClick();
+ }
+ return (
+ <div className="composer-snippet">
+ {onClick && (
+ <div className="pop-snippet-icon cp" role="link" onClick={onc}>
+ ×
+ </div>
+ )}
+ {children}
+ </div>
+ );
+}
+function PostSnippet({ post }: { post: SPID }) {
+ if (!post) return <div className="snippet-error">No post data</div>;
+
+ try {
+ if ("trill" in post) return <Quote data={post.trill} nest={0} />;
+ else if ("nostr" in post) return <NostrSnippet {...post.nostr} />;
+ // else if ("twatter" in post)
+ // return (
+ // <div id={`composer-${type}`}>
+ // <Tweet tweet={post.post} quote={true} />
+ // </div>
+ // );
+ // else if ("rumors" in post)
+ // return (
+ // <div id={`composer-${type}`}>
+ // <div className="rumor-quote f1">
+ // <img src={rumorIcon} alt="" />
+ // <Body poast={post.post} refetch={() => {}} />
+ // <span>{date_diff(post.post.time, "short")}</span>
+ // </div>
+ // </div>
+ // );
+ else return <div className="snippet-error">Unsupported post type</div>;
+ } catch (error) {
+ console.error("Error rendering post snippet:", error);
+ return <div className="snippet-error">Failed to load post</div>;
+ }
+}
+
+export function ReplySnippet({ post }: { post: SPID }) {
+ if (!post) return <div className="snippet-error">No post to reply to</div>;
+
+ try {
+ if ("trill" in post)
+ return (
+ <div id="reply" className="reply-snippet">
+ <Quote data={post.trill} nest={0} />
+ </div>
+ );
+ else if ("nostr" in post)
+ return (
+ <div id="reply" className="reply-snippet">
+ <NostrSnippet {...post.nostr} />
+ </div>
+ );
+ else return <div className="snippet-error">Cannot reply to this post type</div>;
+ } catch (error) {
+ console.error("Error rendering reply snippet:", error);
+ return <div className="snippet-error">Failed to load reply context</div>;
+ }
+}
diff --git a/gui/src/components/feed/PostList.tsx b/gui/src/components/feed/PostList.tsx
new file mode 100644
index 0000000..b09a0e9
--- /dev/null
+++ b/gui/src/components/feed/PostList.tsx
@@ -0,0 +1,33 @@
+import TrillPost from "@/components/post/Post";
+import type { FC } from "@/types/trill";
+// import { useEffect } from "react";
+// import { useQueryClient } from "@tanstack/react-query";
+// import { toFull } from "../thread/helpers";
+
+function TrillFeed({ data, refetch }: { data: FC; refetch: Function }) {
+ // const qc = useQueryClient();
+ // useEffect(() => {
+ // Object.values(data.feed).forEach((poast) => {
+ // const queryKey = ["trill-thread", poast.host, poast.id];
+ // const existing = qc.getQueryData(queryKey);
+ // if (!existing || !("fpost" in (existing as any))) {
+ // qc.setQueryData(queryKey, {
+ // fpost: toFull(poast),
+ // });
+ // }
+ // });
+ // }, [data]);
+ return (
+ <>
+ {Object.keys(data.feed)
+ .sort()
+ .reverse()
+ .slice(0, 50)
+ .map((i) => (
+ <TrillPost key={i} poast={data.feed[i]} refetch={refetch} />
+ ))}
+ </>
+ );
+}
+
+export default TrillFeed;
diff --git a/gui/src/components/layout/Sidebar.tsx b/gui/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..c267e2f
--- /dev/null
+++ b/gui/src/components/layout/Sidebar.tsx
@@ -0,0 +1,80 @@
+import { RADIO, versionNum } from "@/logic/constants";
+import { useLocation } from "wouter";
+import useLocalState from "@/state/state";
+import logo from "@/assets/icons/logo.png";
+import Icon from "@/components/Icon";
+import { ThemeSwitcher } from "@/styles/ThemeSwitcher";
+
+function SlidingMenu() {
+ const [_, navigate] = useLocation();
+ const { api, unreadNotifications, setModal } = useLocalState((s) => ({
+ api: s.api,
+ unreadNotifications: s.unreadNotifications,
+ setModal: s.setModal
+ }));
+
+ function goto(to: string) {
+ navigate(to);
+ }
+
+ function openNotifications() {
+ // We'll create this component next
+ import("../NotificationCenter").then(({ default: NotificationCenter }) => {
+ setModal(<NotificationCenter />);
+ });
+ }
+ return (
+ <div id="left-menu">
+ <div id="logo">
+ <img src={logo} />
+ <h3> Nostrill </h3>
+ </div>
+ <h3>Feeds</h3>
+ <div className="opt" role="link" onClick={() => goto(`/feed/global`)}>
+ <Icon name="home" size={20} />
+ <div>Home</div>
+ </div>
+ <div className="opt notification-item" role="link" onClick={openNotifications}>
+ <div className="notification-icon-wrapper">
+ <Icon name="bell" size={20} />
+ {unreadNotifications > 0 && (
+ <span className="notification-badge">
+ {unreadNotifications > 99 ? "99+" : unreadNotifications}
+ </span>
+ )}
+ </div>
+ <div>Notifications</div>
+ </div>
+ <hr />
+
+ <div
+ className="opt tbd"
+ role="link"
+ // onClick={() => goto("/chat")}
+ >
+ <Icon name="messages" size={20} />
+ <div>Messages</div>
+ </div>
+ <div className="opt" role="link" onClick={() => goto("/pals")}>
+ <Icon name="pals" size={20} />
+ <div>Pals</div>
+ </div>
+ <hr />
+ <div
+ className="opt"
+ role="link"
+ onClick={() => goto(`/feed/${api!.airlock.our}`)}
+ >
+ <Icon name="profile" size={20} />
+ <div>Profile</div>
+ </div>
+ <hr />
+ <div className="opt" role="link" onClick={() => goto("/sets")}>
+ <Icon name="settings" size={20} />
+ <div>Settings</div>
+ </div>
+ <ThemeSwitcher />
+ </div>
+ );
+}
+export default SlidingMenu;
diff --git a/gui/src/components/modals/Modal.tsx b/gui/src/components/modals/Modal.tsx
new file mode 100644
index 0000000..e7bae78
--- /dev/null
+++ b/gui/src/components/modals/Modal.tsx
@@ -0,0 +1,72 @@
+import useLocalState from "@/state/state";
+import { useEffect, useRef, useState } from "react";
+
+function Modal({ children }: any) {
+ const { setModal } = useLocalState((s) => ({ setModal: s.setModal }));
+ function onKey(event: any) {
+ if (event.key === "Escape") setModal(null);
+ }
+ useEffect(() => {
+ document.addEventListener("keyup", onKey);
+ return () => {
+ document.removeEventListener("keyup", onKey);
+ };
+ }, [children]);
+
+ function clickAway(e: React.MouseEvent) {
+ console.log("clicked away");
+ e.stopPropagation();
+ if (!modalRef.current || !modalRef.current.contains(e.target))
+ setModal(null);
+ }
+ const modalRef = useRef(null);
+ return (
+ <div id="modal-background" onClick={clickAway}>
+ <div id="modal" ref={modalRef}>
+ {children}
+ </div>
+ </div>
+ );
+}
+export default Modal;
+
+export function Welcome() {
+ return (
+ <Modal>
+ <div id="welcome-msg">
+ <h1>Welcome to Nostril!</h1>
+ <p>
+ Trill is the world's only truly free and sovereign social media
+ platform, powered by Urbit.
+ </p>
+ <p>
+ Click on the crow icon on the top left to see all available feeds.
+ </p>
+ <p>The Global feed should be populated by default.</p>
+ <p>Follow people soon so your Global feed doesn't go stale.</p>
+ <p>
+ Trill is still on beta. The UI is Mobile only, we recommend you use
+ your phone or the browser dev tools. Desktop UI is on the works.
+ </p>
+ <p>
+ If you have any feedback please reach out to us on Groups at
+ ~hoster-dozzod-sortug/trill or here at ~polwex
+ </p>
+ </div>
+ </Modal>
+ );
+}
+
+export function Tooltip({ children, text, className }: any) {
+ const [show, toggle] = useState(false);
+ return (
+ <div
+ className={"tooltip-wrapper " + (className || "")}
+ onMouseOver={() => toggle(true)}
+ onMouseOut={() => toggle(false)}
+ >
+ {children}
+ {show && <div className="tooltip">{text}</div>}
+ </div>
+ );
+}
diff --git a/gui/src/components/modals/ShipModal.tsx b/gui/src/components/modals/ShipModal.tsx
new file mode 100644
index 0000000..e823a3a
--- /dev/null
+++ b/gui/src/components/modals/ShipModal.tsx
@@ -0,0 +1,48 @@
+import type { Ship } from "@/types/urbit";
+import Modal from "./Modal";
+import Avatar from "../Avatar";
+import Icon from "@/components/Icon";
+import useLocalState from "@/state/state";
+import { useLocation } from "wouter";
+import toast from "react-hot-toast";
+
+export default function ({ ship }: { ship: Ship }) {
+ const { setModal, api } = useLocalState((s) => ({
+ setModal: s.setModal,
+ api: s.api,
+ }));
+ const [_, navigate] = useLocation();
+ function close() {
+ setModal(null);
+ }
+ async function copy(e: React.MouseEvent) {
+ e.stopPropagation();
+ await navigator.clipboard.writeText(ship);
+ toast.success("Copied to clipboard");
+ }
+ return (
+ <Modal close={close}>
+ <div id="ship-modal">
+ <div className="flex">
+ <Avatar p={ship} size={60} />
+ <Icon
+ name="copy"
+ size={20}
+ className="copy-icon cp"
+ onClick={copy}
+ title="Copy ship name"
+ />
+ </div>
+ <div className="buttons f1">
+ <button onClick={() => navigate(`/feed/${ship}`)}>Feed</button>
+ <button onClick={() => navigate(`/pals/${ship}`)}>Profile</button>
+ {ship !== api!.airlock.our && (
+ <>
+ <button onClick={() => navigate(`/chat/dm/${ship}`)}>DM</button>
+ </>
+ )}
+ </div>
+ </div>
+ </Modal>
+ );
+}
diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx
new file mode 100644
index 0000000..6e3089d
--- /dev/null
+++ b/gui/src/components/modals/UserModal.tsx
@@ -0,0 +1,65 @@
+import Modal from "./Modal";
+import Avatar from "../Avatar";
+import Icon from "@/components/Icon";
+import useLocalState from "@/state/state";
+import { useLocation } from "wouter";
+import toast from "react-hot-toast";
+import type { UserType } from "@/types/nostrill";
+
+export default function ({
+ user,
+ userString,
+}: {
+ user: UserType;
+ userString: string;
+}) {
+ const { setModal, api, pubkey } = useLocalState((s) => ({
+ setModal: s.setModal,
+ api: s.api,
+ pubkey: s.pubkey,
+ }));
+ const [_, navigate] = useLocation();
+ function close() {
+ setModal(null);
+ }
+ const itsMe =
+ "urbit" in user
+ ? user.urbit === api?.airlock.our
+ : "nostr" in user
+ ? user.nostr === pubkey
+ : false;
+ async function copy(e: React.MouseEvent) {
+ e.stopPropagation();
+ await navigator.clipboard.writeText(userString);
+ toast.success("Copied to clipboard");
+ }
+ return (
+ <Modal close={close}>
+ <div id="ship-modal">
+ <div className="flex">
+ <Avatar user={user} userString={userString} size={60} />
+ <Icon
+ name="copy"
+ size={20}
+ className="copy-icon cp"
+ onClick={copy}
+ title="Copy ship name"
+ />
+ </div>
+ <div className="buttons f1">
+ <button onClick={() => navigate(`/feed/${userString}`)}>Feed</button>
+ <button onClick={() => navigate(`/pals/${userString}`)}>
+ Profile
+ </button>
+ {itsMe && (
+ <>
+ <button onClick={() => navigate(`/chat/dm/${userString}`)}>
+ DM
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+ </Modal>
+ );
+}
diff --git a/gui/src/components/post/Body.tsx b/gui/src/components/post/Body.tsx
new file mode 100644
index 0000000..b4f1bb2
--- /dev/null
+++ b/gui/src/components/post/Body.tsx
@@ -0,0 +1,174 @@
+import type {
+ // TODO ref backend fetching!!
+ Reference,
+ Block,
+ Inline,
+ 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";
+import { useLocation } from "wouter";
+import Quote from "./Quote";
+import PostData from "./Loader";
+import Card from "./Card.tsx";
+import type { Ship } from "@/types/urbit.ts";
+
+function Body(props: PostProps) {
+ const text = props.poast.contents.filter((c) => {
+ return (
+ "paragraph" in c ||
+ "blockquote" in c ||
+ "heading" in c ||
+ "codeblock" in c ||
+ "list" in c
+ );
+ });
+
+ const media: MediaType[] = props.poast.contents.filter(
+ (c): c is MediaType => "media" in c,
+ );
+
+ const refs = props.poast.contents.filter((c): c is Reference => "ref" in c);
+ const json = props.poast.contents.filter(
+ (c): c is ExternalContent => "json" in c,
+ );
+
+ return (
+ <div className="trill-post-body body">
+ <div className="body-text">
+ {text.map((b, i) => (
+ <TextBlock key={JSON.stringify(b) + i} block={b} />
+ ))}
+ </div>
+ {media.length > 0 && <Media media={media} />}
+ {refs.map((r, i) => (
+ <Ref r={r} nest={props.nest || 0} key={JSON.stringify(r) + i} />
+ ))}
+ <JSONContent content={json} />
+ </div>
+ );
+}
+export default Body;
+
+function TextBlock({ block }: { block: Block }) {
+ const key = JSON.stringify(block);
+ return "paragraph" in block ? (
+ <div className="trill-post-paragraph">
+ {block.paragraph.map((i, ind) => (
+ <Inlin key={key + ind} i={i} />
+ ))}
+ </div>
+ ) : "blockquote" in block ? (
+ <blockquote>
+ {block.blockquote.map((i, ind) => (
+ <Inlin key={key + ind} i={i} />
+ ))}
+ </blockquote>
+ ) : "heading" in block ? (
+ <Heading string={block.heading.text} num={block.heading.num} />
+ ) : "codeblock" in block ? (
+ <pre>
+ <code className={`language-${block.codeblock.lang}`}>
+ {block.codeblock.code}
+ </code>
+ </pre>
+ ) : "list" in block ? (
+ block.list.ordered ? (
+ <ol>
+ {block.list.text.map((i, ind) => (
+ <li key={JSON.stringify(i) + ind}>
+ <Inlin key={key + ind} i={i} />
+ </li>
+ ))}
+ </ol>
+ ) : (
+ <ul>
+ {block.list.text.map((i, ind) => (
+ <li key={JSON.stringify(i) + ind}>
+ <Inlin key={JSON.stringify(i) + ind} i={i} />
+ </li>
+ ))}
+ </ul>
+ )
+ ) : 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;
+}
+
+function LinkParser({ href, show }: { href: string; show: string }) {
+ const YOUTUBE_REGEX_1 = /(youtube\.com\/watch\?v=)(\w+)/;
+ const YOUTUBE_REGEX_2 = /(youtu\.be\/)([a-zA-Z0-9-_]+)/;
+ const m1 = href.match(YOUTUBE_REGEX_1);
+ const m2 = href.match(YOUTUBE_REGEX_2);
+ const ytb = m1 && m1[2] ? m1[2] : m2 && m2[2] ? m2[2] : "";
+ return ytb ? (
+ <YoutubeSnippet href={href} id={ytb} />
+ ) : (
+ <a href={href}>{show}</a>
+ );
+}
+function Heading({ string, num }: { string: string; num: number }) {
+ return num === 1 ? (
+ <h1>{string}</h1>
+ ) : num === 2 ? (
+ <h2>{string}</h2>
+ ) : num === 3 ? (
+ <h3>{string}</h3>
+ ) : num === 4 ? (
+ <h4>{string}</h4>
+ ) : num === 5 ? (
+ <h5>{string}</h5>
+ ) : num === 6 ? (
+ <h6>{string}</h6>
+ ) : null;
+}
+
+function Ref({ r, nest }: { r: Reference; nest: number }) {
+ if (r.ref.type === "trill") {
+ const comp = PostData({
+ host: r.ref.ship,
+ id: r.ref.path.slice(1),
+ nest: nest + 1,
+ className: "quote-in-post",
+ })(Quote);
+ return <Card logo="crow">{comp}</Card>;
+ }
+ return <></>;
+}
diff --git a/gui/src/components/post/Card.tsx b/gui/src/components/post/Card.tsx
new file mode 100644
index 0000000..9309423
--- /dev/null
+++ b/gui/src/components/post/Card.tsx
@@ -0,0 +1,12 @@
+import Icon from "@/components/Icon";
+import type { IconName } from "@/components/Icon";
+
+export default function ({ children, logo, cn}: { cn?: string; logo: IconName; children: any }) {
+ const className = "trill-post-card" + (cn ? ` ${cn}`: "")
+ return (
+ <div className={className}>
+ <Icon name={logo} size={20} className="trill-post-card-logo" />
+ {children}
+ </div>
+ );
+}
diff --git a/gui/src/components/post/External.tsx b/gui/src/components/post/External.tsx
new file mode 100644
index 0000000..d52aec7
--- /dev/null
+++ b/gui/src/components/post/External.tsx
@@ -0,0 +1,40 @@
+import type { ExternalContent } from "@/types/trill";
+import Card from "./Card";
+
+interface JSONProps {
+ content: ExternalContent[];
+}
+
+function JSONContent({ content }: JSONProps) {
+ return (
+ <>
+ {content.map((c, i) => {
+ if (!JSON.parse(c.json.content)) return <p key={i}>Error</p>;
+ else
+ return (
+ <p
+ key={JSON.stringify(c.json)}
+ className="external-content-warning"
+ >
+ External content from "{c.json.origin}", use
+ <a href="https://urbit.org/applications/~sortug/ufa">UFA</a>
+ to display.
+ </p>
+ );
+ })}
+ </>
+ );
+}
+export default JSONContent;
+
+export function YoutubeSnippet({ href, id }: { href: string; id: string }) {
+ const thumbnail = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
+ // todo styiling
+ return (
+ <Card logo="youtube" cn="youtube-thumbnail">
+ <a href={href}>
+ <img src={thumbnail} alt="" />
+ </a>
+ </Card>
+ );
+}
diff --git a/gui/src/components/post/Footer.tsx b/gui/src/components/post/Footer.tsx
new file mode 100644
index 0000000..5b79da0
--- /dev/null
+++ b/gui/src/components/post/Footer.tsx
@@ -0,0 +1,260 @@
+import type { PostProps } from "./Post";
+import Icon from "@/components/Icon";
+import { useState } from "react";
+import useLocalState from "@/state/state";
+import { useLocation } from "wouter";
+import { displayCount } from "@/logic/utils";
+import { TrillReactModal, stringToReact } from "./Reactions";
+import toast from "react-hot-toast";
+import NostrIcon from "./wrappers/NostrIcon";
+// TODO abstract this somehow
+
+function Footer({ poast, refetch }: PostProps) {
+ const [_showMenu, setShowMenu] = useState(false);
+ const [location, navigate] = useLocation();
+ const [reposting, _setReposting] = useState(false);
+ const { api, setComposerData, setModal, addNotification } = useLocalState(
+ (s) => ({
+ api: s.api,
+ setComposerData: s.setComposerData,
+ setModal: s.setModal,
+ addNotification: s.addNotification,
+ }),
+ );
+ const our = api!.airlock.our!;
+ function doReply(e: React.MouseEvent) {
+ console.log("do reply");
+ e.stopPropagation();
+ e.preventDefault();
+ setComposerData({ type: "reply", post: { trill: poast } });
+ // Scroll to top where composer is located
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ // Focus will be handled by the composer component
+ }
+ function doQuote(e: React.MouseEvent) {
+ e.stopPropagation();
+ e.preventDefault();
+ setComposerData({
+ type: "quote",
+ post: { trill: poast },
+ });
+ // Scroll to top where composer is located
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+ const childrenCount = poast.children
+ ? poast.children.length
+ ? poast.children.length
+ : Object.keys(poast.children).length
+ : 0;
+ const myRP = poast.engagement.shared.find((r) => r.pid.ship === our);
+ async function cancelRP(e: React.MouseEvent) {
+ e.stopPropagation();
+ e.preventDefault();
+ const r = await api!.deletePost(our);
+ if (r) toast.success("Repost deleted");
+ refetch();
+ if (location.includes(poast.id)) navigate("/");
+ }
+ async function sendRP(e: React.MouseEvent) {
+ // TODO update backend because contents are only markdown now
+ e.stopPropagation();
+ e.preventDefault();
+ // const c = [
+ // {
+ // ref: {
+ // type: "trill",
+ // ship: poast.host,
+ // path: `/${poast.id}`,
+ // },
+ // },
+ // ];
+ // const post: SentPoast = {
+ // host: our,
+ // author: our,
+ // thread: null,
+ // parent: null,
+ // contents: input,
+ // read: openLock,
+ // write: openLock,
+ // tags: [], // TODO
+ // };
+ // const r = await api!.addPost(post, false);
+ // setReposting(true);
+ // if (r) {
+ // setReposting(false);
+ // toast.success("Your post was published");
+ // }
+ }
+ function doReact(e: React.MouseEvent) {
+ e.stopPropagation();
+ e.preventDefault();
+ const modal = <TrillReactModal poast={poast} />;
+ setModal(modal);
+ }
+ function showReplyCount() {
+ if (poast.children[0]) fetchAndShow(); // Flatpoast
+ // else {
+ // const authors = Object.keys(poast.children).map(
+ // (i) => poast.children[i].post.author
+ // );
+ // setEngagement({ type: "replies", ships: authors }, poast);
+ // }
+ }
+ async function fetchAndShow() {
+ // let authors = [];
+ // for (let i of poast.children as string[]) {
+ // const res = await scrypoastFull(poast.host, i);
+ // if (res)
+ // authors.push(res.post.author || "deleter");
+ // }
+ // setEngagement({ type: "replies", ships: authors }, poast);
+ }
+ function showRepostCount() {
+ // const ships = poast.engagement.shared.map((entry) => entry.host);
+ // setEngagement({ type: "reposts", ships: ships }, poast);
+ }
+ function showQuoteCount() {
+ // setEngagement({ type: "quotes", quotes: poast.engagement.quoted }, poast);
+ }
+ function showReactCount() {
+ // setEngagement({ type: "reacts", reacts: poast.engagement.reacts }, poast);
+ }
+
+ const mostCommonReact = Object.values(poast.engagement.reacts).reduce(
+ (acc: any, item) => {
+ if (!acc.counts[item]) acc.counts[item] = 0;
+ acc.counts[item] += 1;
+ if (!acc.winner || acc.counts[item] > acc.counts[acc.winner])
+ acc.winner = item;
+ return acc;
+ },
+ { counts: {}, winner: "" },
+ ).winner;
+ const reactIcon = stringToReact(mostCommonReact);
+
+ // TODO round up all helpers
+
+ return (
+ <div className="footer-wrapper post-footer">
+ <footer>
+ <div className="icon">
+ <span role="link" onMouseUp={showReplyCount} className="reply-count">
+ {displayCount(childrenCount)}
+ </span>
+ <div className="icon-wrapper" role="link" onMouseUp={doReply}>
+ <Icon name="reply" size={20} />
+ </div>
+ </div>
+ <div className="icon">
+ <span role="link" onMouseUp={showQuoteCount} className="quote-count">
+ {displayCount(poast.engagement.quoted.length)}
+ </span>
+ <div className="icon-wrapper" role="link" onMouseUp={doQuote}>
+ <Icon name="quote" size={20} />
+ </div>
+ </div>
+ <div className="icon">
+ <span
+ role="link"
+ onMouseUp={showRepostCount}
+ className="repost-count"
+ >
+ {displayCount(poast.engagement.shared.length)}
+ </span>
+ {reposting ? (
+ <p>...</p>
+ ) : myRP ? (
+ <div className="icon-wrapper" role="link" onMouseUp={cancelRP}>
+ <Icon
+ name="repost"
+ size={20}
+ className="my-rp"
+ title="cancel repost"
+ />
+ </div>
+ ) : (
+ <div className="icon-wrapper" role="link" onMouseUp={sendRP}>
+ <Icon name="repost" size={20} title="repost" />
+ </div>
+ )}
+ </div>
+ <div className="icon" role="link" onMouseUp={doReact}>
+ <span
+ role="link"
+ onMouseUp={showReactCount}
+ className="reaction-count"
+ >
+ {displayCount(Object.keys(poast.engagement.reacts).length)}
+ </span>
+ {reactIcon}
+ </div>
+ <NostrIcon poast={poast} />
+ </footer>
+ </div>
+ );
+}
+export default Footer;
+
+// function Menu({
+// poast,
+// setShowMenu,
+// refetch,
+// }: {
+// poast: Poast;
+// setShowMenu: Function;
+// refetch: Function;
+// }) {
+// const ref = useRef<HTMLDivElement>(null);
+// const [location, navigate] = useLocation();
+// // TODO this is a mess and the event still propagates
+// useEffect(() => {
+// const checkIfClickedOutside = (e: any) => {
+// e.stopPropagation();
+// if (ref && ref.current && !ref.current.contains(e.target))
+// setShowMenu(false);
+// };
+// document.addEventListener("mousedown", checkIfClickedOutside);
+// return () => {
+// document.removeEventListener("mousedown", checkIfClickedOutside);
+// };
+// }, []);
+// const { our, setModal, setAlert } = useLocalState();
+// const mine = our === poast.host || our === poast.author;
+// async function doDelete(e: React.MouseEvent) {
+// e.stopPropagation();
+// deletePost(poast.host, poast.id);
+// setAlert("Post deleted");
+// setShowMenu(false);
+// refetch();
+// if (location.includes(poast.id)) navigate("/");
+// }
+// async function copyLink(e: React.MouseEvent) {
+// e.stopPropagation();
+// const link = trillPermalink(poast);
+// await navigator.clipboard.writeText(link);
+// // some alert
+// setShowMenu(false);
+// }
+// function openStats(e: React.MouseEvent) {
+// e.stopPropagation();
+// e.preventDefault();
+// const m = <StatsModal poast={poast} close={() => setModal(null)} />;
+// setModal(m);
+// }
+// return (
+// <div ref={ref} id="post-menu">
+// {/* <p onClick={openShare}>Share to Groups</p> */}
+// <p role="link" onMouseUp={openStats}>
+// See Stats
+// </p>
+// <p role="link" onMouseUp={copyLink}>
+// Permalink
+// </p>
+// {mine && (
+// <p role="link" onMouseUp={doDelete}>
+// Delete Post
+// </p>
+// )}
+// </div>
+// );
+// }
diff --git a/gui/src/components/post/Header.tsx b/gui/src/components/post/Header.tsx
new file mode 100644
index 0000000..4e72fe8
--- /dev/null
+++ b/gui/src/components/post/Header.tsx
@@ -0,0 +1,40 @@
+import { date_diff } from "@/logic/utils";
+import type { PostProps } from "./Post";
+import { useLocation } from "wouter";
+import useLocalState from "@/state/state";
+function Header(props: PostProps) {
+ const [_, navigate] = useLocation();
+ const profiles = useLocalState((s) => s.profiles);
+ const profile = profiles.get(props.poast.author);
+ // console.log("profile", profile);
+ // console.log(props.poast.author.length, "length");
+ function go(e: React.MouseEvent) {
+ e.stopPropagation();
+ }
+ function openThread(e: React.MouseEvent) {
+ e.stopPropagation();
+ const sel = window.getSelection()?.toString();
+ if (!sel) navigate(`/feed/${poast.host}/${poast.id}`);
+ }
+ const { poast } = props;
+ const name = profile ? (
+ profile.name
+ ) : (
+ <div className="name cp">
+ <p className="p-only">{poast.author}</p>
+ </div>
+ );
+ return (
+ <header>
+ <div className="author flex-align" role="link" onMouseUp={go}>
+ {name}
+ </div>
+ <div role="link" onMouseUp={openThread} className="date">
+ <p title={new Date(poast.time).toLocaleString()}>
+ {date_diff(poast.time, "short")}
+ </p>
+ </div>
+ </header>
+ );
+}
+export default Header;
diff --git a/gui/src/components/post/Loader.tsx b/gui/src/components/post/Loader.tsx
new file mode 100644
index 0000000..e45e01a
--- /dev/null
+++ b/gui/src/components/post/Loader.tsx
@@ -0,0 +1,148 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import spinner from "@/assets/triangles.svg";
+import { useEffect, useRef, useState } from "react";
+import useLocalState from "@/state/state";
+import type { FullNode, PostID } from "@/types/trill";
+import type { Ship } from "@/types/urbit";
+import type { AsyncRes } from "@/types/ui";
+import { toFlat } from "@/logic/trill/helpers";
+
+type Props = {
+ host: Ship;
+ id: PostID;
+ nest?: number; // nested quotes
+ rter?: Ship;
+ rtat?: number;
+ rtid?: PostID;
+ className?: string;
+};
+function PostData(props: Props) {
+ const { api } = useLocalState((s) => ({
+ api: s.api,
+ }));
+
+ const { host, id, nest } = props;
+
+ const [enest, setEnest] = useState(nest || 0);
+ useEffect(() => {
+ if (nest) setEnest(nest);
+ }, [nest]);
+
+ return function (Component: React.ElementType) {
+ // const [showNested, setShowNested] = useState(nest <= 3);
+ const handleShowNested = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setEnest(enest! - 3);
+ };
+ const [dead, setDead] = useState(false);
+ const [denied, setDenied] = useState(false);
+ const { isLoading, isError, data, refetch } = useQuery({
+ queryKey: ["trill-thread", host, id],
+ queryFn: fetchNode,
+ });
+ const queryClient = useQueryClient();
+ const dataRef = useRef(data);
+ useEffect(() => {
+ dataRef.current = data;
+ }, [data]);
+
+ async function fetchNode(): AsyncRes<FullNode> {
+ let error = "";
+ const res = await api!.scryThread(host, id);
+ console.log("scry res", res);
+ if ("error" in res) error = res.error;
+ if ("ok" in res) return res;
+ else {
+ const res2 = await api!.peekThread(host, id);
+ return res2;
+ }
+ }
+ async function peekTheNode() {
+ // let timer;
+ // peekNode({ ship: host, id });
+ // timer = setTimeout(() => {
+ // const gotPost = dataRef.current && "fpost" in dataRef.current;
+ // setDead(!gotPost);
+ // // clearTimeout(timer);
+ // }, 10_000);
+ }
+
+ // useEffect(() => {
+ // const path = `${host}/${id}`;
+ // if (path in peekedPosts) {
+ // queryClient.setQueryData(["trill-thread", host, id], {
+ // fpost: peekedPosts[path],
+ // });
+ // } else if (path in deniedPosts) {
+ // setDenied(true);
+ // }
+ // }, [peekedPosts]);
+ // useEffect(() => {
+ // const path = `${host}/${id}`;
+ // if (path in deniedPosts) setDenied(true);
+ // }, [deniedPosts]);
+
+ // useEffect(() => {
+ // const l = lastThread;
+ // if (l && l.thread == id) {
+ // queryClient.setQueryData(["trill-thread", host, id], { fpost: l });
+ // }
+ // }, [lastThread]);
+ function retryPeek(e: React.MouseEvent) {
+ // e.stopPropagation();
+ // setDead(false);
+ // peekTheNode();
+ }
+ if (enest > 3)
+ return (
+ <div className={props.className}>
+ <div className="lazy x-center not-found">
+ <button className="x-center" onMouseUp={handleShowNested}>
+ Load more
+ </button>
+ </div>
+ </div>
+ );
+ else
+ return data ? (
+ dead ? (
+ <div className={props.className}>
+ <div className="no-response x-center not-found">
+ <p>{host} did not respond</p>
+ <button className="x-center" onMouseUp={retryPeek}>
+ Try again
+ </button>
+ </div>
+ </div>
+ ) : denied ? (
+ <div className={props.className}>
+ <p className="x-center not-found">
+ {host} denied you access to this post
+ </p>
+ </div>
+ ) : "error" in data ? (
+ <div className={props.className}>
+ <p className="x-center not-found">Post not found</p>
+ <p className="x-center not-found">{data.error}</p>
+ </div>
+ ) : (
+ <Component
+ data={toFlat(data.ok)}
+ refetch={refetch}
+ {...props}
+ nest={enest}
+ />
+ )
+ ) : // no data
+ isLoading || isError ? (
+ <div className={props.className}>
+ <img className="x-center post-spinner" src={spinner} alt="" />
+ </div>
+ ) : (
+ <div className={props.className}>
+ <p>...</p>
+ </div>
+ );
+ };
+}
+export default PostData;
diff --git a/gui/src/components/post/Media.tsx b/gui/src/components/post/Media.tsx
new file mode 100644
index 0000000..04ea156
--- /dev/null
+++ b/gui/src/components/post/Media.tsx
@@ -0,0 +1,35 @@
+import type { Media } from "@/types/trill";
+interface Props {
+ media: Media[];
+}
+function M({ media }: Props) {
+ return (
+ <div className="body-media">
+ {media.map((m, i) => {
+ return "video" in m.media ? (
+ <video key={JSON.stringify(m) + i} src={m.media.video} controls />
+ ) : "audio" in m.media ? (
+ <audio key={JSON.stringify(m) + i} src={m.media.audio} controls />
+ ) : "images" in m.media ? (
+ <Images key={JSON.stringify(m) + i} urls={m.media.images} />
+ ) : null;
+ })}
+ </div>
+ );
+}
+export default M;
+
+function Images({ urls }: { urls: string[] }) {
+ return (
+ <>
+ {urls.map((u, i) => (
+ <img
+ key={u + i}
+ className={`body-img body-img-1-of-${urls.length}`}
+ src={u}
+ alt=""
+ />
+ ))}
+ </>
+ );
+}
diff --git a/gui/src/components/post/Post.tsx b/gui/src/components/post/Post.tsx
new file mode 100644
index 0000000..2965040
--- /dev/null
+++ b/gui/src/components/post/Post.tsx
@@ -0,0 +1,85 @@
+import type { PostID, Poast, Reference } from "@/types/trill";
+
+import Header from "./Header";
+import Body from "./Body";
+import Footer from "./Footer";
+import { useLocation } from "wouter";
+import useLocalState from "@/state/state";
+import RP from "./RP";
+import ShipModal from "../modals/ShipModal";
+import type { Ship } from "@/types/urbit";
+import Sigil from "../Sigil";
+import type { UserProfile } from "@/types/nostrill";
+
+export interface PostProps {
+ poast: Poast;
+ fake?: boolean;
+ rter?: Ship;
+ rtat?: number;
+ rtid?: PostID;
+ nest?: number;
+ refetch?: Function;
+ profile?: UserProfile;
+}
+function Post(props: PostProps) {
+ console.log("post", props);
+ const { poast } = props;
+ if (!poast || poast.contents === null) {
+ return null;
+ }
+ const isRP =
+ poast.contents.length === 1 &&
+ "ref" in poast.contents[0] &&
+ poast.contents[0].ref.type === "trill";
+ if (isRP) {
+ const ref = (poast.contents[0] as Reference).ref;
+ return (
+ <RP
+ host={ref.ship}
+ id={ref.path.slice(1)}
+ rter={poast.author}
+ rtat={poast.time}
+ rtid={poast.id}
+ />
+ );
+ } else return <TrillPost {...props} />;
+}
+export default Post;
+
+function TrillPost(props: PostProps) {
+ const { poast, profile, fake } = props;
+ const setModal = useLocalState((s) => s.setModal);
+ const [_, navigate] = useLocation();
+ function openThread(_e: React.MouseEvent) {
+ const sel = window.getSelection()?.toString();
+ if (!sel) navigate(`/feed/${poast.host}/${poast.id}`);
+ }
+
+ function openModal(e: React.MouseEvent) {
+ e.stopPropagation();
+ setModal(<ShipModal ship={poast.author} />);
+ }
+ const avatar = profile ? (
+ <div className="avatar cp" role="link" onMouseUp={openModal}>
+ <img src={profile.picture} />
+ </div>
+ ) : (
+ <div className="avatar sigil cp" role="link" onMouseUp={openModal}>
+ <Sigil patp={poast.author} size={46} />
+ </div>
+ );
+ return (
+ <div
+ className={`timeline-post trill-post cp`}
+ role="link"
+ onMouseUp={openThread}
+ >
+ <div className="left">{avatar}</div>
+ <div className="right">
+ <Header {...props} />
+ <Body {...props} />
+ {!fake && <Footer {...props} />}
+ </div>
+ </div>
+ );
+}
diff --git a/gui/src/components/post/PostWrapper.tsx b/gui/src/components/post/PostWrapper.tsx
new file mode 100644
index 0000000..c4e754f
--- /dev/null
+++ b/gui/src/components/post/PostWrapper.tsx
@@ -0,0 +1,14 @@
+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/Quote.tsx b/gui/src/components/post/Quote.tsx
new file mode 100644
index 0000000..28149f0
--- /dev/null
+++ b/gui/src/components/post/Quote.tsx
@@ -0,0 +1,64 @@
+import type { FullNode, Poast } from "@/types/trill";
+import { date_diff } from "@/logic/utils";
+import { useLocation } from "wouter";
+import Body from "./Body";
+import Sigil from "../Sigil";
+
+// function Quote({
+// data,
+// refetch,
+// nest,
+// }: {
+// data: FullNode;
+// refetch?: Function;
+// nest: number;
+// }) {
+// const [_, navigate] = useLocation();
+// function gotoQuote(e: React.MouseEvent) {
+// e.stopPropagation();
+// navigate(`/feed/${data.host}/${data.id}`);
+// }
+// return (
+// <div onMouseUp={gotoQuote} className="quote-in-post">
+// <header className="btw">
+// (
+// <div className="quote-author flex">
+// <Sigil patp={data.author} size={20} />
+// {data.author}
+// </div>
+// )<span>{date_diff(data.time, "short")}</span>
+// </header>
+// <Body poast={toFlat(data)} nest={nest} refetch={refetch!} />
+// </div>
+// );
+// }
+function Quote({
+ data,
+ refetch,
+ nest,
+}: {
+ data: Poast;
+ refetch?: Function;
+ nest: number;
+}) {
+ const [_, navigate] = useLocation();
+ function gotoQuote(e: React.MouseEvent) {
+ e.stopPropagation();
+ navigate(`/feed/${data.host}/${data.id}`);
+ }
+ return (
+ <div onMouseUp={gotoQuote} className="quote-in-post">
+ <header className="btw">
+ (
+ <div className="quote-author flex">
+ <Sigil patp={data.author} size={20} />
+ {data.author}
+ </div>
+ )<span>{date_diff(data.time, "short")}</span>
+ </header>
+ <Body poast={data} nest={nest} refetch={refetch!} />
+ </div>
+ );
+}
+
+export default Quote;
diff --git a/gui/src/components/post/RP.tsx b/gui/src/components/post/RP.tsx
new file mode 100644
index 0000000..27fa02d
--- /dev/null
+++ b/gui/src/components/post/RP.tsx
@@ -0,0 +1,47 @@
+import Post from "./Post";
+import type { Ship } from "@/types/urbit";
+import type { Poast, FullNode, ID } from "@/types/trill";
+import PostData from "./Loader";
+export default function (props: {
+ host: string;
+ id: string;
+ rter: Ship;
+ rtat: number;
+ rtid: ID;
+ refetch?: Function;
+}) {
+ return PostData(props)(RP);
+}
+
+function RP({
+ data,
+ refetch,
+ rter,
+ rtat,
+ rtid,
+}: {
+ data: FullNode;
+ refetch: Function;
+ rter: Ship;
+ rtat: number;
+ rtid: ID;
+}) {
+ return (
+ <Post
+ poast={toFlat(data)}
+ rter={rter}
+ rtat={rtat}
+ rtid={rtid}
+ refetch={refetch}
+ />
+ );
+}
+
+export function toFlat(n: FullNode): Poast {
+ return {
+ ...n,
+ children: !n.children
+ ? []
+ : Object.keys(n.children).map((c) => n.children[c].id),
+ };
+}
diff --git a/gui/src/components/post/Reactions.tsx b/gui/src/components/post/Reactions.tsx
new file mode 100644
index 0000000..ae75d8c
--- /dev/null
+++ b/gui/src/components/post/Reactions.tsx
@@ -0,0 +1,134 @@
+import type { Poast } from "@/types/trill";
+import yeschad from "@/assets/reacts/yeschad.png";
+import cringe from "@/assets/reacts/cringe.png";
+import cry from "@/assets/reacts/cry.png";
+import doom from "@/assets/reacts/doom.png";
+import galaxy from "@/assets/reacts/galaxy.png";
+import gigachad from "@/assets/reacts/gigachad.png";
+import pepechin from "@/assets/reacts/pepechin.png";
+import pepeeyes from "@/assets/reacts/pepeeyes.png";
+import pepegmi from "@/assets/reacts/pepegmi.png";
+import pepesad from "@/assets/reacts/pepesad.png";
+import pink from "@/assets/reacts/pink.png";
+import soy from "@/assets/reacts/soy.png";
+import chad from "@/assets/reacts/chad.png";
+import pika from "@/assets/reacts/pika.png";
+import facepalm from "@/assets/reacts/facepalm.png";
+import Icon from "@/components/Icon";
+import emojis from "@/logic/emojis.json";
+import Modal from "../modals/Modal";
+import useLocalState from "@/state/state";
+
+export function ReactModal({ send }: { send: (s: string) => Promise<number> }) {
+ const { setModal } = useLocalState((s) => ({ setModal: s.setModal }));
+ async function sendReact(e: React.MouseEvent, s: string) {
+ e.stopPropagation();
+ const res = await send(s);
+ if (res) setModal(null);
+ }
+ // todo one more meme
+ return (
+ <Modal>
+ <div id="react-list">
+ <span onMouseUp={(e) => sendReact(e, "❤️")}>️️❤️</span>
+ <span onMouseUp={(e) => sendReact(e, "🤔")}>🤔</span>
+ <span onMouseUp={(e) => sendReact(e, "😅")}>😅</span>
+ <span onMouseUp={(e) => sendReact(e, "🤬")}>🤬</span>
+ <span onMouseUp={(e) => sendReact(e, "😂")}>😂️</span>
+ <span onMouseUp={(e) => sendReact(e, "🫡")}>🫡️</span>
+ <span onMouseUp={(e) => sendReact(e, "🤢")}>🤢</span>
+ <span onMouseUp={(e) => sendReact(e, "😭")}>😭</span>
+ <span onMouseUp={(e) => sendReact(e, "😱")}>😱</span>
+ <img
+ onMouseUp={(e) => sendReact(e, "facepalm")}
+ src={facepalm}
+ alt=""
+ />
+ <span onMouseUp={(e) => sendReact(e, "👍")}>👍️</span>
+ <span onMouseUp={(e) => sendReact(e, "👎")}>👎️</span>
+ <span onMouseUp={(e) => sendReact(e, "☝")}>☝️</span>
+ <span onMouseUp={(e) => sendReact(e, "🤝")}>🤝</span>️
+ <span onMouseUp={(e) => sendReact(e, "🙏")}>🙏</span>
+ <span onMouseUp={(e) => sendReact(e, "🤡")}>🤡</span>
+ <span onMouseUp={(e) => sendReact(e, "👀")}>👀</span>
+ <span onMouseUp={(e) => sendReact(e, "🎤")}>🎤</span>
+ <span onMouseUp={(e) => sendReact(e, "💯")}>💯</span>
+ <span onMouseUp={(e) => sendReact(e, "🔥")}>🔥</span>
+ <img onMouseUp={(e) => sendReact(e, "yeschad")} src={yeschad} alt="" />
+ <img
+ onMouseUp={(e) => sendReact(e, "gigachad")}
+ src={gigachad}
+ alt=""
+ />
+ <img onMouseUp={(e) => sendReact(e, "pika")} src={pika} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "cringe")} src={cringe} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "pepegmi")} src={pepegmi} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "pepesad")} src={pepesad} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "galaxy")} src={galaxy} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "pink")} src={pink} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "soy")} src={soy} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "cry")} src={cry} alt="" />
+ <img onMouseUp={(e) => sendReact(e, "doom")} src={doom} alt="" />
+ </div>
+ </Modal>
+ );
+}
+
+export function stringToReact(s: string) {
+ const em = (emojis as Record<string, string>)[s.replace(/\:/g, "")];
+ if (s === "yeschad")
+ return <img className="react-img" src={yeschad} alt="" />;
+ if (s === "facepalm")
+ return <img className="react-img" src={facepalm} alt="" />;
+ if (s === "yes.jpg")
+ return <img className="react-img" src={yeschad} alt="" />;
+ if (s === "gigachad")
+ return <img className="react-img" src={gigachad} alt="" />;
+ if (s === "pepechin")
+ return <img className="react-img" src={pepechin} alt="" />;
+ if (s === "pepeeyes")
+ return <img className="react-img" src={pepeeyes} alt="" />;
+ if (s === "pepegmi")
+ return <img className="react-img" src={pepegmi} alt="" />;
+ if (s === "pepesad")
+ return <img className="react-img" src={pepesad} alt="" />;
+ if (s === "")
+ return <Icon name="emoji" size={20} className="react-img no-react" />;
+ if (s === "cringe") return <img className="react-img" src={cringe} alt="" />;
+ if (s === "cry") return <img className="react-img" src={cry} alt="" />;
+ if (s === "crywojak") return <img className="react-img" src={cry} alt="" />;
+ if (s === "doom") return <img className="react-img" src={doom} alt="" />;
+ if (s === "galaxy") return <img className="react-img" src={galaxy} alt="" />;
+ if (s === "pink") return <img className="react-img" src={pink} alt="" />;
+ if (s === "pinkwojak") return <img className="react-img" src={pink} alt="" />;
+ if (s === "soy") return <img className="react-img" src={soy} alt="" />;
+ if (s === "chad") return <img className="react-img" src={chad} alt="" />;
+ if (s === "pika") return <img className="react-img" src={pika} alt="" />;
+ if (em) return <span className="react-icon">{em}</span>;
+ else if (s.length > 2) return <span className="react-icon"></span>;
+ else return <span className="react-icon">{s}</span>;
+}
+
+export function TrillReactModal({ poast }: { poast: Poast }) {
+ const { api, addNotification } = useLocalState((s) => ({
+ api: s.api,
+ addNotification: s.addNotification,
+ }));
+ const our = api!.airlock.our!;
+
+ async function sendReact(s: string) {
+ const result = await api!.addReact(poast.host, poast.id, s);
+ // Only add notification if reacting to someone else's post
+ if (result && poast.author !== our) {
+ addNotification({
+ type: "react",
+ from: our,
+ message: `You reacted to ${poast.author}'s post`,
+ reaction: s,
+ postId: poast.id,
+ });
+ }
+ return result;
+ }
+ return <ReactModal send={sendReact} />;
+}
diff --git a/gui/src/components/post/StatsModal.tsx b/gui/src/components/post/StatsModal.tsx
new file mode 100644
index 0000000..4720b2a
--- /dev/null
+++ b/gui/src/components/post/StatsModal.tsx
@@ -0,0 +1,106 @@
+import type { Poast } from "@/types/trill";
+import Modal from "../modals/Modal";
+import { useState } from "react";
+import Post from "./Post";
+import RP from "./RP";
+import Avatar from "../Avatar";
+import { stringToReact } from "./Reactions";
+
+function StatsModal({ poast, close }: { close: any; poast: Poast }) {
+ const [tab, setTab] = useState("replies");
+ const replies = poast.children || [];
+ const quotes = poast.engagement.quoted;
+ const reposts = poast.engagement.shared;
+ const reacts = poast.engagement.reacts;
+ function set(e: React.MouseEvent, s: string) {
+ e.stopPropagation();
+ setTab(s);
+ }
+ // TODO revise the global thingy here
+ return (
+ <Modal close={close}>
+ <div id="stats-modal">
+ <Post poast={poast} refetch={() => {}} />
+ <div id="tabs">
+ <div
+ role="link"
+ className={"tab" + (tab === "replies" ? " active-tab" : "")}
+ onClick={(e) => set(e, "replies")}
+ >
+ <h4>Replies</h4>
+ </div>
+ <div
+ role="link"
+ className={"tab" + (tab === "quotes" ? " active-tab" : "")}
+ onClick={(e) => set(e, "quotes")}
+ >
+ <h4>Quotes</h4>
+ </div>
+ <div
+ role="link"
+ className={"tab" + (tab === "reposts" ? " active-tab" : "")}
+ onClick={(e) => set(e, "reposts")}
+ >
+ <h4>Reposts</h4>
+ </div>
+ <div
+ role="link"
+ className={"tab" + (tab === "reacts" ? " active-tab" : "")}
+ onClick={(e) => set(e, "reacts")}
+ >
+ <h4>Reacts</h4>
+ </div>
+ </div>
+ <div id="engagement">
+ {tab === "replies" ? (
+ <div id="replies">
+ {replies.map((p) => (
+ <div key={p} className="reply-stat">
+ <RP
+ host={poast.host}
+ id={p}
+ rter={undefined}
+ rtat={undefined}
+ rtid={undefined}
+ />
+ </div>
+ ))}
+ </div>
+ ) : tab === "quotes" ? (
+ <div id="quotes">
+ {quotes.map((p) => (
+ <div key={p.pid.id} className="quote-stat">
+ <RP
+ host={p.pid.ship}
+ id={p.pid.id}
+ rter={undefined}
+ rtat={undefined}
+ rtid={undefined}
+ />
+ </div>
+ ))}
+ </div>
+ ) : tab === "reposts" ? (
+ <div id="reposts">
+ {reposts.map((p) => (
+ <div key={p.pid.id} className="repost-stat">
+ <Avatar p={p.pid.ship} size={40} />
+ </div>
+ ))}
+ </div>
+ ) : tab === "reacts" ? (
+ <div id="reacts">
+ {Object.keys(reacts).map((p) => (
+ <div key={p} className="react-stat btw">
+ <Avatar p={p} size={32} />
+ {stringToReact(reacts[p])}
+ </div>
+ ))}
+ </div>
+ ) : null}
+ </div>
+ </div>
+ </Modal>
+ );
+}
+export default StatsModal;
diff --git a/gui/src/components/post/wrappers/Nostr.tsx b/gui/src/components/post/wrappers/Nostr.tsx
new file mode 100644
index 0000000..2782fb8
--- /dev/null
+++ b/gui/src/components/post/wrappers/Nostr.tsx
@@ -0,0 +1,15 @@
+import type { NostrMetadata, NostrPost } from "@/types/nostrill";
+import Post from "../Post";
+import useLocalState from "@/state/state";
+
+export default NostrPost;
+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} />;
+}
+
+export function NostrSnippet({ eventId, pubkey, relay }: NostrMetadata) {
+ return <div>wtf</div>;
+}
diff --git a/gui/src/components/post/wrappers/NostrIcon.tsx b/gui/src/components/post/wrappers/NostrIcon.tsx
new file mode 100644
index 0000000..30fbfe9
--- /dev/null
+++ b/gui/src/components/post/wrappers/NostrIcon.tsx
@@ -0,0 +1,25 @@
+import Icon from "@/components/Icon";
+import useLocalState from "@/state/state";
+import toast from "react-hot-toast";
+import type { Poast } from "@/types/trill";
+export default function ({ poast }: { poast: Poast }) {
+ const { relays, api } = useLocalState((s) => ({
+ relays: s.relays,
+ api: s.api,
+ }));
+
+ async function sendToRelay(e: React.MouseEvent) {
+ e.stopPropagation();
+ //
+ const urls = Object.keys(relays);
+ await api!.relayPost(poast.host, poast.id, urls);
+ toast.success("Post relayed");
+ }
+ // TODO round up all helpers
+
+ return (
+ <div className="icon" role="link" onMouseUp={sendToRelay}>
+ <Icon name="nostr" size={20} title="relay to nostr" />
+ </div>
+ );
+}
diff --git a/gui/src/components/profile/Editor.tsx b/gui/src/components/profile/Editor.tsx
new file mode 100644
index 0000000..2e4aebc
--- /dev/null
+++ b/gui/src/components/profile/Editor.tsx
@@ -0,0 +1,262 @@
+import { useState } from "react";
+import type { UserProfile, UserType } from "@/types/nostrill";
+import useLocalState from "@/state/state";
+import Icon from "@/components/Icon";
+import toast from "react-hot-toast";
+import Avatar from "../Avatar";
+
+interface ProfileEditorProps {
+ user: UserType;
+ userString: string;
+ profile: UserProfile | undefined;
+ onSave?: () => void;
+}
+
+const ProfileEditor: React.FC<ProfileEditorProps> = ({
+ user,
+ profile,
+ userString,
+ onSave,
+}) => {
+ const { api, profiles } = useLocalState((s) => ({
+ api: s.api,
+ pubkey: s.pubkey,
+ profiles: s.profiles,
+ }));
+
+ // Initialize state with existing profile or defaults
+ const [name, setName] = useState(profile?.name || userString);
+ const [picture, setPicture] = useState(profile?.picture || "");
+ const [about, setAbout] = useState(profile?.about || "");
+ const [customFields, setCustomFields] = useState<
+ Array<{ key: string; value: string }>
+ >(
+ Object.entries(profile?.other || {}).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ );
+ const [isEditing, setIsEditing] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const handleAddCustomField = () => {
+ setCustomFields([...customFields, { key: "", value: "" }]);
+ };
+
+ const handleUpdateCustomField = (
+ index: number,
+ field: "key" | "value",
+ newValue: string,
+ ) => {
+ const updated = [...customFields];
+ updated[index][field] = newValue;
+ setCustomFields(updated);
+ };
+
+ const handleRemoveCustomField = (index: number) => {
+ setCustomFields(customFields.filter((_, i) => i !== index));
+ };
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ try {
+ // Convert custom fields array to object
+ const other: Record<string, string> = {};
+ customFields.forEach(({ key, value }) => {
+ if (key.trim()) {
+ other[key.trim()] = value;
+ }
+ });
+
+ const nprofile: UserProfile = {
+ name,
+ picture,
+ about,
+ other,
+ };
+
+ // Call API to save profile
+ if (api && typeof api.createProfile === "function") {
+ await api.createProfile(nprofile);
+ } else {
+ throw new Error("Profile update API not available");
+ }
+
+ toast.success("Profile updated successfully");
+ setIsEditing(false);
+ onSave?.();
+ } catch (error) {
+ toast.error("Failed to update profile");
+ console.error("Failed to save profile:", error);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleCancel = () => {
+ // Reset to original values
+ const profile = profiles.get(userString);
+ if (profile) {
+ setName(profile.name || userString);
+ setPicture(profile.picture || "");
+ setAbout(profile.about || "");
+ setCustomFields(
+ Object.entries(profile.other || {}).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ );
+ }
+ setIsEditing(false);
+ };
+ console.log({ profile });
+ console.log({ name, picture, customFields });
+
+ return (
+ <div className="profile-editor">
+ <div className="profile-header">
+ <h2>Edit Profile</h2>
+ {!isEditing && (
+ <button onClick={() => setIsEditing(true)} className="edit-btn">
+ <Icon name="settings" size={16} />
+ Edit
+ </button>
+ )}
+ </div>
+
+ {isEditing ? (
+ <div className="profile-form">
+ <div className="form-group">
+ <label htmlFor="name">Display Name</label>
+ <input
+ id="name"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="Your display name"
+ />
+ </div>
+
+ <div className="form-group">
+ <label htmlFor="picture">Profile Picture URL</label>
+ <input
+ id="picture"
+ type="url"
+ value={picture}
+ onChange={(e) => setPicture(e.target.value)}
+ placeholder="https://example.com/avatar.jpg"
+ />
+ <div className="picture-preview">
+ {picture ? (
+ <img src={picture} />
+ ) : (
+ <Avatar
+ user={user}
+ userString={userString}
+ profile={profile}
+ size={120}
+ picOnly={true}
+ />
+ )}
+ </div>
+ </div>
+
+ <div className="form-group">
+ <label htmlFor="about">About</label>
+ <textarea
+ id="about"
+ value={about}
+ onChange={(e) => setAbout(e.target.value)}
+ placeholder="Tell us about yourself..."
+ rows={4}
+ />
+ </div>
+
+ <div className="form-group custom-fields">
+ <label>Custom Fields</label>
+ {customFields.map((field, index) => (
+ <div key={index} className="custom-field-row">
+ <input
+ type="text"
+ value={field.key}
+ onChange={(e) =>
+ handleUpdateCustomField(index, "key", e.target.value)
+ }
+ placeholder="Field name"
+ className="field-key-input"
+ />
+ <input
+ type="text"
+ value={field.value}
+ onChange={(e) =>
+ handleUpdateCustomField(index, "value", e.target.value)
+ }
+ placeholder="Field value"
+ className="field-value-input"
+ />
+ <button
+ onClick={() => handleRemoveCustomField(index)}
+ className="remove-field-btn"
+ title="Remove field"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ <button onClick={handleAddCustomField} className="add-field-btn">
+ + Add Custom Field
+ </button>
+ </div>
+
+ <div className="form-actions">
+ <button
+ onClick={handleSave}
+ disabled={isSaving}
+ className="save-btn"
+ >
+ {isSaving ? "Saving..." : "Save Profile"}
+ </button>
+ <button
+ onClick={handleCancel}
+ disabled={isSaving}
+ className="cancel-btn"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className="profile-view">
+ <div className="profile-picture">
+ <Avatar
+ user={user}
+ userString={userString}
+ profile={profile}
+ size={120}
+ picOnly={true}
+ />
+ </div>
+
+ <div className="profile-info">
+ <h3>{name}</h3>
+ {about && <p className="profile-about">{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>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default ProfileEditor;
diff --git a/gui/src/components/profile/Profile.tsx b/gui/src/components/profile/Profile.tsx
new file mode 100644
index 0000000..b5f22e9
--- /dev/null
+++ b/gui/src/components/profile/Profile.tsx
@@ -0,0 +1,67 @@
+import "@/styles/Profile.css";
+import type { UserProfile, UserType } from "@/types/nostrill";
+import useLocalState from "@/state/state";
+import Avatar from "../Avatar";
+import ProfileEditor from "./Editor";
+
+interface Props {
+ user: UserType;
+ userString: string;
+ isMe: boolean;
+ onSave?: () => void;
+}
+
+const Loader: React.FC<Props> = (props) => {
+ const { profiles } = useLocalState((s) => ({
+ profiles: s.profiles,
+ }));
+ const profile = profiles.get(props.userString);
+
+ if (props.isMe) return <ProfileEditor {...props} profile={profile} />;
+ else return <Profile profile={profile} {...props} />;
+};
+function Profile({
+ user,
+ userString,
+ profile,
+}: {
+ user: UserType;
+ userString: string;
+ profile: UserProfile | undefined;
+}) {
+ // Initialize state with existing profile or defaults
+
+ // View-only mode for other users' profiles - no editing allowed
+ 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>
+ <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>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export default Loader;