diff options
| author | polwex <polwex@sortug.com> | 2025-11-18 09:43:16 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-11-18 09:43:16 +0700 |
| commit | a465c73178dc621578d10312f263355f0f2d5309 (patch) | |
| tree | 08a0b655835d4c08d45e8e3e8f52601f7911044b | |
| parent | 7706acaafa89691dba33c216e6287a8405c4c302 (diff) | |
fixes to profile handling and rendering
| -rw-r--r-- | app/app/nostrill.hoon | 12 | ||||
| -rw-r--r-- | app/lib/json/nostr.hoon | 5 | ||||
| -rw-r--r-- | app/lib/nostr/client.hoon | 12 | ||||
| -rw-r--r-- | app/lib/websockets.hoon | 5 | ||||
| -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 |
11 files changed, 608 insertions, 39 deletions
diff --git a/app/app/nostrill.hoon b/app/app/nostrill.hoon index 9d67dea..230f72f 100644 --- a/app/app/nostrill.hoon +++ b/app/app/nostrill.hoon @@ -234,6 +234,11 @@ :_ this :~ (connect:ws endpoint bowl) == + %wscancel + =/ wid 1 + :_ this + :~ (cancel-connect:ws wid) + == %wstest :: =/ url 'ws://localhost:8888' =/ url 'wss://nos.lol' @@ -313,6 +318,13 @@ $(pfs t.pfs) `this + [%prof @] + =/ pubkey=(unit @ux) (slaw:sr %ux +.noun) + ~& pubkey=pubkey + ?~ pubkey ~& "pubkey not valid hex. take out the 0x maybe" !! + =^ cs state (get-profile:nclient u.pubkey) + [cs this] + %wtf =/ lol=(unit @) ~ =/ l ~| "wtf" (need lol) diff --git a/app/lib/json/nostr.hoon b/app/lib/json/nostr.hoon index 3c42f0b..ca4ed43 100644 --- a/app/lib/json/nostr.hoon +++ b/app/lib/json/nostr.hoon @@ -276,10 +276,13 @@ ?~ crd $(fields t.fields) $(fields t.fields, um um(name u.crd)) %'about' =/ crd (so jn) - ?~ crd $(fields t.fields) $(fields t.fields, um um(picture u.crd)) + ?~ crd $(fields t.fields) $(fields t.fields, um um(about u.crd)) %'picture' =/ crd (so jn) ?~ crd $(fields t.fields) $(fields t.fields, um um(picture u.crd)) + %'image' + =/ crd (so jn) + ?~ crd $(fields t.fields) $(fields t.fields, um um(picture u.crd)) == -- -- diff --git a/app/lib/nostr/client.hoon b/app/lib/nostr/client.hoon index 741c51e..9c732bb 100644 --- a/app/lib/nostr/client.hoon +++ b/app/lib/nostr/client.hoon @@ -69,6 +69,13 @@ =/ =filter:nsur [~ `pubkeys `kinds ~ ~ ~ ~] (send-req ~[filter] .y ~) +++ get-profile |= pubkey=@ux + =/ kinds (silt ~[0]) + :: =/ since (to-unix-secs:jikan:sr last-week) + =/ pubkeys (silt ~[pubkey]) + =/ =filter:nsur [~ `pubkeys `kinds ~ ~ ~ ~] + (send-req ~[filter] .n ~) + ++ get-profiles ^- (quip card _state) =/ npoasts (tap:norm:sur nostr-feed.state) @@ -80,11 +87,12 @@ =. missing-profs ?: have missing-profs (~(put in missing-profs) pubkey.poast) $(npoasts t.npoasts) =/ kinds (silt ~[0]) - ?. (gth ~(wyt in pubkeys) 300) + =/ chunk-size 300 + ?. (gth ~(wyt in pubkeys) chunk-size) =/ =filter:nsur [~ `pubkeys `kinds ~ ~ ~ ~] (send-req ~[filter] .n ~) :: - =/ chunks=(list (list @ux)) (chunk-by-size:seq ~(tap in pubkeys) 300) + =/ chunks=(list (list @ux)) (chunk-by-size:seq ~(tap in pubkeys) chunk-size) ?~ chunks ~& >>> "error chunking pubkeys" `state =/ queue=(list filter:nsur) %+ turn t.chunks |= l=(list @ux) ^- filter:nsur diff --git a/app/lib/websockets.hoon b/app/lib/websockets.hoon index ae48775..a87f2a1 100644 --- a/app/lib/websockets.hoon +++ b/app/lib/websockets.hoon @@ -4,6 +4,11 @@ =/ =task:iris [%websocket-connect dap.bowl endpoint] [%pass /ws-connect %arvo %i task] + ++ cancel-connect |= wid=@ud + ^- card:agent:gall + =/ =task:iris [%cancel-websocket wid] + [%pass /ws-connect %arvo %i task] + ++ disconnect |= wid=@ud ^- card:agent:gall =/ =path /websocket-client/(scot %ud wid) 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; |
