From 8751ba26ebf7b7761b9e237f2bf3453623dd1018 Mon Sep 17 00:00:00 2001 From: polwex Date: Mon, 6 Oct 2025 10:13:39 +0700 Subject: added frontend WS connection for demonstration purposes --- gui/src/components/Avatar.tsx | 62 ++++++ gui/src/components/Icon.tsx | 137 +++++++++++++ gui/src/components/NotificationCenter.tsx | 192 ++++++++++++++++++ gui/src/components/Sigil.tsx | 50 +++++ gui/src/components/WsWidget.tsx | 123 ++++++++++++ gui/src/components/composer/Composer.tsx | 205 +++++++++++++++++++ gui/src/components/composer/Snippets.tsx | 86 ++++++++ gui/src/components/feed/PostList.tsx | 33 ++++ gui/src/components/layout/Sidebar.tsx | 80 ++++++++ gui/src/components/modals/Modal.tsx | 72 +++++++ gui/src/components/modals/ShipModal.tsx | 48 +++++ gui/src/components/modals/UserModal.tsx | 65 ++++++ gui/src/components/post/Body.tsx | 174 ++++++++++++++++ gui/src/components/post/Card.tsx | 12 ++ gui/src/components/post/External.tsx | 40 ++++ gui/src/components/post/Footer.tsx | 260 ++++++++++++++++++++++++ gui/src/components/post/Header.tsx | 40 ++++ gui/src/components/post/Loader.tsx | 148 ++++++++++++++ gui/src/components/post/Media.tsx | 35 ++++ gui/src/components/post/Post.tsx | 85 ++++++++ gui/src/components/post/PostWrapper.tsx | 14 ++ gui/src/components/post/Quote.tsx | 64 ++++++ gui/src/components/post/RP.tsx | 47 +++++ gui/src/components/post/Reactions.tsx | 134 +++++++++++++ gui/src/components/post/StatsModal.tsx | 106 ++++++++++ gui/src/components/post/wrappers/Nostr.tsx | 15 ++ gui/src/components/post/wrappers/NostrIcon.tsx | 25 +++ gui/src/components/profile/Editor.tsx | 262 +++++++++++++++++++++++++ gui/src/components/profile/Profile.tsx | 67 +++++++ 29 files changed, 2681 insertions(+) create mode 100644 gui/src/components/Avatar.tsx create mode 100644 gui/src/components/Icon.tsx create mode 100644 gui/src/components/NotificationCenter.tsx create mode 100644 gui/src/components/Sigil.tsx create mode 100644 gui/src/components/WsWidget.tsx create mode 100644 gui/src/components/composer/Composer.tsx create mode 100644 gui/src/components/composer/Snippets.tsx create mode 100644 gui/src/components/feed/PostList.tsx create mode 100644 gui/src/components/layout/Sidebar.tsx create mode 100644 gui/src/components/modals/Modal.tsx create mode 100644 gui/src/components/modals/ShipModal.tsx create mode 100644 gui/src/components/modals/UserModal.tsx create mode 100644 gui/src/components/post/Body.tsx create mode 100644 gui/src/components/post/Card.tsx create mode 100644 gui/src/components/post/External.tsx create mode 100644 gui/src/components/post/Footer.tsx create mode 100644 gui/src/components/post/Header.tsx create mode 100644 gui/src/components/post/Loader.tsx create mode 100644 gui/src/components/post/Media.tsx create mode 100644 gui/src/components/post/Post.tsx create mode 100644 gui/src/components/post/PostWrapper.tsx create mode 100644 gui/src/components/post/Quote.tsx create mode 100644 gui/src/components/post/RP.tsx create mode 100644 gui/src/components/post/Reactions.tsx create mode 100644 gui/src/components/post/StatsModal.tsx create mode 100644 gui/src/components/post/wrappers/Nostr.tsx create mode 100644 gui/src/components/post/wrappers/NostrIcon.tsx create mode 100644 gui/src/components/profile/Editor.tsx create mode 100644 gui/src/components/profile/Profile.tsx (limited to 'gui/src/components') 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 ? ( + + ) : "urbit" in user && isValidPatp(user.urbit) ? ( + + ) : ( + + ); + const avatar = ( +
+ {avatarInner} +
+ ); + if (picOnly) return avatar; + + const tooLong = (s: string) => (s.length > 15 ? " too-long" : ""); + function openModal(e: React.MouseEvent) { + if (noClickOnName) return; + e.stopPropagation(); + setModal(); + } + const name = ( +
+ {profile ? ( +

{profile.name}

+ ) : "urbit" in user ? ( +

+ {user.urbit.length > 28 ? "Anon" : user.urbit} +

+ ) : ( +

{user.nostr}

+ )} +
+ ); + return
{name}
; +} 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 = { + 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 = ({ + 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 ( + {title + ); +}; + +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 ( + setModal(null)}> +
+
+

Notifications

+
+ {unreadNotifications > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ +
+ + +
+ +
+ {filteredNotifications.length === 0 ? ( +
+ +

No {filter === "unread" ? "unread " : ""}notifications

+
+ ) : ( + filteredNotifications.map((notification) => ( +
handleNotificationClick(notification)} + > +
+ +
+ +
+
+ +
+

{getNotificationText(notification)}

+ + {formatTimestamp(notification.timestamp)} + +
+
+
+ + {!notification.read &&
} +
+ )) + )} +
+
+ + ); +}; + +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 ( + + ); + 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 ( +
+
+

WebSocketWidget

+ + {status.toUpperCase()} {retryCount ? `(retry ${retryCount})` : ""} + +
+ +
+
+ URL: {url} +
+
+ Buffered: {bufferedAmount} bytes +
+ {error && ( +
+ Error:{" "} + {"message" in error + ? (error as any).message + : String(error.type || "error")} +
+ )} +
+ +
+
Last message:
+
+ {lastMessage + ? typeof lastMessage.data === "string" + ? lastMessage.data + : "(binary)" + : "—"} +
+
+ +
{ + e.preventDefault(); + if (!outbound) return; + send(outbound); + setOutbound(""); + }} + > + setOutbound(e.target.value)} + /> + +
+ +
+ + +
+ +
+ Usage +
+          {`import WebSocketWidget from "./WebSocketWidget";
+
+export default function App() {
+  return (
+    
+ +
+ ); +} +`} +
+
+
+ ); +} 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(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) { + 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 ( +
+
+ +
+ +
+ {/* Reply snippets appear above input */} + {composerData && composerData.type === "reply" && ( +
+
+ + Replying to + + +
+ +
+ )} + + {/* Quote context header above input (without snippet) */} + {composerData && composerData.type === "quote" && ( +
+
+ + Quote posting + + +
+
+ )} + +
+ setInput(e.currentTarget.value)} + onFocus={() => setIsExpanded(true)} + placeholder={placeHolder} + /> + +
+ + {/* Quote snippets appear below input */} + {composerData && composerData.type === "quote" && ( +
+ +
+ )} +
+
+ ); +} + +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 ( + + + + ); +} + +export function ComposerSnippet({ + onClick, + children, +}: { + onClick?: any; + children: any; +}) { + function onc(e: React.MouseEvent) { + e.stopPropagation(); + if (onClick) onClick(); + } + return ( +
+ {onClick && ( +
+ × +
+ )} + {children} +
+ ); +} +function PostSnippet({ post }: { post: SPID }) { + if (!post) return
No post data
; + + try { + if ("trill" in post) return ; + else if ("nostr" in post) return ; + // else if ("twatter" in post) + // return ( + //
+ // + //
+ // ); + // else if ("rumors" in post) + // return ( + //
+ //
+ // + // {}} /> + // {date_diff(post.post.time, "short")} + //
+ //
+ // ); + else return
Unsupported post type
; + } catch (error) { + console.error("Error rendering post snippet:", error); + return
Failed to load post
; + } +} + +export function ReplySnippet({ post }: { post: SPID }) { + if (!post) return
No post to reply to
; + + try { + if ("trill" in post) + return ( +
+ +
+ ); + else if ("nostr" in post) + return ( +
+ +
+ ); + else return
Cannot reply to this post type
; + } catch (error) { + console.error("Error rendering reply snippet:", error); + return
Failed to load reply context
; + } +} 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) => ( + + ))} + + ); +} + +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(); + }); + } + return ( +
+ +

Feeds

+
goto(`/feed/global`)}> + +
Home
+
+
+
+ + {unreadNotifications > 0 && ( + + {unreadNotifications > 99 ? "99+" : unreadNotifications} + + )} +
+
Notifications
+
+
+ +
goto("/chat")} + > + +
Messages
+
+
goto("/pals")}> + +
Pals
+
+
+
goto(`/feed/${api!.airlock.our}`)} + > + +
Profile
+
+
+
goto("/sets")}> + +
Settings
+
+ +
+ ); +} +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 ( + + ); +} +export default Modal; + +export function Welcome() { + return ( + +
+

Welcome to Nostril!

+

+ Trill is the world's only truly free and sovereign social media + platform, powered by Urbit. +

+

+ Click on the crow icon on the top left to see all available feeds. +

+

The Global feed should be populated by default.

+

Follow people soon so your Global feed doesn't go stale.

+

+ 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. +

+

+ If you have any feedback please reach out to us on Groups at + ~hoster-dozzod-sortug/trill or here at ~polwex +

+
+
+ ); +} + +export function Tooltip({ children, text, className }: any) { + const [show, toggle] = useState(false); + return ( +
toggle(true)} + onMouseOut={() => toggle(false)} + > + {children} + {show &&
{text}
} +
+ ); +} 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 ( + +
+
+ + +
+
+ + + {ship !== api!.airlock.our && ( + <> + + + )} +
+
+
+ ); +} 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 ( + +
+
+ + +
+
+ + + {itsMe && ( + <> + + + )} +
+
+
+ ); +} 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 ( +
+
+ {text.map((b, i) => ( + + ))} +
+ {media.length > 0 && } + {refs.map((r, i) => ( + + ))} + +
+ ); +} +export default Body; + +function TextBlock({ block }: { block: Block }) { + const key = JSON.stringify(block); + return "paragraph" in block ? ( +
+ {block.paragraph.map((i, ind) => ( + + ))} +
+ ) : "blockquote" in block ? ( +
+ {block.blockquote.map((i, ind) => ( + + ))} +
+ ) : "heading" in block ? ( + + ) : "codeblock" in block ? ( +
+      
+        {block.codeblock.code}
+      
+    
+ ) : "list" in block ? ( + block.list.ordered ? ( +
    + {block.list.text.map((i, ind) => ( +
  1. + +
  2. + ))} +
+ ) : ( +
    + {block.list.text.map((i, ind) => ( +
  • + +
  • + ))} +
+ ) + ) : 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 ? ( + {i.text} + ) : "italic" in i ? ( + {i.italic} + ) : "bold" in i ? ( + {i.bold} + ) : "strike" in i ? ( + {i.strike} + ) : "underline" in i ? ( + {i.underline} + ) : "sup" in i ? ( + {i.sup} + ) : "sub" in i ? ( + {i.sub} + ) : "ship" in i ? ( + gotoShip(e, i.ship)} + > + {i.ship} + + ) : "codespan" in i ? ( + {i.codespan} + ) : "link" in i ? ( + + ) : "break" in i ? ( +
+ ) : 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 ? ( + + ) : ( + {show} + ); +} +function Heading({ string, num }: { string: string; num: number }) { + return num === 1 ? ( +

{string}

+ ) : num === 2 ? ( +

{string}

+ ) : num === 3 ? ( +

{string}

+ ) : num === 4 ? ( +

{string}

+ ) : num === 5 ? ( +
{string}
+ ) : num === 6 ? ( +
{string}
+ ) : 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 {comp}; + } + 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 ( +
+ + {children} +
+ ); +} 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

Error

; + else + return ( +

+ External content from "{c.json.origin}", use + UFA + to display. +

+ ); + })} + + ); +} +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 ( + + + + + + ); +} 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 = ; + 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 ( +
+
+
+ + {displayCount(childrenCount)} + +
+ +
+
+
+ + {displayCount(poast.engagement.quoted.length)} + +
+ +
+
+
+ + {displayCount(poast.engagement.shared.length)} + + {reposting ? ( +

...

+ ) : myRP ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + {displayCount(Object.keys(poast.engagement.reacts).length)} + + {reactIcon} +
+ +
+
+ ); +} +export default Footer; + +// function Menu({ +// poast, +// setShowMenu, +// refetch, +// }: { +// poast: Poast; +// setShowMenu: Function; +// refetch: Function; +// }) { +// const ref = useRef(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 = setModal(null)} />; +// setModal(m); +// } +// return ( +//
+// {/*

Share to Groups

*/} +//

+// See Stats +//

+//

+// Permalink +//

+// {mine && ( +//

+// Delete Post +//

+// )} +//
+// ); +// } 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 + ) : ( +
+

{poast.author}

+
+ ); + return ( +
+
+ {name} +
+
+

+ {date_diff(poast.time, "short")} +

+
+
+ ); +} +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 { + 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 ( +
+
+ +
+
+ ); + else + return data ? ( + dead ? ( +
+
+

{host} did not respond

+ +
+
+ ) : denied ? ( +
+

+ {host} denied you access to this post +

+
+ ) : "error" in data ? ( +
+

Post not found

+

{data.error}

+
+ ) : ( + + ) + ) : // no data + isLoading || isError ? ( +
+ +
+ ) : ( +
+

...

+
+ ); + }; +} +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 ( +
+ {media.map((m, i) => { + return "video" in m.media ? ( +
+ ); +} +export default M; + +function Images({ urls }: { urls: string[] }) { + return ( + <> + {urls.map((u, i) => ( + + ))} + + ); +} 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 ( + + ); + } else return ; +} +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(); + } + const avatar = profile ? ( +
+ +
+ ) : ( +
+ +
+ ); + return ( +
+
{avatar}
+
+
+ + {!fake &&
} +
+
+ ); +} 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 ; + else return ; +} + +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 ( +//
+//
+// ( +//
+// +// {data.author} +//
+// ){date_diff(data.time, "short")} +//
+// +//
+// ); +// } +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 ( +
+
+ ( +
+ + {data.author} +
+ ){date_diff(data.time, "short")} +
+ +
+ ); +} + +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 ( + + ); +} + +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 }) { + 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 ( + +
+ sendReact(e, "❤️")}>️️❤️ + sendReact(e, "🤔")}>🤔 + sendReact(e, "😅")}>😅 + sendReact(e, "🤬")}>🤬 + sendReact(e, "😂")}>😂️ + sendReact(e, "🫡")}>🫡️ + sendReact(e, "🤢")}>🤢 + sendReact(e, "😭")}>😭 + sendReact(e, "😱")}>😱 + sendReact(e, "facepalm")} + src={facepalm} + alt="" + /> + sendReact(e, "👍")}>👍️ + sendReact(e, "👎")}>👎️ + sendReact(e, "☝")}>☝️ + sendReact(e, "🤝")}>🤝️ + sendReact(e, "🙏")}>🙏 + sendReact(e, "🤡")}>🤡 + sendReact(e, "👀")}>👀 + sendReact(e, "🎤")}>🎤 + sendReact(e, "💯")}>💯 + sendReact(e, "🔥")}>🔥 + sendReact(e, "yeschad")} src={yeschad} alt="" /> + sendReact(e, "gigachad")} + src={gigachad} + alt="" + /> + sendReact(e, "pika")} src={pika} alt="" /> + sendReact(e, "cringe")} src={cringe} alt="" /> + sendReact(e, "pepegmi")} src={pepegmi} alt="" /> + sendReact(e, "pepesad")} src={pepesad} alt="" /> + sendReact(e, "galaxy")} src={galaxy} alt="" /> + sendReact(e, "pink")} src={pink} alt="" /> + sendReact(e, "soy")} src={soy} alt="" /> + sendReact(e, "cry")} src={cry} alt="" /> + sendReact(e, "doom")} src={doom} alt="" /> +
+
+ ); +} + +export function stringToReact(s: string) { + const em = (emojis as Record)[s.replace(/\:/g, "")]; + if (s === "yeschad") + return ; + if (s === "facepalm") + return ; + if (s === "yes.jpg") + return ; + if (s === "gigachad") + return ; + if (s === "pepechin") + return ; + if (s === "pepeeyes") + return ; + if (s === "pepegmi") + return ; + if (s === "pepesad") + return ; + if (s === "") + return ; + if (s === "cringe") return ; + if (s === "cry") return ; + if (s === "crywojak") return ; + if (s === "doom") return ; + if (s === "galaxy") return ; + if (s === "pink") return ; + if (s === "pinkwojak") return ; + if (s === "soy") return ; + if (s === "chad") return ; + if (s === "pika") return ; + if (em) return {em}; + else if (s.length > 2) return ; + else return {s}; +} + +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 ; +} 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 ( + +
+ {}} /> +
+
set(e, "replies")} + > +

Replies

+
+
set(e, "quotes")} + > +

Quotes

+
+
set(e, "reposts")} + > +

Reposts

+
+
set(e, "reacts")} + > +

Reacts

+
+
+
+ {tab === "replies" ? ( +
+ {replies.map((p) => ( +
+ +
+ ))} +
+ ) : tab === "quotes" ? ( +
+ {quotes.map((p) => ( +
+ +
+ ))} +
+ ) : tab === "reposts" ? ( +
+ {reposts.map((p) => ( +
+ +
+ ))} +
+ ) : tab === "reacts" ? ( +
+ {Object.keys(reacts).map((p) => ( +
+ + {stringToReact(reacts[p])} +
+ ))} +
+ ) : null} +
+
+
+ ); +} +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 ; +} + +export function NostrSnippet({ eventId, pubkey, relay }: NostrMetadata) { + return
wtf
; +} 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 ( +
+ +
+ ); +} 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 = ({ + 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 = {}; + 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 ( +
+
+

Edit Profile

+ {!isEditing && ( + + )} +
+ + {isEditing ? ( +
+
+ + setName(e.target.value)} + placeholder="Your display name" + /> +
+ +
+ + setPicture(e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> +
+ {picture ? ( + + ) : ( + + )} +
+
+ +
+ +