diff options
author | polwex <polwex@sortug.com> | 2025-09-18 00:24:39 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-09-18 00:24:39 +0700 |
commit | 4b016c908dda2019f3bf89e5a3d2eae535e5fbd2 (patch) | |
tree | 639613aa8bcc3d36b5165a32ece4a421dabde4c8 | |
parent | 985fa2f7c99832cdf3c3351d2273c8fd05402b78 (diff) |
oioi
23 files changed, 452 insertions, 197 deletions
diff --git a/desk/app/nostrill.hoon b/desk/app/nostrill.hoon index 17732e7..cbd1c2f 100644 --- a/desk/app/nostrill.hoon +++ b/desk/app/nostrill.hoon @@ -1,7 +1,7 @@ /- sur=nostrill, nsur=nostr /+ lib=nostrill, nlib=nostr, sr=sortug, shim, dbug, muta=nostrill-mutations, jsonlib=json-nostrill, - trill=trill-post, comms=nostrill-comms + trill=trill-post, comms=nostrill-comms, followlib=follows /= web /web/router |% +$ versioned-state $%(state-0:sur) @@ -17,6 +17,7 @@ mutat ~(. muta [state bowl]) shimm ~(. shim [state bowl]) coms ~(. comms [state bowl]) + fols ~(. followlib [state bowl]) ++ on-init ^- (quip card:agent:gall agent:gall) =/ default (default-state:lib bowl) @@ -31,11 +32,11 @@ ++ on-load |~ old-state=vase ^- (quip card:agent:gall agent:gall) - =/ old-state !<(versioned-state old-state) - ?- -.old-state - %0 `this(state old-state) - == - :: `this(state (default-state:lib bowl)) + :: =/ old-state !<(versioned-state old-state) + :: ?- -.old-state + :: %0 `this(state old-state) + :: == + `this(state (default-state:lib bowl)) :: ++ on-poke |~ [=mark =vase] @@ -97,7 +98,7 @@ =/ sp (build-sp:trill our.bowl our.bowl content.poke) =/ p (build-post:trill now.bowl pub.i.keys sp) =. state (add-to-feed:mutat p) - =/ profile (~(get by profiles) pub.i.keys) + =/ profile (~(get by profiles) [%urbit our.bowl]) =/ pw [p (some pub.i.keys) ~ ~ profile] =/ =fact:ui:sur [%post %add pw] =/ card (update-ui:cards fact) @@ -116,17 +117,21 @@ [cs this] == ++ handle-fols |= poke=fols-poke:ui:sur - ?- -.poke - %add `this - %del `this - == + =^ cs state + ?- -.poke + %add (handle-add:fols +.poke) + + %del (handle-del:fols +.poke) + == + [cs this] + ++ handle-prof |= poke=prof-poke:ui:sur ?- -.poke %add - =. profiles (~(put by profiles) pub.i.keys +.poke) + =. profiles (~(put by profiles) [%urbit our.bowl] +.poke) `this %del - =. profiles (~(del by profiles) pub.i.keys) + =. profiles (~(del by profiles) [%urbit our.bowl]) `this == ++ handle-rela |= poke=relay-poke:ui:sur @@ -243,12 +248,13 @@ |= =(pole knot) ~& on-watch=`path`pole ?+ pole !! + [%follow ~] :_ this (give-feed:coms pole) [%beg %feed ~] - :_ this give-feed:coms + :_ this (give-feed:coms pole) [%beg %thread ids=@t ~] =/ id (slaw:sr %uw ids.pole) ?~ id ~& error-parsing-ted-id=pole `this - :_ this (give-ted:coms u.id) + :_ this (give-ted:coms u.id pole) [%ui ~] ?> .=(our.bowl src.bowl) :_ this @@ -271,8 +277,10 @@ == :: ++ on-agent - |~ [wire sign:agent:gall] + |~ [wire=(pole knot) =sign:agent:gall] ^- (quip card:agent:gall agent:gall) + ~& on-agent=wire + ~& on-agent=sign `this :: ++ on-arvo diff --git a/desk/lib/json/nostrill.hoon b/desk/lib/json/nostrill.hoon index b5a619c..2edf7f4 100644 --- a/desk/lib/json/nostrill.hoon +++ b/desk/lib/json/nostrill.hoon @@ -36,35 +36,42 @@ :: TODO do we even need this :- sub-id (numb received.es) - ++ en-profiles |= m=(map @ux user-meta:nsur) + ++ en-profiles |= m=(map user:sur user-meta:nsur) %- pairs - %+ turn ~(tap by m) |= [key=@ux p=user-meta:nsur] - =/ jkey (hex:en:common key) + %+ turn ~(tap by m) |= [key=user:sur p=user-meta:nsur] + =/ jkey (user key) ?> ?=(%s -.jkey) :- +.jkey (user-meta:en:nostr p) ++ enfollowing - |= m=(map @ux feed:feed) + |= m=(map user:sur feed:feed) ^- json - %- pairs %+ turn ~(tap by m) |= [key=@ux f=feed:feed] - =/ jkey (hex:en:common key) + %- pairs %+ turn ~(tap by m) |= [key=user:sur f=feed:feed] + =/ jkey (user key) ?> ?=(%s -.jkey) :- +.jkey (feed:en:trill f) ++ engraph - |= m=(map @ux (set follow:sur)) + |= m=(map user:sur (set user:sur)) ^- json - %- pairs %+ turn ~(tap by m) |= [key=@ux s=(set follow:sur)] - =/ jkey (hex:en:common key) + %- pairs %+ turn ~(tap by m) |= [key=user:sur s=(set user:sur)] + =/ jkey (user key) ?> ?=(%s -.jkey) :- +.jkey - :- %a %+ turn ~(tap in s) |= f=follow:sur - %- pairs - :~ pubkey+(hex:en:common pubkey.f) - name+s+name.f - :- %relay ?~ relay.f ~ s+u.relay.f - == + :- %a %+ turn ~(tap in s) user + ++ follow + |= f=follow:sur + %- pairs + :~ pubkey+(hex:en:common pubkey.f) + name+s+name.f + :- %relay ?~ relay.f ~ s+u.relay.f + == + ++ user |= u=user:sur ^- json + ?- -.u + %urbit (patp:en:common +.u) + %nostr (hex:en:common +.u) + == :: ui facts ++ fact |= f=fact:ui:sur ^- json %+ frond %fact @@ -105,17 +112,27 @@ %ng [%s msg.res] == ++ resd |= rd=res-data:comms ^- json - %+ frond -.rd ?- -.rd - %feed (feed-with-cursor:en:trill +.rd) + %feed (user-data +.rd) :: TODO wrap it for nostr shit - %thread (full-node:en:trill +.rd) - %prof (user-meta:en:nostr +.rd) + %thread (frond -.rd (full-node:en:trill +.rd)) + == + ++ user-data + |= ud=[=fc:feed profile=(unit user-meta:nsur)] + %: pairs + feed+(feed-with-cursor:en:trill fc.ud) + :- %profile ?~ profile.ud ~ (user-meta:en:nostr u.profile.ud) + ~ == -- ++ de =, dejs-soft:format |% +++ user + %- of :~ + urbit+(se:de:common %p) + nostr+hex:de:common + == :: ui ++ ui %- of :~ @@ -128,8 +145,8 @@ == ++ ui-fols %- of :~ - add+hex:de:common - del+hex:de:common + add+user + del+user == ++ ui-begs %- of :~ @@ -143,9 +160,18 @@ == ++ ui-prof %- of :~ - add+user-meta:de:nostr + add+ui-meta del+ul == +++ ui-meta + %- ot :~ + name+so + about+so + picture+so + other+other-meta + == +++ other-meta |= jon=json + ?. ?=(%o -.jon) ~ (some p.jon) ++ ui-post %- of :~ add+de-post diff --git a/desk/lib/nostrill/comms.hoon b/desk/lib/nostrill/comms.hoon index 833c07d..87a63b2 100644 --- a/desk/lib/nostrill/comms.hoon +++ b/desk/lib/nostrill/comms.hoon @@ -9,7 +9,6 @@ ?- -.req %feed handle-feed %thread (handle-thread +.req) - %prof handle-prof == ++ handle-feed =/ can (can-access:gatelib src.bowl lock.feed-perms.state bowl) @@ -21,49 +20,35 @@ =/ lp latest-page:feedlib =/ lp2 lp(count backlog.feed-perms.state) =/ =fc:feed (lp2 feed.state) - =/ crd (res-poke [%ok %feed fc]) + =/ prof (~(get by profiles.state) [%urbit our.bowl]) + =/ crd (res-poke [%ok %feed fc prof]) :_ state :~(crd) -++ give-feed +++ give-feed + |= pat=path ~& give-feed=src.bowl =/ can (can-access:gatelib src.bowl lock.feed-perms.state bowl) ?. can :: TODO keep track of the requests at the feed-perms struct - (res-fact [%ng 'not allowed']) + (res-fact [%ng 'not allowed'] pat) :: =/ lp latest-page:feedlib =/ lp2 lp(count backlog.feed-perms.state) =/ =fc:feed (lp2 feed.state) - (res-fact [%ok %feed fc]) + =/ prof (~(get by profiles.state) [%urbit our.bowl]) + (res-fact [%ok %feed fc prof] pat) -++ give-ted |= id=@ +++ give-ted |= [id=@ pat=path] =/ ted (get:orm:feed feed.state id) ?~ ted - (res-fact [%ng 'no such thread']) + (res-fact [%ng 'no such thread'] pat) =/ can (can-access:gatelib src.bowl read.u.ted bowl) ?. can - (res-fact [%ng 'not allowed']) + (res-fact [%ng 'not allowed'] pat) :: =/ fn (node-to-full:feedlib u.ted feed.state) - (res-fact [%ok %thread fn]) + (res-fact [%ok %thread fn] pat) :: -++ handle-prof - =/ can (can-access:gatelib src.bowl lock.feed-perms.state bowl) - ?. can - :: TODO keep track of the requests at the feed-perms struct - =/ crd (res-poke [%ng 'not allowed']) - :_ state :~(crd) - :: - :: TODO @p or keys... wat do - :: =/ up (~(get by profiles.state) our.bowl) - =/ up (~(get by profiles.state) pub.i.keys.state) - ?~ up - =/ crd (res-poke [%ng 'dont have one']) - :_ state :~(crd) - - =/ crd (res-poke [%ok %prof u.up]) - :_ state :~(crd) - ++ handle-thread |= id=@da =/ ted (get:orm:feed feed.state id) ?~ ted @@ -85,8 +70,8 @@ =/ =poke:comms [%res res] =/ cage [%noun !>(poke)] [%pass /poke %agent [src.bowl dap.bowl] %poke cage] -++ res-fact |= =res:comms ^- (list card:agent:gall) - =/ paths ~[/beg/feed] +++ res-fact |= [=res:comms pat=path] ^- (list card:agent:gall) + =/ paths ~[pat] =/ =poke:comms [%res res] ~& > giving-res-fact=res =/ jon (beg-res:en:jsonlib res) diff --git a/desk/lib/nostrill/follows.hoon b/desk/lib/nostrill/follows.hoon new file mode 100644 index 0000000..c2eb987 --- /dev/null +++ b/desk/lib/nostrill/follows.hoon @@ -0,0 +1,40 @@ +/- sur=nostrill, nsur=nostr, comms=nostrill-comms, feed=trill-feed +/+ js=json-nostr, sr=sortug, nlib=nostr, constants, gatelib=trill-gate, feedlib=trill-feed, jsonlib=json-nostrill +|_ [=state:sur =bowl:gall] +++ handle-add |= =user:sur + ^- (quip card:agent:gall _state) + ?- -.user + %urbit =/ c (urbit-watch +.user) + :- :~(c) state + %nostr `state + == +++ handle-del |= =user:sur + ^- (quip card:agent:gall _state) + =. following.state (~(del by following.state) user) + =/ graph (~(get by follow-graph.state) [%urbit our.bowl]) + ?~ graph `state + =/ nset (~(del in u.graph) user) + =. follow-graph.state (~(put by follow-graph.state) [%urbit our.bowl] nset) + `state +++ handle-follow-ok |= [=user:sur =feed:feed profile=(unit user-meta:nsur)] + ^- (quip card:agent:gall _state) + =. following (~(put by following) user feed) + =. profiles ?~ profile profiles (~(put by profiles) user u.profile) + `state + + +++ urbit-watch |= sip=@p ^- card:agent:gall + [%pass /watch %agent [sip dap.bowl] %watch /follow] + +:: ++ res-fact |= =res:comms ^- (list card:agent:gall) +:: =/ paths ~[/beg/feed] +:: =/ =poke:comms [%res res] +:: ~& > giving-res-fact=res +:: =/ jon (beg-res:en:jsonlib res) +:: =/ cage [%json !>(jon)] +:: :~ +:: [%give %fact paths cage] +:: [%give %kick paths ~] +:: == + +-- diff --git a/desk/lib/nostrill/mutations.hoon b/desk/lib/nostrill/mutations.hoon index f493bcf..8fca2b2 100644 --- a/desk/lib/nostrill/mutations.hoon +++ b/desk/lib/nostrill/mutations.hoon @@ -181,7 +181,7 @@ ?~ ujon ~& failed-parse-metadata=ujon `state =/ umeta (user-meta:de:njs u.ujon) ?~ umeta ~& >> failed-dejs-metadata=ujon `state - =. profiles.state (~(put by profiles.state) pubkey.event u.umeta) + =. profiles.state (~(put by profiles.state) [%nostr pubkey.event] u.umeta) `state @@ -224,7 +224,7 @@ `state ++ parse-follow ^- (quip card _state) - =/ following (~(get by follow-graph.state) pubkey.event) + =/ following (~(get by follow-graph.state) [%nostr pubkey.event]) =/ follow-set ?~ following *(set follow:sur) u.following |- ?~ tags.event `state =/ t=tag:nsur i.tags.event diff --git a/desk/sur/nostrill.hoon b/desk/sur/nostrill.hoon index 70ce480..a091dd0 100644 --- a/desk/sur/nostrill.hoon +++ b/desk/sur/nostrill.hoon @@ -12,9 +12,9 @@ :: nostr feed from relays =nostr-feed :: profiles - profiles=(map @ux user-meta:nostr) - following=(map @ux =feed:trill) - follow-graph=(map @ux (set follow)) + profiles=(map user user-meta:nostr) + following=(map user =feed:trill) + follow-graph=(map user (set user)) :: TODO global feed somehow? == @@ -29,6 +29,7 @@ $: pub=(unit @ux) relay=(unit @t) pr=(unit user-meta:nostr) == ++$ user $%([%urbit p=@p] [%nostr p=@ux]) +$ follow [pubkey=@ux name=@t relay=(unit @t)] ++ ui @@ -52,8 +53,8 @@ $: pub=(unit @ux) [%del pubkey=@ux] == +$ fols-poke - $% [%add pubkey=@ux] - [%del pubkey=@ux] + $% [%add =user] + [%del =user] == +$ prof-poke $% [%add meta=user-meta:nostr] diff --git a/desk/sur/nostrill/comms.hoon b/desk/sur/nostrill/comms.hoon index d3dc8e1..4930235 100644 --- a/desk/sur/nostrill/comms.hoon +++ b/desk/sur/nostrill/comms.hoon @@ -8,15 +8,13 @@ +$ req $% [%feed ~] [%thread id=@da] - [%prof ~] == +$ res $% [%ok p=res-data] [%ng msg=@t] == +$ res-data - $% [%feed =fc:feed] + $% [%feed =fc:feed profile=(unit user-meta:nsur)] [%thread p=full-node:post] - [%prof p=user-meta:nsur] == -- diff --git a/front/src/Router.tsx b/front/src/Router.tsx index 83d212f..1293709 100644 --- a/front/src/Router.tsx +++ b/front/src/Router.tsx @@ -27,3 +27,11 @@ function toGlobal() { export function P404() { return <h1 className="x-center">404</h1>; } +export function ErrorPage({ msg }: { msg: string }) { + return ( + <div> + <P404 /> + <h3>{msg}</h3> + </div> + ); +} diff --git a/front/src/components/Avatar.tsx b/front/src/components/Avatar.tsx index 0f3dc90..a071655 100644 --- a/front/src/components/Avatar.tsx +++ b/front/src/components/Avatar.tsx @@ -1,20 +1,21 @@ import useLocalState from "@/state/state"; -import type { Ship } from "@/types/urbit"; import Sigil from "./Sigil"; -import ShipModal from "./modals/ShipModal"; import { isValidPatp } from "urbit-ob"; -import type { UserProfile } from "@/types/nostrill"; +import type { UserProfile, UserType } from "@/types/nostrill"; import Icon from "@/components/Icon"; +import UserModal from "./modals/UserModal"; export default function ({ - p, + user, + userString, size, color, noClickOnName, profile, picOnly = false, }: { - p: Ship; + user: UserType; + userString: string; size: number; color?: string; noClickOnName?: boolean; @@ -23,10 +24,11 @@ export default function ({ }) { const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); // TODO revisit this when %whom updates + console.log({ profile }); const avatarInner = profile ? ( - <img src={profile.picture} /> - ) : isValidPatp(p) ? ( - <Sigil patp={p} size={size} bg={color} /> + <img src={profile.picture} width={size} height={size} /> + ) : "urbit" in user && isValidPatp(user.urbit) ? ( + <Sigil patp={user.urbit} size={size} bg={color} /> ) : ( <Icon name="comet" /> ); @@ -41,14 +43,18 @@ export default function ({ function openModal(e: React.MouseEvent) { if (noClickOnName) return; e.stopPropagation(); - setModal(<ShipModal ship={p} />); + setModal(<UserModal user={user} userString={userString} />); } const name = ( <div className="name cp" role="link" onMouseUp={openModal}> {profile ? ( <p>{profile.name}</p> + ) : "urbit" in user ? ( + <p className={"p-only" + tooLong(user.urbit)}> + {user.urbit.length > 28 ? "Anon" : user.urbit} + </p> ) : ( - <p className={"p-only" + tooLong(p)}>{p.length > 28 ? "Anon" : p}</p> + <p className={"p-only" + tooLong(user.nostr)}>{user.nostr}</p> )} </div> ); diff --git a/front/src/components/Icon.tsx b/front/src/components/Icon.tsx index a316e08..797a87b 100644 --- a/front/src/components/Icon.tsx +++ b/front/src/components/Icon.tsx @@ -65,7 +65,7 @@ interface IconProps { size?: number; className?: string; title?: string; - onClick?: (e?: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent) => any; color?: "primary" | "text" | "textSecondary" | "textMuted" | "custom"; customColor?: string; } @@ -84,7 +84,11 @@ const Icon: React.FC<IconProps> = ({ // Simple filter based on theme - icons should match text const getFilter = () => { // For dark themes, invert the black SVGs to white - if (theme.name === "dark" || theme.name === "noir" || theme.name === "gruvbox") { + if ( + theme.name === "dark" || + theme.name === "noir" || + theme.name === "gruvbox" + ) { return "invert(1)"; } // For light themes with dark text, keep as is @@ -130,4 +134,4 @@ const Icon: React.FC<IconProps> = ({ ); }; -export default Icon;
\ No newline at end of file +export default Icon; diff --git a/front/src/components/modals/UserModal.tsx b/front/src/components/modals/UserModal.tsx new file mode 100644 index 0000000..6e3089d --- /dev/null +++ b/front/src/components/modals/UserModal.tsx @@ -0,0 +1,65 @@ +import Modal from "./Modal"; +import Avatar from "../Avatar"; +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) => ({ + setModal: s.setModal, + api: s.api, + pubkey: s.pubkey, + })); + const [_, navigate] = useLocation(); + function close() { + setModal(null); + } + const itsMe = + "urbit" in user + ? user.urbit === api?.airlock.our + : "nostr" in user + ? user.nostr === pubkey + : false; + async function copy(e: React.MouseEvent) { + e.stopPropagation(); + await navigator.clipboard.writeText(userString); + toast.success("Copied to clipboard"); + } + 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> + <div className="buttons f1"> + <button onClick={() => navigate(`/feed/${userString}`)}>Feed</button> + <button onClick={() => navigate(`/pals/${userString}`)}> + Profile + </button> + {itsMe && ( + <> + <button onClick={() => navigate(`/chat/dm/${userString}`)}> + DM + </button> + </> + )} + </div> + </div> + </Modal> + ); +} diff --git a/front/src/components/post/Post.tsx b/front/src/components/post/Post.tsx index 277c119..2965040 100644 --- a/front/src/components/post/Post.tsx +++ b/front/src/components/post/Post.tsx @@ -22,6 +22,7 @@ export interface PostProps { profile?: UserProfile; } function Post(props: PostProps) { + console.log("post", props); const { poast } = props; if (!poast || poast.contents === null) { return null; diff --git a/front/src/components/ProfileEditor.tsx b/front/src/components/profile/Editor.tsx index 9a7493f..2e4aebc 100644 --- a/front/src/components/ProfileEditor.tsx +++ b/front/src/components/profile/Editor.tsx @@ -1,31 +1,37 @@ -import { useState, useEffect } from "react"; -import type { UserProfile } from "@/types/nostrill"; +import { useState } from "react"; +import type { UserProfile, UserType } from "@/types/nostrill"; import useLocalState from "@/state/state"; import Icon from "@/components/Icon"; import toast from "react-hot-toast"; -import Avatar from "./Avatar"; +import Avatar from "../Avatar"; interface ProfileEditorProps { - ship: string; + user: UserType; + userString: string; + profile: UserProfile | undefined; onSave?: () => void; } -const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { +const ProfileEditor: React.FC<ProfileEditorProps> = ({ + user, + profile, + userString, + onSave, +}) => { const { api, profiles } = useLocalState((s) => ({ api: s.api, + pubkey: s.pubkey, profiles: s.profiles, })); - const isOwnProfile = ship === api?.airlock.our; // Initialize state with existing profile or defaults - const existingProfile = profiles.get(ship); - const [name, setName] = useState(existingProfile?.name || ""); - const [picture, setPicture] = useState(existingProfile?.picture || ""); - const [about, setAbout] = useState(existingProfile?.about || ""); + const [name, setName] = useState(profile?.name || userString); + const [picture, setPicture] = useState(profile?.picture || ""); + const [about, setAbout] = useState(profile?.about || ""); const [customFields, setCustomFields] = useState< Array<{ key: string; value: string }> >( - Object.entries(existingProfile?.other || {}).map(([key, value]) => ({ + Object.entries(profile?.other || {}).map(([key, value]) => ({ key, value, })), @@ -33,21 +39,6 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); - useEffect(() => { - const profile = profiles.get(ship); - if (profile) { - setName(profile.name || ""); - setPicture(profile.picture || ""); - setAbout(profile.about || ""); - setCustomFields( - Object.entries(profile.other || {}).map(([key, value]) => ({ - key, - value, - })), - ); - } - }, [ship, profiles]); - const handleAddCustomField = () => { setCustomFields([...customFields, { key: "", value: "" }]); }; @@ -77,7 +68,7 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { } }); - const profile: UserProfile = { + const nprofile: UserProfile = { name, picture, about, @@ -86,7 +77,7 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { // Call API to save profile if (api && typeof api.createProfile === "function") { - await api.createProfile(profile); + await api.createProfile(nprofile); } else { throw new Error("Profile update API not available"); } @@ -104,9 +95,9 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { const handleCancel = () => { // Reset to original values - const profile = profiles.get(ship); + const profile = profiles.get(userString); if (profile) { - setName(profile.name || ""); + setName(profile.name || userString); setPicture(profile.picture || ""); setAbout(profile.about || ""); setCustomFields( @@ -118,39 +109,14 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { } setIsEditing(false); }; - - if (!isOwnProfile) { - // View-only mode for other users' profiles - no editing allowed - return ( - <div className="profile-editor view-mode"> - <div className="profile-picture"> - <Avatar p={ship} size={120} picOnly={true} /> - </div> - <div className="profile-info"> - <h2>{name || ship}</h2> - {about && <p className="profile-about">{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> - ))} - </div> - )} - </div> - </div> - ); - } + console.log({ profile }); + console.log({ name, picture, customFields }); return ( <div className="profile-editor"> <div className="profile-header"> <h2>Edit Profile</h2> - {isOwnProfile && !isEditing && ( + {!isEditing && ( <button onClick={() => setIsEditing(true)} className="edit-btn"> <Icon name="settings" size={16} /> Edit @@ -181,7 +147,17 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { placeholder="https://example.com/avatar.jpg" /> <div className="picture-preview"> - <Avatar p={ship} size={54} picOnly={true} /> + {picture ? ( + <img src={picture} /> + ) : ( + <Avatar + user={user} + userString={userString} + profile={profile} + size={120} + picOnly={true} + /> + )} </div> </div> @@ -252,11 +228,17 @@ const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { ) : ( <div className="profile-view"> <div className="profile-picture"> - <Avatar p={ship} size={120} picOnly={true} /> + <Avatar + user={user} + userString={userString} + profile={profile} + size={120} + picOnly={true} + /> </div> <div className="profile-info"> - <h3>{name || ship}</h3> + <h3>{name}</h3> {about && <p className="profile-about">{about}</p>} {customFields.length > 0 && ( diff --git a/front/src/components/profile/Profile.tsx b/front/src/components/profile/Profile.tsx new file mode 100644 index 0000000..b5f22e9 --- /dev/null +++ b/front/src/components/profile/Profile.tsx @@ -0,0 +1,67 @@ +import "@/styles/Profile.css"; +import type { UserProfile, UserType } from "@/types/nostrill"; +import useLocalState from "@/state/state"; +import Avatar from "../Avatar"; +import ProfileEditor from "./Editor"; + +interface Props { + user: UserType; + userString: string; + isMe: boolean; + onSave?: () => void; +} + +const Loader: React.FC<Props> = (props) => { + const { profiles } = useLocalState((s) => ({ + profiles: s.profiles, + })); + const profile = profiles.get(props.userString); + + if (props.isMe) return <ProfileEditor {...props} profile={profile} />; + else return <Profile profile={profile} {...props} />; +}; +function Profile({ + user, + userString, + profile, +}: { + user: UserType; + userString: string; + profile: UserProfile | undefined; +}) { + // Initialize state with existing profile or defaults + + // View-only mode for other users' profiles - no editing allowed + 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> + <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> + ))} + </div> + )} + </div> + </div> + ); +} + +export default Loader; diff --git a/front/src/logic/api.ts b/front/src/logic/api.ts index cf44073..148d255 100644 --- a/front/src/logic/api.ts +++ b/front/src/logic/api.ts @@ -1,6 +1,6 @@ import Urbit from "urbit-api"; -export const URL = import.meta.env.PROD ? "" : "http://localhost:8083"; +export const URL = import.meta.env.PROD ? "" : "http://localhost:8081"; export async function start(): Promise<Urbit> { const airlock = new Urbit(URL, ""); diff --git a/front/src/logic/nostrill.ts b/front/src/logic/nostrill.ts index bf9212d..bd5fc9c 100644 --- a/front/src/logic/nostrill.ts +++ b/front/src/logic/nostrill.ts @@ -1,6 +1,9 @@ import type { Event } from "@/types/nostr"; import type { Content, FC, Poast } from "@/types/trill"; import { engagementBunt, openLock } from "./bunts"; +import type { UserType } from "@/types/nostrill"; +import type { Result } from "@/types/ui"; +import { isValidPatp } from "urbit-ob"; export function eventsToFc(postEvents: Event[]): FC { const fc = postEvents.reduce( (acc: FC, event: Event) => { @@ -66,6 +69,24 @@ export function eventToPoast(event: Event): Poast | null { return poast; } +export function userToString(user: UserType): Result<string> { + if ("urbit" in user) { + const isValid = isValidPatp(user.urbit); + if (isValid) return { ok: user.urbit }; + else return { error: "invalid @p" }; + } else if ("nostr" in user) return { ok: user.nostr }; + else return { error: "unknown user" }; +} +export function isValidNostrPubkey(pubkey: string): boolean { + // TODO + if (pubkey.length !== 64) return false; + try { + BigInt("0x" + pubkey); + return true; + } catch (_e) { + return false; + } +} // NOTE common tags: // imeta // client diff --git a/front/src/logic/requests/nostrill.ts b/front/src/logic/requests/nostrill.ts index 74fcb87..4147e35 100644 --- a/front/src/logic/requests/nostrill.ts +++ b/front/src/logic/requests/nostrill.ts @@ -2,7 +2,7 @@ import type Urbit from "urbit-api"; import type { Cursor, FC, PostID } from "@/types/trill"; import type { Ship } from "@/types/urbit"; import { FeedPostCount } from "../constants"; -import type { UserProfile } from "@/types/nostrill"; +import type { UserProfile, UserType } from "@/types/nostrill"; import type { AsyncRes } from "@/types/ui"; // Subscribe @@ -121,13 +121,13 @@ export default class IO { } // follows - async follow(ship: Ship) { - const json = { add: ship }; + async follow(user: UserType) { + const json = { add: user }; return this.poke({ fols: json }); } - async unfollow(ship: Ship) { - const json = { del: ship }; + async unfollow(user: UserType) { + const json = { del: user }; return await this.poke({ fols: json }); } // profiles @@ -162,7 +162,9 @@ export default class IO { } // threads // - async peekFeed(host: string): AsyncRes<FC> { + async peekFeed( + host: string, + ): AsyncRes<{ feed: FC; profile: UserProfile | null }> { try { const json = { begs: { feed: host } }; const res: any = await this.thread("beg", json); @@ -170,7 +172,7 @@ export default class IO { if (!("begs" in res)) return { error: "wrong request" }; if ("ng" in res.begs) return { error: res.begs.ng }; if (!("feed" in res.begs.ok)) return { error: "wrong request" }; - else return { ok: res.begs.ok.feed }; + else return { ok: res.begs.ok }; } catch (e) { return { error: `${e}` }; } diff --git a/front/src/pages/Feed.tsx b/front/src/pages/Feed.tsx index 5902162..66acc66 100644 --- a/front/src/pages/Feed.tsx +++ b/front/src/pages/Feed.tsx @@ -1,7 +1,7 @@ // import spinner from "@/assets/icons/spinner.svg"; import "@/styles/trill.css"; import "@/styles/feed.css"; -import UserFeed from "./User"; +import UserLoader from "./User"; import PostList from "@/components/feed/PostList"; import useLocalState from "@/state/state"; import { useParams } from "wouter"; @@ -10,10 +10,8 @@ import { useState } from "react"; import Composer from "@/components/composer/Composer"; import Icon from "@/components/Icon"; import toast from "react-hot-toast"; -// import UserFeed from "./User"; -import { P404 } from "@/Router"; -import { isValidPatp } from "urbit-ob"; import { eventsToFc } from "@/logic/nostrill"; +import { ErrorPage } from "@/Router"; type FeedType = "global" | "following" | "nostr"; function Loader() { @@ -27,8 +25,8 @@ function Loader() { if (params.taip === "nostr") return <FeedPage t={"nostr"} />; // else if (param === FeedType.Rumors) return <Rumors />; // else if (param === FeedType.Home) return <UserFeed p={our} />; - else if (isValidPatp(params.taip!)) return <UserFeed p={params.taip!} />; - else return <P404 />; + else if (params.taip) return <UserLoader userString={params.taip!} />; + else return <ErrorPage msg="No such page" />; } function FeedPage({ t }: { t: FeedType }) { const [active, setActive] = useState<FeedType>(t); diff --git a/front/src/pages/User.tsx b/front/src/pages/User.tsx index e209bb3..d8b66e1 100644 --- a/front/src/pages/User.tsx +++ b/front/src/pages/User.tsx @@ -1,25 +1,59 @@ // import spinner from "@/assets/icons/spinner.svg"; import Composer from "@/components/composer/Composer"; import PostList from "@/components/feed/PostList"; -import ProfileEditor from "@/components/ProfileEditor"; +import Profile from "@/components/profile/Profile"; import useLocalState, { useStore } from "@/state/state"; -import type { Ship } from "@/types/urbit"; -import "@/styles/ProfileEditor.css"; import Icon from "@/components/Icon"; import toast from "react-hot-toast"; import { useState } from "react"; import type { FC } from "@/types/trill"; +import type { UserType } from "@/types/nostrill"; +import { isValidPatp } from "urbit-ob"; +import { isValidNostrPubkey } from "@/logic/nostrill"; +import { ErrorPage } from "@/Router"; -function UserFeed({ p }: { p: Ship }) { - const { api } = useLocalState((s) => ({ +function UserLoader({ userString }: { userString: string }) { + const { api, pubkey } = useLocalState((s) => ({ api: s.api, + pubkey: s.pubkey, + })); + // auto updating on SSE doesn't work if we do shallow + + const user = isValidPatp(userString) + ? { urbit: userString } + : isValidNostrPubkey(userString) + ? { nostr: userString } + : { error: "" }; + + const isOwnProfile = + "urbit" in user + ? user.urbit === api?.airlock.our + : "nostr" in user + ? pubkey === user.nostr + : false; + if ("error" in user) return <ErrorPage msg={"Invalid user"} />; + else + return <UserFeed user={user} userString={userString} isMe={isOwnProfile} />; +} + +function UserFeed({ + user, + userString, + isMe, +}: { + user: UserType; + userString: string; + isMe: boolean; +}) { + const { api, addProfile } = useLocalState((s) => ({ + api: s.api, + addProfile: s.addProfile, })); // auto updating on SSE doesn't work if we do shallow const { following } = useStore(); - const feed = following.get(p); + const feed = following.get(userString); const refetch = () => feed; - const isOwnProfile = p === api?.airlock.our; - const isFollowing = following.has(p); + const isFollowing = following.has(userString); const [isFollowLoading, setIsFollowLoading] = useState(false); const [isAccessLoading, setIsAccessLoading] = useState(false); @@ -31,14 +65,16 @@ function UserFeed({ p }: { p: Ship }) { setIsFollowLoading(true); try { if (isFollowing) { - await api.unfollow(p); - toast.success(`Unfollowed ${p}`); + await api.unfollow(user); + toast.success(`Unfollowed ${userString}`); } else { - await api.follow(p); - toast.success(`Now following ${p}`); + await api.follow(user); + toast.success(`Now following ${userString}`); } } catch (error) { - toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} ${p}`); + toast.error( + `Failed to ${isFollowing ? "unfollow" : "follow"} ${userString}`, + ); console.error("Follow error:", error); } finally { setIsFollowLoading(false); @@ -47,15 +83,20 @@ function UserFeed({ p }: { p: Ship }) { const handleRequestAccess = async () => { if (!api) return; + if (!("urbit" in user)) return; setIsAccessLoading(true); try { - const res = await api.peekFeed(p); - toast.success(`Access request sent to ${p}`); + const res = await api.peekFeed(user.urbit); + toast.success(`Access request sent to ${user.urbit}`); if ("error" in res) toast.error(res.error); - else setFC(res.ok); + 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 ${p}`); + toast.error(`Failed to request access from ${user.urbit}`); console.error("Access request error:", error); } finally { setIsAccessLoading(false); @@ -64,14 +105,14 @@ function UserFeed({ p }: { p: Ship }) { return ( <div id="user-page"> - <ProfileEditor ship={p} /> + <Profile user={user} userString={userString} isMe={isMe} /> - {!isOwnProfile && ( + {!isMe && ( <div className="user-actions"> <button onClick={handleFollow} disabled={isFollowLoading} - className={`action-btn ${isFollowing ? "following" : "follow"}`} + className={`action-btn ${isFollowing ? "" : "follow"}`} > {isFollowLoading ? ( <> @@ -118,7 +159,7 @@ function UserFeed({ p }: { p: Ship }) { </div> ) : null} - {!isOwnProfile && !feed && !fc && ( + {!isMe && !feed && !fc && ( <div id="other-user-feed"> <div className="empty-feed-message"> <Icon name="messages" size={48} color="textMuted" /> @@ -135,4 +176,4 @@ function UserFeed({ p }: { p: Ship }) { ); } -export default UserFeed; +export default UserLoader; diff --git a/front/src/state/state.ts b/front/src/state/state.ts index 2e747ea..715427d 100644 --- a/front/src/state/state.ts +++ b/front/src/state/state.ts @@ -19,10 +19,11 @@ export type LocalState = { setModal: (modal: JSX.Element | null) => void; composerData: ComposerData | null; setComposerData: (c: ComposerData | null) => void; - key: string; + pubkey: string; nostrFeed: Event[]; relays: Record<string, Event[]>; profiles: Map<string, UserProfile>; // pubkey key + addProfile: (key: string, u: UserProfile) => void; following: Map<string, FC>; followers: string[]; }; @@ -38,7 +39,7 @@ export const useStore = creator((set, get) => ({ await api.subscribeStore((data) => { console.log("store sub", data); if ("state" in data) { - const { feed, nostr, following, relays, profiles, key } = data.state; + const { feed, nostr, following, relays, profiles, pubkey } = data.state; const flwing = new Map(Object.entries(following as Record<string, FC>)); flwing.set(api!.airlock.our!, feed); set({ @@ -46,7 +47,7 @@ export const useStore = creator((set, get) => ({ nostrFeed: nostr, profiles: new Map(Object.entries(profiles)), following: flwing, - key, + pubkey, }); } else if ("fact" in data) { if ("post" in data.fact) { @@ -65,8 +66,13 @@ export const useStore = creator((set, get) => ({ }); set({ api }); }, - key: "", + pubkey: "", profiles: new Map(), + addProfile: (key, profile) => { + const profiles = get().profiles; + profiles.set(key, profile); + set({ profiles }); + }, relays: {}, nostrFeed: [], following: new Map(), diff --git a/front/src/styles/ProfileEditor.css b/front/src/styles/Profile.css index c1b65e5..624cb12 100644 --- a/front/src/styles/ProfileEditor.css +++ b/front/src/styles/Profile.css @@ -1,4 +1,4 @@ -.profile-editor { +.profile { align-items: center; padding: 20px; background: var(--color-surface); diff --git a/front/src/styles/feed.css b/front/src/styles/feed.css index 05f0bb2..02d64db 100644 --- a/front/src/styles/feed.css +++ b/front/src/styles/feed.css @@ -2,12 +2,6 @@ border: 1px solid var(--color-text); } -.avatar, -.avatar img { - width: 48px; - height: 48px; -} - /* Nostr Feed Styles */ .nostr-empty-state { display: flex; @@ -133,6 +127,7 @@ from { transform: rotate(0deg); } + to { transform: rotate(360deg); } diff --git a/front/src/types/nostrill.ts b/front/src/types/nostrill.ts index bcd3628..5ce033c 100644 --- a/front/src/types/nostrill.ts +++ b/front/src/types/nostrill.ts @@ -1,6 +1,7 @@ import type { NostrEvent } from "./nostr"; import type { Poast } from "./trill"; +export type UserType = { urbit: string } | { nostr: string }; export type UserProfile = { name: string; picture: string; // URL |