summaryrefslogtreecommitdiff
path: root/gui/src/components/modals/UserModal.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src/components/modals/UserModal.tsx')
-rw-r--r--gui/src/components/modals/UserModal.tsx256
1 files changed, 228 insertions, 28 deletions
diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx
index 6e3089d..0694f1e 100644
--- a/gui/src/components/modals/UserModal.tsx
+++ b/gui/src/components/modals/UserModal.tsx
@@ -4,59 +4,259 @@ 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) => ({
+import { isValidPatp } from "urbit-ob";
+import { isValidNostrPubkey } from "@/logic/nostrill";
+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 [_, navigate] = useLocation();
+ const [loading, setLoading] = useState(false);
+
function close() {
setModal(null);
}
+
+ const user = isValidPatp(userString)
+ ? { urbit: userString }
+ : isValidNostrPubkey(userString)
+ ? { nostr: userString }
+ : { error: "" };
+
+ if ("error" in user) {
+ return (
+ <Modal close={close}>
+ <div className="user-modal-error">
+ <Icon name="comet" size={48} />
+ <p>Invalid user identifier</p>
+ </div>
+ </Modal>
+ );
+ }
+
const itsMe =
"urbit" in user
? user.urbit === api?.airlock.our
: "nostr" in user
? user.nostr === pubkey
: false;
+
+ const profile = profiles.get(userString);
+ const isFollowing = following.has(userString);
+ const isFollower = followers.includes(userString);
+
+ // Get follower/following counts from the user's feed if available
+ const userFeed = following.get(userString);
+ const postCount = userFeed ? Object.keys(userFeed.feed).length : 0;
+
async function copy(e: React.MouseEvent) {
e.stopPropagation();
await navigator.clipboard.writeText(userString);
toast.success("Copied to clipboard");
}
+
+ async function handleFollow(e: React.MouseEvent) {
+ 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);
+ }
+ } else {
+ const result = await api.follow(userString);
+ if ("ok" in result) {
+ toast.success(`Following ${profile?.name || userString}`);
+ } else {
+ toast.error(result.error);
+ }
+ }
+ } catch (err) {
+ toast.error("Action failed");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleAvatarClick(e: React.MouseEvent) {
+ e.stopPropagation();
+ if ("nostr" in user) {
+ const nprof = generateNprofile(userString);
+ const href = `https://primal.net/p/${nprof}`;
+ window.open(href, "_blank");
+ }
+ }
+
+ const displayName = profile?.name || ("urbit" in user ? user.urbit : "Anon");
+ 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 => {
+ try {
+ new URL(str);
+ return true;
+ } catch {
+ return str.startsWith('http://') || str.startsWith('https://');
+ }
+ };
+
+ // Get banner image from profile.other
+ const bannerImage = profile?.other?.banner || profile?.other?.Banner;
+
+ // Filter out banner from other fields since we display it separately
+ const otherFields = profile?.other
+ ? Object.entries(profile.other).filter(
+ ([key]) => key.toLowerCase() !== 'banner'
+ )
+ : [];
+
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 className="user-modal">
+ {/* Banner Image */}
+ {bannerImage && (
+ <div className="user-modal-banner">
+ <img src={bannerImage} alt="Profile banner" />
+ </div>
+ )}
+
+ {/* Header with Avatar and Basic Info */}
+ <div className="user-modal-header">
+ <div
+ className="user-modal-avatar-wrapper"
+ onClick={handleAvatarClick}
+ style={{ cursor: "nostr" in user ? "pointer" : "default" }}
+ >
+ <Avatar
+ user={user}
+ userString={userString}
+ profile={profile}
+ size={80}
+ picOnly
+ />
+ </div>
+
+ <div className="user-modal-info">
+ <h2 className="user-modal-name">{displayName}</h2>
+ <div className="user-modal-id-row">
+ <span className="user-modal-id" title={userString}>
+ {"urbit" in user ? user.urbit : truncatedId}
+ </span>
+ <Icon
+ name="copy"
+ size={16}
+ className="user-modal-copy-icon cp"
+ onClick={copy}
+ title="Copy to clipboard"
+ />
+ </div>
+
+ {/* User type badge */}
+ <div className="user-modal-badge">
+ {"urbit" in user ? (
+ <span className="badge badge-urbit">Urbit</span>
+ ) : (
+ <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>}
+ </div>
+ </div>
</div>
- <div className="buttons f1">
- <button onClick={() => navigate(`/feed/${userString}`)}>Feed</button>
- <button onClick={() => navigate(`/pals/${userString}`)}>
- Profile
- </button>
- {itsMe && (
+
+ {/* Profile About Section */}
+ {profile?.about && (
+ <div className="user-modal-about">
+ <p>{profile.about}</p>
+ </div>
+ )}
+
+ {/* Stats */}
+ <div className="user-modal-stats">
+ {postCount > 0 && (
+ <div className="stat">
+ <span className="stat-value">{postCount}</span>
+ <span className="stat-label">Posts</span>
+ </div>
+ )}
+ {/* Additional stats could go here */}
+ </div>
+
+ {/* Custom Fields */}
+ {otherFields.length > 0 && (
+ <div className="user-modal-custom-fields">
+ <h4>Additional Info</h4>
+ {otherFields.map(([key, value]) => (
+ <div key={key} className="custom-field-item">
+ <span className="field-key">{key}:</span>
+ {isURL(value) ? (
+ <a
+ href={value}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="field-value field-link"
+ onClick={(e) => e.stopPropagation()}
+ >
+ {value}
+ <Icon name="nostr" size={12} className="external-link-icon" />
+ </a>
+ ) : (
+ <span className="field-value">{value}</span>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Action Buttons */}
+ <div className="user-modal-actions">
+ {!itsMe && (
+ <button
+ className={`action-btn ${isFollowing ? "following" : "follow"}`}
+ onClick={handleFollow}
+ disabled={loading}
+ >
+ <Icon name="pals" size={16} />
+ {loading ? "..." : isFollowing ? "Following" : "Follow"}
+ </button>
+ )}
+
+ {"urbit" in user ? (
<>
- <button onClick={() => navigate(`/chat/dm/${userString}`)}>
- DM
+ <button
+ className="action-btn secondary"
+ onClick={() => {
+ navigate(`/feed/${userString}`);
+ close();
+ }}
+ >
+ <Icon name="home" size={16} />
+ View Feed
</button>
</>
+ ) : (
+ <button
+ className="action-btn secondary"
+ onClick={handleAvatarClick}
+ >
+ <Icon name="nostr" size={16} />
+ View on Primal
+ </button>
)}
</div>
</div>