diff options
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/devenv.nix | 8 | ||||
| -rw-r--r-- | gui/src/components/Avatar.tsx | 9 | ||||
| -rw-r--r-- | gui/src/components/modals/UserModal.tsx | 256 | ||||
| -rw-r--r-- | gui/src/components/post/Post.tsx | 4 | ||||
| -rw-r--r-- | gui/src/logic/nostr.ts | 10 | ||||
| -rw-r--r-- | gui/src/styles/UserModal.css | 316 | ||||
| -rw-r--r-- | gui/src/styles/styles.css | 10 |
7 files changed, 577 insertions, 36 deletions
diff --git a/gui/devenv.nix b/gui/devenv.nix index e4e3748..af7c11f 100644 --- a/gui/devenv.nix +++ b/gui/devenv.nix @@ -8,6 +8,14 @@ # https://devenv.sh/basics/ env.GREET = "devenv"; + env.ANTHROPIC_BASE_URL = "https://api.moonshot.ai/anthropic"; + env.ANTHROPIC_AUTH_TOKEN = "sk-el9tYLoqFmDrauY293aUpMUvgncoYjCtofRjKsdgrrI9NrP2"; + env.ANTHROPIC_MODEL = "kimi-k2-thinking-turbo"; + env.ANTHROPIC_DEFAULT_OPUS_MODEL = "kimi-k2-thinking-turbo"; + env.ANTHROPIC_DEFAULT_SONNET_MODEL = "kimi-k2-thinking-turbo"; + env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "kimi-k2-thinking-turbo"; + env.CLAUDE_CODE_SUBAGENT_MODEL = "kimi-k2-thinking-turbo "; + # https://devenv.sh/packages/ packages = with pkgs; [ git diff --git a/gui/src/components/Avatar.tsx b/gui/src/components/Avatar.tsx index a071655..2b38848 100644 --- a/gui/src/components/Avatar.tsx +++ b/gui/src/components/Avatar.tsx @@ -43,7 +43,7 @@ export default function ({ function openModal(e: React.MouseEvent) { if (noClickOnName) return; e.stopPropagation(); - setModal(<UserModal user={user} userString={userString} />); + setModal(<UserModal userString={userString} />); } const name = ( <div className="name cp" role="link" onMouseUp={openModal}> @@ -58,5 +58,10 @@ export default function ({ )} </div> ); - return <div className="ship-avatar">{name}</div>; + return ( + <div className="ship-avatar"> + {avatar} + {name} + </div> + ); } 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> diff --git a/gui/src/components/post/Post.tsx b/gui/src/components/post/Post.tsx index 2d9a09a..7413e70 100644 --- a/gui/src/components/post/Post.tsx +++ b/gui/src/components/post/Post.tsx @@ -6,7 +6,7 @@ import Footer from "./Footer"; import { useLocation } from "wouter"; import useLocalState from "@/state/state"; import RP from "./RP"; -import ShipModal from "../modals/ShipModal"; +import UserModal from "../modals/UserModal"; import type { Ship } from "@/types/urbit"; import Sigil from "../Sigil"; import type { UserProfile } from "@/types/nostrill"; @@ -57,7 +57,7 @@ function TrillPost(props: PostProps) { function openModal(e: React.MouseEvent) { e.stopPropagation(); - setModal(<ShipModal ship={poast.author} />); + setModal(<UserModal userString={poast.author} />); } const avatar = profile ? ( <div className="avatar sigil cp" role="link" onMouseUp={openModal}> diff --git a/gui/src/logic/nostr.ts b/gui/src/logic/nostr.ts index b85047f..7da9b91 100644 --- a/gui/src/logic/nostr.ts +++ b/gui/src/logic/nostr.ts @@ -12,6 +12,16 @@ export function generateNevent(event: Event) { return nev; } +export function generateNpub(pubkey: string) { + const npub = nip19.npubEncode(pubkey); + return npub; +} +export function generateNprofile(pubkey: string) { + const prof = { pubkey }; + const nprofile = nip19.nprofileEncode(prof); + return nprofile; +} + // let sk = generateSecretKey() // let nsec = nip19.nsecEncode(sk) // let { type, data } = nip19.decode(nsec) diff --git a/gui/src/styles/UserModal.css b/gui/src/styles/UserModal.css new file mode 100644 index 0000000..bf4ff56 --- /dev/null +++ b/gui/src/styles/UserModal.css @@ -0,0 +1,316 @@ +/* User Modal Styles */ + +.user-modal { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 400px; + max-width: 500px; + padding: 24px; +} + +.user-modal-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 40px; + text-align: center; + color: var(--color-text-secondary); +} + +/* Header Section */ +.user-modal-header { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.user-modal-avatar-wrapper { + flex-shrink: 0; +} + +.user-modal-avatar-wrapper .avatar { + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + border: 3px solid var(--color-border); + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.user-modal-avatar-wrapper .avatar:hover { + transform: scale(1.05); + border-color: var(--color-primary); +} + +.user-modal-avatar-wrapper .avatar img, +.user-modal-avatar-wrapper .avatar canvas { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-modal-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.user-modal-name { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--color-text); + word-wrap: break-word; +} + +.user-modal-id-row { + display: flex; + align-items: center; + gap: 8px; +} + +.user-modal-id { + font-size: 14px; + color: var(--color-text-secondary); + font-family: "Source Code Pro", monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-modal-copy-icon { + flex-shrink: 0; + color: var(--color-text-secondary); + transition: color 0.2s ease; +} + +.user-modal-copy-icon:hover { + color: var(--color-primary); +} + +/* Badges */ +.user-modal-badge { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-urbit { + background: var(--color-primary); + color: white; +} + +.badge-nostr { + background: #8e44ad; + color: white; +} + +.badge-me { + background: var(--color-accent); + color: white; +} + +.badge-follows { + background: var(--color-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +/* About Section */ +.user-modal-about { + padding: 16px; + background: var(--color-surface); + border-radius: 8px; + border-left: 3px solid var(--color-primary); +} + +.user-modal-about p { + margin: 0; + color: var(--color-text); + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; +} + +/* Stats */ +.user-modal-stats { + display: flex; + gap: 24px; + padding: 16px; + background: var(--color-surface); + border-radius: 8px; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.stat-value { + font-size: 20px; + font-weight: 600; + color: var(--color-text); +} + +.stat-label { + font-size: 12px; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Custom Fields */ +.user-modal-custom-fields { + padding: 16px; + background: var(--color-surface); + border-radius: 8px; +} + +.user-modal-custom-fields h4 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.custom-field-item { + display: flex; + gap: 8px; + margin-bottom: 8px; + font-size: 14px; +} + +.custom-field-item:last-child { + margin-bottom: 0; +} + +.custom-field-item .field-key { + font-weight: 500; + color: var(--color-text); + min-width: 100px; +} + +.custom-field-item .field-value { + color: var(--color-text-secondary); + word-wrap: break-word; + flex: 1; +} + +/* Action Buttons */ +.user-modal-actions { + display: flex; + gap: 12px; + padding-top: 8px; + border-top: 1px solid var(--color-border); +} + +.user-modal-actions .action-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 20px; + border: 2px solid; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; +} + +.user-modal-actions .action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.user-modal-actions .action-btn.follow { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.user-modal-actions .action-btn.follow:hover:not(:disabled) { + background: var(--color-primary); + color: white; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.user-modal-actions .action-btn.following { + border-color: var(--color-success); + background: var(--color-success); + color: white; +} + +.user-modal-actions .action-btn.following:hover:not(:disabled) { + background: var(--color-error); + border-color: var(--color-error); + transform: translateY(-1px); +} + +.user-modal-actions .action-btn.secondary { + border-color: var(--color-border); + color: var(--color-text); +} + +.user-modal-actions .action-btn.secondary:hover:not(:disabled) { + border-color: var(--color-text-secondary); + background: var(--color-surface); + transform: translateY(-1px); +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .user-modal { + min-width: 320px; + max-width: 100%; + padding: 16px; + } + + .user-modal-name { + font-size: 20px; + } + + .user-modal-actions { + flex-direction: column; + } + + .user-modal-actions .action-btn { + width: 100%; + } +} + +/* Modal background improvements for user modal */ +#modal-background { + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +#modal { + background: var(--color-background); + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-height: 90vh; + overflow-y: auto; +} diff --git a/gui/src/styles/styles.css b/gui/src/styles/styles.css index ac3c71b..5772c40 100644 --- a/gui/src/styles/styles.css +++ b/gui/src/styles/styles.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./UserModal.css"; /* assets */ /* fonts */ @@ -573,10 +574,6 @@ h6 { } } - post-body { - max-height: 300px; - overflow-y: auto; - } & .date { color: grey; @@ -584,6 +581,11 @@ h6 { } + .body { + max-height: 300px; + overflow-y: auto; + } + & footer { justify-content: left; margin: unset; |
