diff options
| author | polwex <polwex@sortug.com> | 2025-11-18 16:59:24 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-11-18 16:59:24 +0700 |
| commit | 420a543f8af3075502b0a7530a0fa06af264eb8b (patch) | |
| tree | 7446a8c59bd4b66ca2bb5ed04bce40d382e31c36 /gui/src/components | |
| parent | 76f99af3c98a689441315b5ed087c4b83c083191 (diff) | |
refactoring gui too, improvements to Nostr user logic
Diffstat (limited to 'gui/src/components')
| -rw-r--r-- | gui/src/components/layout/Sidebar.tsx | 20 | ||||
| -rw-r--r-- | gui/src/components/modals/UserModal.tsx | 98 | ||||
| -rw-r--r-- | gui/src/components/nostr/Feed.tsx | 1 | ||||
| -rw-r--r-- | gui/src/components/nostr/User.tsx | 128 | ||||
| -rw-r--r-- | gui/src/components/post/Header.tsx | 4 | ||||
| -rw-r--r-- | gui/src/components/profile/Profile.tsx | 52 | ||||
| -rw-r--r-- | gui/src/components/trill/User.tsx | 180 |
7 files changed, 414 insertions, 69 deletions
diff --git a/gui/src/components/layout/Sidebar.tsx b/gui/src/components/layout/Sidebar.tsx index c267e2f..bc11e48 100644 --- a/gui/src/components/layout/Sidebar.tsx +++ b/gui/src/components/layout/Sidebar.tsx @@ -7,16 +7,16 @@ import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; function SlidingMenu() { const [_, navigate] = useLocation(); - const { api, unreadNotifications, setModal } = useLocalState((s) => ({ + const { api, unreadNotifications, setModal } = useLocalState((s) => ({ api: s.api, unreadNotifications: s.unreadNotifications, - setModal: s.setModal + setModal: s.setModal, })); - + function goto(to: string) { navigate(to); } - + function openNotifications() { // We'll create this component next import("../NotificationCenter").then(({ default: NotificationCenter }) => { @@ -30,11 +30,15 @@ function SlidingMenu() { <h3> Nostrill </h3> </div> <h3>Feeds</h3> - <div className="opt" role="link" onClick={() => goto(`/feed/global`)}> + <div className="opt" role="link" onClick={() => goto(`/f/global`)}> <Icon name="home" size={20} /> <div>Home</div> </div> - <div className="opt notification-item" role="link" onClick={openNotifications}> + <div + className="opt notification-item" + role="link" + onClick={openNotifications} + > <div className="notification-icon-wrapper"> <Icon name="bell" size={20} /> {unreadNotifications > 0 && ( @@ -50,7 +54,7 @@ function SlidingMenu() { <div className="opt tbd" role="link" - // onClick={() => goto("/chat")} + // onClick={() => setModal(<p>lmao</p>)} > <Icon name="messages" size={20} /> <div>Messages</div> @@ -63,7 +67,7 @@ function SlidingMenu() { <div className="opt" role="link" - onClick={() => goto(`/feed/${api!.airlock.our}`)} + onClick={() => goto(`/u/${api!.airlock.our}`)} > <Icon name="profile" size={20} /> <div>Profile</div> diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx index 0694f1e..aeffc95 100644 --- a/gui/src/components/modals/UserModal.tsx +++ b/gui/src/components/modals/UserModal.tsx @@ -1,3 +1,5 @@ +import "@/styles/Profile.css"; +import "@/styles/UserModal.css"; import Modal from "./Modal"; import Avatar from "../Avatar"; import Icon from "@/components/Icon"; @@ -10,14 +12,15 @@ import { generateNprofile } from "@/logic/nostr"; import { useState } from "react"; export default function ({ userString }: { userString: string }) { - const { setModal, api, pubkey, profiles, following, followers } = useLocalState((s) => ({ - setModal: s.setModal, - api: s.api, - pubkey: s.pubkey, - profiles: s.profiles, - following: s.following, - followers: s.followers, - })); + const { setModal, api, pubkey, profiles, following, followers } = + useLocalState((s) => ({ + setModal: s.setModal, + api: s.api, + pubkey: s.pubkey, + profiles: s.profiles, + following: s.following, + followers: s.followers, + })); const [_, navigate] = useLocation(); const [loading, setLoading] = useState(false); @@ -64,25 +67,28 @@ export default function ({ userString }: { userString: string }) { } async function handleFollow(e: React.MouseEvent) { + if ("error" in user) return; e.stopPropagation(); if (!api) return; setLoading(true); try { if (isFollowing) { - const result = await api.unfollow(userString); - if ("ok" in result) { - toast.success(`Unfollowed ${profile?.name || userString}`); - } else { - toast.error(result.error); - } + const result = await api.unfollow(user); + console.log(result); + // if ("ok" in result) { + // toast.success(`Unfollowed ${profile?.name || userString}`); + // } else { + // toast.error(result.error); + // } } else { - const result = await api.follow(userString); - if ("ok" in result) { - toast.success(`Following ${profile?.name || userString}`); - } else { - toast.error(result.error); - } + const result = await api.follow(user); + console.log(result); + // if ("ok" in result) { + // toast.success(`Following ${profile?.name || userString}`); + // } else { + // toast.error(result.error); + // } } } catch (err) { toast.error("Action failed"); @@ -101,9 +107,10 @@ export default function ({ userString }: { userString: string }) { } const displayName = profile?.name || ("urbit" in user ? user.urbit : "Anon"); - const truncatedId = userString.length > 20 - ? `${userString.slice(0, 10)}...${userString.slice(-8)}` - : userString; + const truncatedId = + userString.length > 20 + ? `${userString.slice(0, 10)}...${userString.slice(-8)}` + : userString; // Check if a string is a URL const isURL = (str: string): boolean => { @@ -111,7 +118,7 @@ export default function ({ userString }: { userString: string }) { new URL(str); return true; } catch { - return str.startsWith('http://') || str.startsWith('https://'); + return str.startsWith("http://") || str.startsWith("https://"); } }; @@ -121,7 +128,7 @@ export default function ({ userString }: { userString: string }) { // Filter out banner from other fields since we display it separately const otherFields = profile?.other ? Object.entries(profile.other).filter( - ([key]) => key.toLowerCase() !== 'banner' + ([key]) => key.toLowerCase() !== "banner", ) : []; @@ -130,7 +137,7 @@ export default function ({ userString }: { userString: string }) { <div className="user-modal"> {/* Banner Image */} {bannerImage && ( - <div className="user-modal-banner"> + <div className="user-banner"> <img src={bannerImage} alt="Profile banner" /> </div> )} @@ -174,7 +181,9 @@ export default function ({ userString }: { userString: string }) { <span className="badge badge-nostr">Nostr</span> )} {itsMe && <span className="badge badge-me">You</span>} - {isFollower && !itsMe && <span className="badge badge-follows">Follows you</span>} + {isFollower && !itsMe && ( + <span className="badge badge-follows">Follows you</span> + )} </div> </div> </div> @@ -213,7 +222,11 @@ export default function ({ userString }: { userString: string }) { onClick={(e) => e.stopPropagation()} > {value} - <Icon name="nostr" size={12} className="external-link-icon" /> + <Icon + name="nostr" + size={12} + className="external-link-icon" + /> </a> ) : ( <span className="field-value">{value}</span> @@ -235,21 +248,20 @@ export default function ({ userString }: { userString: string }) { {loading ? "..." : isFollowing ? "Following" : "Follow"} </button> )} + <> + <button + className="action-btn secondary" + onClick={() => { + navigate(`/u/${userString}`); + close(); + }} + > + <Icon name="home" size={16} /> + View Feed + </button> + </> - {"urbit" in user ? ( - <> - <button - className="action-btn secondary" - onClick={() => { - navigate(`/feed/${userString}`); - close(); - }} - > - <Icon name="home" size={16} /> - View Feed - </button> - </> - ) : ( + {"nostr" in user ? ( <button className="action-btn secondary" onClick={handleAvatarClick} @@ -257,7 +269,7 @@ export default function ({ userString }: { userString: string }) { <Icon name="nostr" size={16} /> View on Primal </button> - )} + ) : null} </div> </div> </Modal> diff --git a/gui/src/components/nostr/Feed.tsx b/gui/src/components/nostr/Feed.tsx index 0e74cea..d21307b 100644 --- a/gui/src/components/nostr/Feed.tsx +++ b/gui/src/components/nostr/Feed.tsx @@ -14,6 +14,7 @@ export default function Nostr() { })); console.log({ relays }); const [isSyncing, setIsSyncing] = useState(false); + console.log({ nostrFeed }); const feed = eventsToFc(nostrFeed); console.log({ feed }); const refetch = () => feed; diff --git a/gui/src/components/nostr/User.tsx b/gui/src/components/nostr/User.tsx new file mode 100644 index 0000000..a9e9e2f --- /dev/null +++ b/gui/src/components/nostr/User.tsx @@ -0,0 +1,128 @@ +import useLocalState from "@/state/state"; +import { useState } from "react"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import type { UserType } from "@/types/nostrill"; +import type { FC } from "@/types/trill"; +import Composer from "../composer/Composer"; +import PostList from "@/components/feed/PostList"; + +export default function NostrUser({ + user, + userString, + feed, + isFollowLoading, + setIsFollowLoading, + isAccessLoading, + setIsAccessLoading, +}: { + user: UserType; + userString: string; + feed: FC | undefined; + isFollowLoading: boolean; + setIsFollowLoading: (b: boolean) => void; + isAccessLoading: boolean; + setIsAccessLoading: (b: boolean) => void; +}) { + const { api } = useLocalState((s) => ({ + api: s.api, + })); + const [fc, setFC] = useState<FC>(); + + // Show empty state with resync option when no feed data + + async function refetch() { + // + } + async function handleFollow() { + if (!api) return; + + setIsFollowLoading(true); + try { + if (feed) { + await api.unfollow(user); + } else { + await api.follow(user); + toast.success(`Follow request sent to ${userString}`); + } + } catch (error) { + toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${userString}`); + setIsFollowLoading(false); + console.error("Follow error:", error); + } + } + async function handleRequestAccess() { + if (!api) return; + + setIsAccessLoading(true); + // try { + // const res = await api.peekFeed(user.urbit); + // toast.success(`Access request sent to ${user.urbit}`); + // addNotification({ + // type: "access_request", + // from: userString, + // message: `Access request sent to ${userString}`, + // }); + // if ("error" in res) toast.error(res.error); + // else { + // console.log("peeked", res.ok.feed); + // setFC(res.ok.feed); + // if (res.ok.profile) addProfile(userString, res.ok.profile); + // } + // } catch (error) { + // toast.error(`Failed to request access from ${user.urbit}`); + // console.error("Access request error:", error); + // } finally { + // setIsAccessLoading(false); + // } + } + return ( + <> + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${!!feed ? "" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {!!feed ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={!!feed ? "bell" : "pals"} size={16} /> + {!!feed ? "Unfollow" : "Follow"} + </> + )} + </button> + + {(!feed || !feed.feed || Object.keys(feed.feed).length === 0) && ( + <button + onClick={handleRequestAccess} + disabled={isAccessLoading} + className="action-btn access" + > + {isAccessLoading ? ( + <> + <Icon name="settings" size={16} /> + Fetching... + </> + ) : ( + <> + <Icon name="key" size={16} /> + Fetch Feed + </> + )} + </button> + )} + </div> + {(feed || fc) && ( + <div id="feed-proper"> + <Composer /> + <PostList data={(feed || fc)!} refetch={refetch} /> + </div> + )} + </> + ); +} diff --git a/gui/src/components/post/Header.tsx b/gui/src/components/post/Header.tsx index b0822b4..5898eba 100644 --- a/gui/src/components/post/Header.tsx +++ b/gui/src/components/post/Header.tsx @@ -8,12 +8,12 @@ function Header(props: PostProps) { // console.log(props.poast.author.length, "length"); function go(e: React.MouseEvent) { e.stopPropagation(); - navigate(`/feed/${poast.host}`); + navigate(`/u/${poast.host}`); } function openThread(e: React.MouseEvent) { e.stopPropagation(); const sel = window.getSelection()?.toString(); - if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + if (!sel) navigate(`/t/${poast.host}/${poast.id}`); } const { poast } = props; const name = profile ? ( diff --git a/gui/src/components/profile/Profile.tsx b/gui/src/components/profile/Profile.tsx index b5f22e9..ab65a7b 100644 --- a/gui/src/components/profile/Profile.tsx +++ b/gui/src/components/profile/Profile.tsx @@ -16,6 +16,7 @@ const Loader: React.FC<Props> = (props) => { profiles: s.profiles, })); const profile = profiles.get(props.userString); + console.log({ profiles }); if (props.isMe) return <ProfileEditor {...props} profile={profile} />; else return <Profile profile={profile} {...props} />; @@ -32,31 +33,50 @@ function Profile({ // Initialize state with existing profile or defaults // View-only mode for other users' profiles - no editing allowed + const bannerImage = profile?.other?.banner || profile?.other?.Banner; const customFields = profile?.other ? Object.entries(profile.other) : []; return ( - <div className="profile view-mode"> - <div className="profile-picture"> - <Avatar - user={user} - userString={userString} - size={120} - picOnly={true} - profile={profile} - /> + <div className="profile"> + {bannerImage && ( + <div className="user-banner"> + <img src={bannerImage} alt="Profile banner" /> + </div> + )} + <div className="flex items-center gap-4"> + <div className="profile-picture"> + <Avatar + user={user} + userString={userString} + size={120} + picOnly={true} + profile={profile} + /> + </div> + <h2 className="text-4xl">{profile?.name || userString}</h2> </div> <div className="profile-info"> - <h2>{profile?.name || userString}</h2> {profile?.about && <p className="profile-about">{profile.about}</p>} {customFields.length > 0 && ( <div className="profile-custom-fields"> <h4>Additional Info</h4> - {customFields.map(([key, value], index) => ( - <div key={index} className="custom-field-view"> - <span className="field-key">{key}:</span> - <span className="field-value">{value}</span> - </div> - ))} + + {customFields.map(([key, value], index) => { + if (key.toLocaleLowerCase() === "banner") return null; + const isURL = URL.parse(value); + return ( + <div key={index} className="custom-field-view"> + <span className="field-key">{key}:</span> + {isURL ? ( + <a className="field-value" href={value} target="_blank"> + {value} + </a> + ) : ( + <span className="field-value">{value}</span> + )} + </div> + ); + })} </div> )} </div> diff --git a/gui/src/components/trill/User.tsx b/gui/src/components/trill/User.tsx new file mode 100644 index 0000000..b7b53d6 --- /dev/null +++ b/gui/src/components/trill/User.tsx @@ -0,0 +1,180 @@ +// import spinner from "@/assets/icons/spinner.svg"; +import Composer from "@/components/composer/Composer"; +import PostList from "@/components/feed/PostList"; +import useLocalState from "@/state/state"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import { useEffect, useState } from "react"; +import type { FC } from "@/types/trill"; +import type { UserType } from "@/types/nostrill"; + +function UserFeed({ + user, + userString, + feed, + isFollowLoading, + setIsFollowLoading, + isAccessLoading, + setIsAccessLoading, +}: { + user: UserType; + userString: string; + feed: FC | undefined; + isFollowLoading: boolean; + setIsFollowLoading: (b: boolean) => void; + isAccessLoading: boolean; + setIsAccessLoading: (b: boolean) => void; +}) { + const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({ + api: s.api, + addProfile: s.addProfile, + addNotification: s.addNotification, + lastFact: s.lastFact, + })); + const hasFeed = !feed ? false : Object.entries(feed).length > 0; + const refetch = () => feed; + + const [fc, setFC] = useState<FC>(); + + useEffect(() => { + console.log("fact", lastFact); + console.log(isFollowLoading); + if (!isFollowLoading) return; + const follow = lastFact?.fols; + if (!follow) return; + if ("new" in follow) { + if (userString !== follow.new.user) return; + toast.success(`Now following ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "follow", + from: userString, + message: `You are now following ${userString}`, + }); + } else if ("quit" in follow) { + toast.success(`Unfollowed ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "unfollow", + from: userString, + message: `You unfollowed ${userString}`, + }); + } + }, [lastFact, userString, isFollowLoading]); + + const handleFollow = async () => { + if (!api) return; + + setIsFollowLoading(true); + try { + if (!!feed) { + await api.unfollow(user); + } else { + await api.follow(user); + toast.success(`Follow request sent to ${userString}`); + } + } catch (error) { + toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${userString}`); + setIsFollowLoading(false); + console.error("Follow error:", error); + } + }; + + const handleRequestAccess = async () => { + if (!api) return; + if (!("urbit" in user)) return; + + setIsAccessLoading(true); + try { + const res = await api.peekFeed(user.urbit); + toast.success(`Access request sent to ${user.urbit}`); + addNotification({ + type: "access_request", + from: userString, + message: `Access request sent to ${userString}`, + }); + if ("error" in res) toast.error(res.error); + else { + console.log("peeked", res.ok.feed); + setFC(res.ok.feed); + if (res.ok.profile) addProfile(userString, res.ok.profile); + } + } catch (error) { + toast.error(`Failed to request access from ${user.urbit}`); + console.error("Access request error:", error); + } finally { + setIsAccessLoading(false); + } + }; + console.log({ user, userString, feed, fc }); + + return ( + <> + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${!!feed ? "" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {!!feed ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={!!feed ? "bell" : "pals"} size={16} /> + {!!feed ? "Unfollow" : "Follow"} + </> + )} + </button> + + <button + onClick={handleRequestAccess} + disabled={isAccessLoading} + className="action-btn access" + > + {isAccessLoading ? ( + <> + <Icon name="settings" size={16} /> + Requesting... + </> + ) : ( + <> + <Icon name="key" size={16} /> + Request Access + </> + )} + </button> + </div> + + {feed && hasFeed ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={feed} refetch={refetch} /> + </div> + ) : fc ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={fc} refetch={refetch} /> + </div> + ) : null} + + {!feed && !fc && ( + <div id="other-user-feed"> + <div className="empty-feed-message"> + <Icon name="messages" size={48} color="textMuted" /> + <h3>No Posts Available</h3> + <p> + This user's posts are not publicly visible. + {!!feed && " Try following them"} or request temporary access to + see their content. + </p> + </div> + </div> + )} + </> + ); +} + +export default UserFeed; |
