From a465c73178dc621578d10312f263355f0f2d5309 Mon Sep 17 00:00:00 2001 From: polwex Date: Tue, 18 Nov 2025 09:43:16 +0700 Subject: fixes to profile handling and rendering --- app/app/nostrill.hoon | 12 ++ app/lib/json/nostr.hoon | 5 +- app/lib/nostr/client.hoon | 12 +- app/lib/websockets.hoon | 5 + gui/devenv.nix | 8 + gui/src/components/Avatar.tsx | 9 +- gui/src/components/modals/UserModal.tsx | 256 +++++++++++++++++++++++--- gui/src/components/post/Post.tsx | 4 +- gui/src/logic/nostr.ts | 10 + gui/src/styles/UserModal.css | 316 ++++++++++++++++++++++++++++++++ gui/src/styles/styles.css | 10 +- 11 files changed, 608 insertions(+), 39 deletions(-) create mode 100644 gui/src/styles/UserModal.css 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(); + setModal(); } const name = (
@@ -58,5 +58,10 @@ export default function ({ )}
); - return
{name}
; + return ( +
+ {avatar} + {name} +
+ ); } 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 ( + +
+ +

Invalid user identifier

+
+
+ ); + } + 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 ( -
-
- - +
+ {/* Banner Image */} + {bannerImage && ( +
+ Profile banner +
+ )} + + {/* Header with Avatar and Basic Info */} +
+
+ +
+ +
+

{displayName}

+
+ + {"urbit" in user ? user.urbit : truncatedId} + + +
+ + {/* User type badge */} +
+ {"urbit" in user ? ( + Urbit + ) : ( + Nostr + )} + {itsMe && You} + {isFollower && !itsMe && Follows you} +
+
-
- - - {itsMe && ( + + {/* Profile About Section */} + {profile?.about && ( +
+

{profile.about}

+
+ )} + + {/* Stats */} +
+ {postCount > 0 && ( +
+ {postCount} + Posts +
+ )} + {/* Additional stats could go here */} +
+ + {/* Custom Fields */} + {otherFields.length > 0 && ( +
+

Additional Info

+ {otherFields.map(([key, value]) => ( +
+ {key}: + {isURL(value) ? ( + e.stopPropagation()} + > + {value} + + + ) : ( + {value} + )} +
+ ))} +
+ )} + + {/* Action Buttons */} +
+ {!itsMe && ( + + )} + + {"urbit" in user ? ( <> - + ) : ( + )}
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(); + setModal(); } const avatar = profile ? (
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; -- cgit v1.2.3