diff options
50 files changed, 2344 insertions, 435 deletions
diff --git a/desk/app/nostrill.hoon b/desk/app/nostrill.hoon index e311b5f..17732e7 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 + trill=trill-post, comms=nostrill-comms /= web /web/router |% +$ versioned-state $%(state-0:sur) @@ -16,6 +16,7 @@ cards ~(. cards:lib bowl) mutat ~(. muta [state bowl]) shimm ~(. shim [state bowl]) + coms ~(. comms [state bowl]) ++ on-init ^- (quip card:agent:gall agent:gall) =/ default (default-state:lib bowl) @@ -34,17 +35,26 @@ ?- -.old-state %0 `this(state old-state) == + :: `this(state (default-state:lib bowl)) :: ++ on-poke |~ [=mark =vase] ^- (quip card:agent:gall agent:gall) |^ ?+ mark `this - %noun debug - %json on-ui + %noun handle-comms + %json on-ui %handle-http-request handle-shim == - :: handling shim events + ++ handle-comms + =/ pok (cast-poke:coms q.vase) + ?: ?=(%dbug -.pok) (debug +.pok) + =^ cs state + ?- -.pok + %req (handle-req:coms +.pok) + %res (handle-res:coms +.pok) + == + [cs this] ++ handle-shim =/ order !<(order:web vase) :: ~& request.req.order @@ -68,6 +78,7 @@ ?- -.u.upoke %keys handle-cycle-keys %fols (handle-fols +.u.upoke) + %begs (handle-begs +.u.upoke) %prof (handle-prof +.u.upoke) %post (handle-post +.u.upoke) %rela (handle-rela +.u.upoke) @@ -95,19 +106,39 @@ %rt `this %del `this == + ++ handle-begs |= poke=begs-poke:ui:sur + ?- -.poke + %feed + =/ cs ~ + [cs this] + %thread + =/ cs ~ + [cs this] + == ++ handle-fols |= poke=fols-poke:ui:sur - `this + ?- -.poke + %add `this + %del `this + == ++ handle-prof |= poke=prof-poke:ui:sur ?- -.poke %add - =. profiles (~(put by profiles) +<.poke +>.poke) + =. profiles (~(put by profiles) pub.i.keys +.poke) `this %del - =. profiles (~(del by profiles) +.poke) + =. profiles (~(del by profiles) pub.i.keys) `this == ++ handle-rela |= poke=relay-poke:ui:sur ?- -.poke + %add =. relays (~(put by relays) +.poke *relay-stats:nsur) + `this + %del =. relays (~(del by relays) +.poke) + `this + :: + %sync =^ cs state get-posts:shimm + [cs this] + :: %send =/ upoast (get-poast:mutat host.poke id.poke) ?~ upoast `this @@ -119,8 +150,7 @@ :: - ++ debug - =/ noun !<(* vase) + ++ debug |= noun=* ?+ noun `this %wtf =/ lol=(unit @) ~ @@ -211,12 +241,19 @@ :: ++ on-watch |= =(pole knot) - ?> .=(our.bowl src.bowl) + ~& on-watch=`path`pole ?+ pole !! + [%beg %feed ~] + :_ this give-feed:coms + [%beg %thread ids=@t ~] + =/ id (slaw:sr %uw ids.pole) + ?~ id ~& error-parsing-ted-id=pole `this + :_ this (give-ted:coms u.id) [%ui ~] + ?> .=(our.bowl src.bowl) :_ this =/ jon (state:en:jsonlib state) - [%give %fact ~ [%json !>(jon)]]^~ + [%give %fact ~[/ui] [%json !>(jon)]]^~ == :: ++ on-leave diff --git a/desk/lib/json/nostrill.hoon b/desk/lib/json/nostrill.hoon index bd34acc..b5a619c 100644 --- a/desk/lib/json/nostrill.hoon +++ b/desk/lib/json/nostrill.hoon @@ -1,4 +1,4 @@ -/- sur=nostrill, nsur=nostr, feed=trill-feed +/- sur=nostrill, nsur=nostr, feed=trill-feed, comms=nostrill-comms /+ sr=sortug, common=json-common, trill=json-trill, nostr=json-nostr |% ++ en @@ -70,9 +70,13 @@ %+ frond %fact %+ frond -.f ?- -.f - %post (postfact +.f) - %enga (enga +.f) + %nostr (en-nostr-feed +.f) + %post (postfact +.f) + %enga (enga +.f) == + ++ tedfact |= pf=post-fact:ui:sur ^- json + %+ frond -.pf + (post-wrapper +.pf) ++ postfact |= pf=post-fact:ui:sur ^- json %+ frond -.pf (post-wrapper +.pf) @@ -93,6 +97,21 @@ =. l ?~ relay.p l :_ l ['relay' %s u.relay.p] =. l ?~ pr.p l :_ l ['profile' (user-meta:en:nostr u.pr.p)] %- pairs l + + ++ beg-res |= =res:comms ^- json + %+ frond %begs %+ frond -.res + ?- -.res + %ok (resd +.res) + %ng [%s msg.res] + == + ++ resd |= rd=res-data:comms ^- json + %+ frond -.rd + ?- -.rd + %feed (feed-with-cursor:en:trill +.rd) + :: TODO wrap it for nostr shit + %thread (full-node:en:trill +.rd) + %prof (user-meta:en:nostr +.rd) + == -- ++ de =, dejs-soft:format @@ -102,6 +121,7 @@ %- of :~ keys+ul fols+ui-fols + begs+ui-begs prof+ui-prof post+ui-post rela+ui-relay @@ -111,15 +131,21 @@ add+hex:de:common del+hex:de:common == +++ ui-begs + %- of :~ + feed+(se:de:common %p) + thread+de-pid + == +++ de-pid + %- ot :~ + host+(se:de:common %p) + id+de-atom-id + == ++ ui-prof %- of :~ - add+add-prof - del+hex:de:common + add+user-meta:de:nostr + del+ul == -++ add-prof %- ot :~ - pubkey+hex:de:common - meta+user-meta:de:nostr -== ++ ui-post %- of :~ add+de-post @@ -138,9 +164,12 @@ == ++ ui-relay %- of :~ - send+de-relay + add+so + del+so + sync+ul + send+de-relay-send == -++ de-relay %- ot :~ +++ de-relay-send %- ot :~ host+(se:de:common %p) id+de-atom-id relays+(ar so) diff --git a/desk/lib/json/trill.hoon b/desk/lib/json/trill.hoon index efa4ffc..415d2f4 100644 --- a/desk/lib/json/trill.hoon +++ b/desk/lib/json/trill.hoon @@ -185,7 +185,35 @@ :~ ship+(patp:en:common ship.pid) id+(ud:en:common id.pid) == - :: + ++ full-node + |= p=full-node:post ^- json + %- pairs + :~ id+(ud:en:common id.p) + host+(patp:en:common host.p) + author+(patp:en:common author.p) + thread+(ud:en:common thread.p) + parent+?~(parent.p ~ (ud:en:common u.parent.p)) + contents+(content contents.p) + hash+(b64:en:common hash.p) + engagement+(engagement engagement.p) + children+(internal-graph children.p) + time+(time id.p) + == + ++ internal-graph + |= int=internal-graph:post ^- json + ?- -.int + %empty ~ + %full (full-graph +.int) + == + ++ full-graph + |= f=full-graph:post + ^- json + %- pairs + %+ turn (tap:form:post f) + |= [post-id=@da fn=full-node:post] + ^- [@ta json] + :- (crip (scow:sr %ud `@ud`post-id)) + (full-node fn) :: -- -- diff --git a/desk/lib/nostrill.hoon b/desk/lib/nostrill.hoon index 0570dbc..6d22adc 100644 --- a/desk/lib/nostrill.hoon +++ b/desk/lib/nostrill.hoon @@ -5,7 +5,7 @@ ++ default-state |= =bowl:gall ^- state:sur =/ s *state-0:sur =/ l public-relays:nsur - =/ l (scag 1 l) + :: =/ l (scag 1 l) :: =/ l ~['wss://relay.damus.io' 'wss://nos.lol'] =/ rl %+ turn l |= t=@t [t *relay-stats:nsur] :: =/ l ~[['wss://relay.damus.io' ~]] diff --git a/desk/lib/nostrill/comms.hoon b/desk/lib/nostrill/comms.hoon new file mode 100644 index 0000000..833c07d --- /dev/null +++ b/desk/lib/nostrill/comms.hoon @@ -0,0 +1,99 @@ +/- 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] +++ cast-poke + |= raw=* ^- poke:comms + ;; poke:comms raw +:: Req +++ handle-req |= =req:comms + ?- -.req + %feed handle-feed + %thread (handle-thread +.req) + %prof handle-prof + == +++ handle-feed + =/ 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) + :: + =/ lp latest-page:feedlib + =/ lp2 lp(count backlog.feed-perms.state) + =/ =fc:feed (lp2 feed.state) + =/ crd (res-poke [%ok %feed fc]) + :_ state :~(crd) + +++ give-feed + ~& 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']) + :: + =/ lp latest-page:feedlib + =/ lp2 lp(count backlog.feed-perms.state) + =/ =fc:feed (lp2 feed.state) + (res-fact [%ok %feed fc]) + +++ give-ted |= id=@ + =/ ted (get:orm:feed feed.state id) + ?~ ted + (res-fact [%ng 'no such thread']) + =/ can (can-access:gatelib src.bowl read.u.ted bowl) + ?. can + (res-fact [%ng 'not allowed']) + :: + =/ fn (node-to-full:feedlib u.ted feed.state) + (res-fact [%ok %thread fn]) +:: +++ 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 + =/ crd (res-poke [%ng 'no such thread']) + :_ state :~(crd) + =/ can (can-access:gatelib src.bowl read.u.ted bowl) + ?. can + =/ crd (res-poke [%ng 'not allowed']) + :_ state :~(crd) + :: + =/ fn (node-to-full:feedlib u.ted feed.state) + =/ crd (res-poke [%ok %thread fn]) + :_ state :~(crd) +:: res +++ handle-res |= =res:comms + `state +:: +++ res-poke |= =res:comms ^- card:agent:gall + =/ =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] + =/ =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 4dda095..f493bcf 100644 --- a/desk/lib/nostrill/mutations.hoon +++ b/desk/lib/nostrill/mutations.hoon @@ -2,6 +2,7 @@ post=trill-post, gate=trill-gate, feed=trill-feed /+ appjs=json-nostrill, + lib=nostrill, njs=json-nostr, postlib=trill-post, shim, @@ -57,12 +58,6 @@ -:: ++ handle-shim-msg |= msg=res:shim:nsur -:: ^- (quip card _state) -:: ?- -.msg -:: %ws (handle-ws +.msg) -:: %http (handle-http +.msg) -:: == ++ handle-http |= [sub-id=@t msgs=(list relay-msg:nsur)] @@ -92,8 +87,10 @@ ++ handle-ws |= [relay=@t msg=relay-msg:nsur] =/ rs (~(get by relays.state) relay) - ?~ rs `state + ?~ rs :: TODO do we really + `state =^ cards state + ~& handle-ws=-.msg ?- -.msg %ok (handle-ok relay +.msg) %event @@ -102,11 +99,13 @@ %eose :: TODO do unsub for replaceable/addressable events - :: =/ creq (~(get by reqs.u.rs) +.msg) - :: ?~ creq `state + =/ creq (~(get by reqs.u.rs) +.msg) + ?~ creq `state :: =. reqs.u.rs (~(del by reqs.u.rs) +.msg) :: =. relays.state (~(put by relays.state) relay u.rs) - `state + =/ cardslib ~(. cards:lib bowl) + =/ c (update-ui:cardslib [%nostr nostr-feed.state]) + :_ state :~(c) %closed =. reqs.u.rs (~(del by reqs.u.rs) sub-id.msg) =. relays.state (~(put by relays.state) relay u.rs) `state diff --git a/desk/lib/shim.hoon b/desk/lib/shim.hoon index f2e0b8a..1b78f0a 100644 --- a/desk/lib/shim.hoon +++ b/desk/lib/shim.hoon @@ -13,7 +13,9 @@ ++ parse-body |= jstring=@t =/ ures (de:json:html jstring) ?~ ures ~ - (shim-res:de:js u.ures) + =/ ur (shim-res:de:js u.ures) + ?~ ur ~& >>> shim-msg-parsing-failed=jstring ~ + ur :: __ ++ get-req |= fs=(list filter:nsur) ^- [bulk-req:shim:nsur _state] @@ -74,8 +76,11 @@ |= req=bulk-req:shim:nsur ^- card:agent:gall =/ req-body (bulk-req:en:js req) :: ~& shim-req-json=(en:json:html req-body) + =/ host .^(hart:eyre %e /(scot %p our.bowl)/host/(scot %da now.bowl)) + =/ origin %- crip (head:en-purl:html host) =/ headers :~ [key='content-type' value='application/json'] + [key='origin' value=origin] == =/ =request:http [%'POST' url:shim:nsur headers `(json-body:web req-body)] =/ pat /shim @@ -86,8 +91,11 @@ ^- card:agent:gall =/ req-body (http-req:en:js req) :: ~& shim-req-json=(en:json:html req-body) + =/ host .^(hart:eyre %e /(scot %p our.bowl)/host/(scot %da now.bowl)) + =/ origin %- crip (head:en-purl:html host) =/ headers :~ [key='content-type' value='application/json'] + [key='origin' value=origin] == =/ =request:http [%'POST' url:shim:nsur headers `(json-body:web req-body)] [%pass /http/[sub-id.req] %arvo %k %fard dap.bowl %fetch %noun !>(request)] diff --git a/desk/lib/trill/feed.hoon b/desk/lib/trill/feed.hoon index c21feb3..721a596 100644 --- a/desk/lib/trill/feed.hoon +++ b/desk/lib/trill/feed.hoon @@ -1,14 +1,16 @@ -/- feed=trill-feed, sur=nostrill +/- feed=trill-feed, post=trill-post, sur=nostrill /+ sr=sortug, constants |% -++ latest-page |= f=feed:feed ^- fc:feed +++ latest-page +=/ count feed-page-size:constants +|= f=feed:feed ^- fc:feed =/ nodelist (tap:orm:feed f) - =/ subset (scag feed-page-size:constants nodelist) + =/ subset (scag count nodelist) ?~ subset [f ~ ~] - =/ start `id.i.subset + =/ start `-.i.subset =/ rev (flop subset) ?~ rev [f ~ ~] - =/ end `id.i.rev + =/ end `-.i.rev =/ nf (gas:orm:feed *feed:feed subset) [nf start end] :: @@ -16,20 +18,20 @@ =/ nodelist (tap:norm:sur f) =/ subset (scag feed-page-size:constants nodelist) ?~ subset [f ~ ~] - =/ start `id.i.subset + =/ start (some `@da`-.i.subset) =/ rev (flop subset) ?~ rev [f ~ ~] - =/ end `id.i.rev + =/ end (some `@da`-.i.rev) =/ nf (gas:norm:sur *nostr-feed:sur subset) [nf start end] :: :: NOTE START IS OLD, END IS NEW ++ subset +=/ count feed-page-size:constants |= [=fc:feed replies=? now=@da] ^- fc:feed ?: ?&(?=(%~ start.fc) ?=(%~ end.fc)) (latest-page feed.fc) - =/ count feed-page-size:constants =/ start ?~ start.fc 0 u.start.fc =/ end ?~ end.fc now u.end.fc =/ nodelist (tap:orm:feed feed.fc) @@ -43,9 +45,26 @@ == ?& (lte id start) (gte id end) == =/ thread-count (lent threads) - =/ result=(list [id:post post:post]) ?: newest (scag count threads) (flop (scag count (flop threads))) + :: TODO I remember something was weird about this + :: =/ result=(list [id:post post:post]) ?: newest (scag count threads) (flop (scag count (flop threads))) + =/ result=(list [id:post post:post]) (scag count threads) =/ cursors=[(unit @da) (unit @da)] ?~ result [~ ~] ?~ threads [~ ~] :- ?: .=((head result) (head threads)) ~ `id:(head result) ?: .=((rear result) (rear threads)) ~ `id:(rear result) [(gas:orm:feed *feed:feed result) -.cursors +.cursors] +:: posts +++ node-to-full +|= [p=post:post f=feed:feed] ^- full-node:post + p(children (convert-children children.p f)) +++ convert-children +|= [children=(set id:post) f=feed:feed] + ^- internal-graph:post + =/ g=full-graph:post %- ~(rep in children) + |= [=id:post acc=full-graph:post] + =/ n (get:orm:feed f id) + ?~ n acc + =/ full-node (node-to-full u.n f) + (put:form:post acc id full-node) + ?~ children [%empty ~] + :- %full g -- diff --git a/desk/lib/trill/gate.hoon b/desk/lib/trill/gate.hoon new file mode 100644 index 0000000..ebb78b8 --- /dev/null +++ b/desk/lib/trill/gate.hoon @@ -0,0 +1,79 @@ +/- gate=trill-gate +|% +++ mask-lock +|= =lock:gate ^- lock:gate + :* ?: public.rank.lock rank.lock [~ %| %|] + ?: public.luk.lock luk.lock [~ %| %|] + ?: public.ship.lock ship.lock [~ %| %|] + ?: public.tags.lock tags.lock [~ %| %|] + ?: public.custom.lock custom.lock [~ %|] + == +++ can-access +|= [=ship =lock:gate =bowl:gall] ^- ? + ?^ fn.custom.lock %- u.fn.custom.lock ship + =/ in-luk (~(has in caveats.ship.lock) ship) + =/ fu (sein:title our.bowl now.bowl ship) + =/ ye (sein:title our.bowl now.bowl fu) + =/ ze (sein:title our.bowl now.bowl ye) + =/ in-ship ?| + (~(has in caveats.luk.lock) fu) + (~(has in caveats.luk.lock) ye) + (~(has in caveats.luk.lock) ze) + == + =/ in-rank (~(has in caveats.rank.lock) (clan:title ship)) + :: =/ in-tags (~(has in (scry-pals-tags caveats.tags.lock)) ship) + =/ can |= [pit=? has=?] ^- ? ?: pit has !has + =/ as-ship (can locked.ship.lock in-ship) + =/ as-luk (can locked.ship.lock in-luk) + =/ as-rank (can locked.ship.lock in-rank) + ::=/ as-tags (can locked.ship.lock in-tags) + ?&(as-ship as-luk as-rank) + +++ scry-pals-tags +|= tags=(set @t) ^- (set @p) + :: .^() + ~ +++ apply-change +|= [=lock:gate =change:gate] ^- lock:gate + ?- -.change + %set-rank lock(rank +.change) + %set-luk lock(luk +.change) + %set-ship lock(ship +.change) + %set-tags lock(tags +.change) + %set-custom lock ::TODO + == +++ open-all +|= =lock:gate ^- lock:gate + %= lock + rank rank.lock(locked .n) + luk luk.lock(locked .n) + ship ship.lock(locked .n) + tags tags.lock(locked .n) + == +++ lock-all +|= =lock:gate ^- lock:gate +%= lock +rank rank.lock(locked .y) +luk luk.lock(locked .y) +ship ship.lock(locked .y) +tags tags.lock(locked .y) +== +++ toggle-rank +|= [r=rank:title setting=[caveats=(set rank:title) locked=? public=?]] + =/ new-caveats=(set rank:title) ?: locked.setting + (~(put in caveats.setting) r) + (~(del in caveats.setting) r) + setting(caveats new-caveats) +++ toggle-ship +|= [s=ship setting=[caveats=(set ship) locked=? public=?]] + =/ new-caveats=(set ship) ?: locked.setting + (~(put in caveats.setting) s) + (~(del in caveats.setting) s) + setting(caveats new-caveats) +++ toggle-tag +|= [t=@t setting=[caveats=(set @t) locked=? public=?]] + =/ new-caveats=(set @t) ?: locked.setting + (~(put in caveats.setting) t) + (~(del in caveats.setting) t) + setting(caveats new-caveats) +-- diff --git a/desk/mar/json.hoon b/desk/mar/json.hoon new file mode 100644 index 0000000..7d6fcbf --- /dev/null +++ b/desk/mar/json.hoon @@ -0,0 +1,26 @@ +:: +:::: /hoon/json/mar + :: +/? 310 + :: +:::: compute + :: +=, eyre +=, format +=, html +|_ jon=^json +:: +++ grow :: convert to + |% + ++ mime [/application/json (as-octs:mimes -:txt)] :: convert to %mime + ++ txt [(en:json jon)]~ + -- +++ grab + |% :: convert from + ++ mime |=([p=mite q=octs] (fall (de:json (@t q.q)) *^json)) + ++ noun ^json :: clam from %noun + ++ numb numb:enjs + ++ time time:enjs + -- +++ grad %mime +-- diff --git a/desk/mar/tang.hoon b/desk/mar/tang.hoon new file mode 100644 index 0000000..9fdd314 --- /dev/null +++ b/desk/mar/tang.hoon @@ -0,0 +1,25 @@ +:: +:::: /hoon/tang/mar + :: +/? 310 +:: +=, format +|_ tan=(list tank) +++ grad %noun +++ grow + |% + ++ noun tan + ++ json + =/ result=(each (list ^json) tang) + (mule |.((turn tan tank:enjs:format))) + ?- -.result + %& a+p.result + %| a+[a+[%s '[[output rendering error]]']~]~ + == + -- +++ grab :: convert from + |% + ++ noun (list ^tank) :: clam from %noun + ++ tank |=(a=^tank [a]~) + -- +-- diff --git a/desk/sur/nostr.hoon b/desk/sur/nostr.hoon index ff5ad6b..a1b54d1 100644 --- a/desk/sur/nostr.hoon +++ b/desk/sur/nostr.hoon @@ -77,10 +77,12 @@ $% [%event sub-id=@t =event] :: https://github.com/sesseor/nostr-relays-list/blob/main/relays.txt ++ public-relays ^- (list @t) - :~ 'wss://nos.lol' - :: 'wss://relay.damus.io' - :: 'wss://nostr.wine' - :: 'wss://offchain.pub' + :~ + 'wss://n.urbit.cloud' + 'wss://nos.lol' + 'wss://relay.damus.io' + 'wss://nostr.wine' + 'wss://offchain.pub' == :: 'wss://knostr.neutrine.com' -- diff --git a/desk/sur/nostrill.hoon b/desk/sur/nostrill.hoon index a9ef8f3..70ce480 100644 --- a/desk/sur/nostrill.hoon +++ b/desk/sur/nostrill.hoon @@ -1,4 +1,4 @@ -/- trill=trill-feed, tp=trill-post, nostr +/- nostr, trill=trill-feed, tp=trill-post, gate=trill-gate |% +$ state state-0 +$ state-0 @@ -8,6 +8,7 @@ keys=(lest keys:nostr) :: cycled, i.keys is current one :: own feed feed=feed:trill + feed-perms=gate:gate :: nostr feed from relays =nostr-feed :: profiles @@ -34,12 +35,17 @@ $: pub=(unit @ux) |% +$ poke $% [%fols fols-poke] + [%begs begs-poke] [%post post-poke] :: [%reac reac-poke] [%prof prof-poke] [%keys ~] :: cycle-keys [%rela relay-poke] == + +$ begs-poke + $% [%feed p=@p] + [%thread p=@p id=@da] + == +$ post-poke $% [%add content=@t] [%rt id=@ux pubkey=@ux relay=@t] :: NIP-18 @@ -50,15 +56,21 @@ $: pub=(unit @ux) [%del pubkey=@ux] == +$ prof-poke - $% [%add pubkey=@ux meta=user-meta:nostr] - [%del pubkey=@ux] + $% [%add meta=user-meta:nostr] + [%del ~] == +$ relay-poke - $% [%send host=@p id=@ relays=(list @t)] + $% [%add p=@t] + [%del p=@t] + :: + [%sync ~] + :: send event for... relaying + [%send host=@p id=@ relays=(list @t)] == :: facts +$ fact - $% [%post post-fact] + $% [%nostr feed=nostr-feed] + [%post post-fact] [%enga p=post-wrapper reaction=*] == +$ post-fact diff --git a/desk/sur/nostrill/comms.hoon b/desk/sur/nostrill/comms.hoon new file mode 100644 index 0000000..d3dc8e1 --- /dev/null +++ b/desk/sur/nostrill/comms.hoon @@ -0,0 +1,22 @@ +/- sur=nostrill, nsur=nostr, feed=trill-feed, post=trill-post +|% ++$ poke + $% [%req req] + [%res res] + [%dbug *] + == ++$ req + $% [%feed ~] + [%thread id=@da] + [%prof ~] + == ++$ res + $% [%ok p=res-data] + [%ng msg=@t] + == ++$ res-data + $% [%feed =fc:feed] + [%thread p=full-node:post] + [%prof p=user-meta:nsur] + == +-- diff --git a/desk/ted/beg.hoon b/desk/ted/beg.hoon new file mode 100644 index 0000000..2cabbea --- /dev/null +++ b/desk/ted/beg.hoon @@ -0,0 +1,31 @@ +/- spider +/+ strandio, jsonlib=json-nostrill +=, strand=strand:spider +=, strand-fail=strand-fail:libstrand:spider +^- thread:spider +|= arg=vase + =/ m (strand ,vase) ^- form:m + |^ + =/ ujon !<((unit json) arg) + :: ~& ujon=ujon + ?~ ujon (pure:m !>(bail)) + =/ req (ui:de:jsonlib u.ujon) + ?~ req (pure:m !>(bail)) + ?. ?=(%begs -.u.req) (pure:m !>(bail)) + ?- +<.u.req + %feed + ;< =bowl:spider bind:m get-bowl:strandio + =/ desk q.byk.bowl + ~& dock=[+>.u.req desk] + ;< =cage bind:m (watch-one:strandio /beg/feed [+>.u.req desk] /beg/feed) + ~& > watch-cage=-.cage + =/ j !<(json +.cage) + (pure:m !>(j)) + + %thread + (pure:m !>(bail)) + == + ++ bail ^- json + %+ frond:enjs:format %error + s+'error' + -- diff --git a/front/CLAUDE.md b/front/CLAUDE.md new file mode 100644 index 0000000..64ccf9b --- /dev/null +++ b/front/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- **Development server**: `bun run dev` - Starts Vite dev server on http://localhost:5173 +- **Build**: `bun run build` - TypeScript compilation followed by Vite production build +- **Linting**: `bun run lint` - Run ESLint on all files +- **Preview build**: `bun run preview` - Preview production build locally +- **Type checking**: `tsc -b` - Run TypeScript compiler to check types + +## Architecture + +This is a React TypeScript frontend for an Urbit application called Nostrill, which appears to integrate Nostr (decentralized social protocol) with Urbit (personal server). + +### Key Technologies +- **React 19** with TypeScript +- **Vite** for build tooling +- **Zustand** for global state management +- **TanStack Query** for server state and data fetching +- **Wouter** for routing +- **Urbit API** integration via custom packages in parent directories + +### Project Structure + +``` +src/ +├── components/ # UI components organized by feature +│ ├── composer/ # Post composition UI +│ ├── feed/ # Feed display components +│ ├── layout/ # Layout components (Sidebar, etc.) +│ ├── modals/ # Modal dialogs +│ └── post/ # Post display components and wrappers +├── logic/ # Business logic and utilities +│ ├── api.ts # Urbit connection setup +│ ├── nostrill.ts # Nostrill-specific logic +│ └── requests/ # API request handlers +├── pages/ # Route components (Feed, User, Settings) +├── state/ # Zustand store (state.ts) +├── styles/ # Styling and theming +├── types/ # TypeScript type definitions +└── Router.tsx # Main routing configuration +``` + +### State Management + +The application uses Zustand for state management (`src/state/state.ts`): +- Manages Urbit connection via `IO` class +- Stores Nostr events, user profiles, relay data +- Handles following/followers relationships +- Manages UI state (modals, composer data) + +### Urbit Integration + +- Connection established via `src/logic/api.ts` +- Uses Urbit Airlock/SSE for real-time updates +- Interacts with the `nostrill` desk on the Urbit ship +- Local packages used from parent directories: + - `urbit-api`: HTTP API client + - `urbit-ob`: Urbit ID utilities + - `urbit-sigils`: Visual ship identifiers + +### Path Aliases + +The project uses `@` alias for `src/` directory (configured in vite.config.ts). + +### Key Data Flows + +1. **Initialization**: App.tsx → state.init() → api.start() → Urbit connection +2. **State Updates**: Urbit SSE → IO subscriptions → Zustand store updates +3. **User Actions**: Components → IO methods → Urbit pokes/scries → State updates
\ No newline at end of file diff --git a/front/src/App.tsx b/front/src/App.tsx index f921bbf..415cb66 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -14,7 +14,10 @@ const queryClient = new QueryClient(); function App() { const [loading, setLoading] = useState(true); console.log("NOSTRILL INIT"); - const { init, modal } = useLocalState(); + const { init, modal } = useLocalState((s) => ({ + init: s.init, + modal: s.modal, + })); useEffect(() => { init().then((_res: any) => { setLoading(false); diff --git a/front/src/components/Avatar.tsx b/front/src/components/Avatar.tsx index 35b4386..0f3dc90 100644 --- a/front/src/components/Avatar.tsx +++ b/front/src/components/Avatar.tsx @@ -2,25 +2,41 @@ 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 Icon from "@/components/Icon"; export default function ({ p, size, color, noClickOnName, + profile, + picOnly = false, }: { p: Ship; size: number; color?: string; noClickOnName?: boolean; + profile?: UserProfile; + picOnly?: boolean; }) { - const { setModal } = useLocalState(); + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); // TODO revisit this when %whom updates + const avatarInner = profile ? ( + <img src={profile.picture} /> + ) : isValidPatp(p) ? ( + <Sigil patp={p} size={size} bg={color} /> + ) : ( + <Icon name="comet" /> + ); const avatar = ( - <div className="avatar-w sigil cp" role="link" onClick={openModal}> - <Sigil patp={p} size={size} color={color} /> + <div className="avatar cp" onClick={openModal}> + {avatarInner} </div> ); + if (picOnly) return avatar; + const tooLong = (s: string) => (s.length > 15 ? " too-long" : ""); function openModal(e: React.MouseEvent) { if (noClickOnName) return; @@ -29,31 +45,12 @@ export default function ({ } const name = ( <div className="name cp" role="link" onMouseUp={openModal}> - <p className={"p-only" + tooLong(p)}>{p.length > 28 ? "Anon" : p}</p> - </div> - ); - return ( - <div className="ship-avatar"> - {avatar} - {name} - </div> - ); -} - -export function SigilOnly({ p, size, color }: any) { - const { setModal } = useLocalState(); - function openModal(e: React.MouseEvent) { - e.stopPropagation(); - setModal(<ShipModal ship={p} />); - } - return ( - <div - className="avatar-w sigil cp" - role="link" - onClick={openModal} - onMouseUp={openModal} - > - <Sigil patp={p} size={size} color={color} /> + {profile ? ( + <p>{profile.name}</p> + ) : ( + <p className={"p-only" + tooLong(p)}>{p.length > 28 ? "Anon" : p}</p> + )} </div> ); + return <div className="ship-avatar">{name}</div>; } diff --git a/front/src/components/Icon.tsx b/front/src/components/Icon.tsx new file mode 100644 index 0000000..a316e08 --- /dev/null +++ b/front/src/components/Icon.tsx @@ -0,0 +1,133 @@ +import { useTheme } from "@/styles/ThemeProvider"; + +import bellSvg from "@/assets/icons/bell.svg"; +import cometSvg from "@/assets/icons/comet.svg"; +import copySvg from "@/assets/icons/copy.svg"; +import crowSvg from "@/assets/icons/crow.svg"; +import emojiSvg from "@/assets/icons/emoji.svg"; +import homeSvg from "@/assets/icons/home.svg"; +import keySvg from "@/assets/icons/key.svg"; +import messagesSvg from "@/assets/icons/messages.svg"; +import nostrSvg from "@/assets/icons/nostr.svg"; +import palsSvg from "@/assets/icons/pals.svg"; +import profileSvg from "@/assets/icons/profile.svg"; +import quoteSvg from "@/assets/icons/quote.svg"; +import radioSvg from "@/assets/icons/radio.svg"; +import replySvg from "@/assets/icons/reply.svg"; +import repostSvg from "@/assets/icons/rt.svg"; +import rumorsSvg from "@/assets/icons/rumors.svg"; +import settingsSvg from "@/assets/icons/settings.svg"; +import youtubeSvg from "@/assets/icons/youtube.svg"; + +export type IconName = + | "bell" + | "comet" + | "copy" + | "crow" + | "emoji" + | "home" + | "key" + | "messages" + | "nostr" + | "pals" + | "profile" + | "quote" + | "radio" + | "reply" + | "repost" + | "rumors" + | "settings" + | "youtube"; + +const iconMap: Record<IconName, string> = { + bell: bellSvg, + comet: cometSvg, + copy: copySvg, + crow: crowSvg, + emoji: emojiSvg, + home: homeSvg, + key: keySvg, + messages: messagesSvg, + nostr: nostrSvg, + pals: palsSvg, + profile: profileSvg, + quote: quoteSvg, + radio: radioSvg, + reply: replySvg, + repost: repostSvg, + rumors: rumorsSvg, + settings: settingsSvg, + youtube: youtubeSvg, +}; + +interface IconProps { + name: IconName; + size?: number; + className?: string; + title?: string; + onClick?: (e?: React.MouseEvent) => void; + color?: "primary" | "text" | "textSecondary" | "textMuted" | "custom"; + customColor?: string; +} + +const Icon: React.FC<IconProps> = ({ + name, + size = 20, + className = "", + title, + onClick, + color = "text", + customColor, +}) => { + const { theme } = useTheme(); + + // 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") { + return "invert(1)"; + } + // For light themes with dark text, keep as is + if (theme.name === "light") { + return "none"; + } + // For colored themes, adjust brightness/contrast + if (theme.name === "sepia") { + return "sepia(1) saturate(2) hue-rotate(20deg) brightness(0.8)"; + } + if (theme.name === "ocean") { + return "brightness(0) saturate(100%) invert(13%) sepia(95%) saturate(3207%) hue-rotate(195deg) brightness(94%) contrast(106%)"; + } + if (theme.name === "forest") { + return "brightness(0) saturate(100%) invert(24%) sepia(95%) saturate(1352%) hue-rotate(87deg) brightness(92%) contrast(96%)"; + } + return "none"; + }; + + const iconUrl = iconMap[name]; + + if (!iconUrl) { + console.error(`Icon "${name}" not found`); + return null; + } + + return ( + <img + src={iconUrl} + className={`icon ${className}`} + onClick={onClick} + title={title} + alt={title || name} + style={{ + width: size, + height: size, + display: "inline-block", + cursor: onClick ? "pointer" : "default", + filter: getFilter(), + transition: "filter 0.2s ease", + }} + /> + ); +}; + +export default Icon;
\ No newline at end of file diff --git a/front/src/components/ProfileEditor.tsx b/front/src/components/ProfileEditor.tsx new file mode 100644 index 0000000..9a7493f --- /dev/null +++ b/front/src/components/ProfileEditor.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect } from "react"; +import type { UserProfile } from "@/types/nostrill"; +import useLocalState from "@/state/state"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import Avatar from "./Avatar"; + +interface ProfileEditorProps { + ship: string; + onSave?: () => void; +} + +const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { + const { api, profiles } = useLocalState((s) => ({ + api: s.api, + 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 [customFields, setCustomFields] = useState< + Array<{ key: string; value: string }> + >( + Object.entries(existingProfile?.other || {}).map(([key, value]) => ({ + key, + value, + })), + ); + 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: "" }]); + }; + + const handleUpdateCustomField = ( + index: number, + field: "key" | "value", + newValue: string, + ) => { + const updated = [...customFields]; + updated[index][field] = newValue; + setCustomFields(updated); + }; + + const handleRemoveCustomField = (index: number) => { + setCustomFields(customFields.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + // Convert custom fields array to object + const other: Record<string, string> = {}; + customFields.forEach(({ key, value }) => { + if (key.trim()) { + other[key.trim()] = value; + } + }); + + const profile: UserProfile = { + name, + picture, + about, + other, + }; + + // Call API to save profile + if (api && typeof api.createProfile === "function") { + await api.createProfile(profile); + } else { + throw new Error("Profile update API not available"); + } + + toast.success("Profile updated successfully"); + setIsEditing(false); + onSave?.(); + } catch (error) { + toast.error("Failed to update profile"); + console.error("Failed to save profile:", error); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + // Reset to original values + 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, + })), + ); + } + 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> + ); + } + + return ( + <div className="profile-editor"> + <div className="profile-header"> + <h2>Edit Profile</h2> + {isOwnProfile && !isEditing && ( + <button onClick={() => setIsEditing(true)} className="edit-btn"> + <Icon name="settings" size={16} /> + Edit + </button> + )} + </div> + + {isEditing ? ( + <div className="profile-form"> + <div className="form-group"> + <label htmlFor="name">Display Name</label> + <input + id="name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Your display name" + /> + </div> + + <div className="form-group"> + <label htmlFor="picture">Profile Picture URL</label> + <input + id="picture" + type="url" + value={picture} + onChange={(e) => setPicture(e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> + <div className="picture-preview"> + <Avatar p={ship} size={54} picOnly={true} /> + </div> + </div> + + <div className="form-group"> + <label htmlFor="about">About</label> + <textarea + id="about" + value={about} + onChange={(e) => setAbout(e.target.value)} + placeholder="Tell us about yourself..." + rows={4} + /> + </div> + + <div className="form-group custom-fields"> + <label>Custom Fields</label> + {customFields.map((field, index) => ( + <div key={index} className="custom-field-row"> + <input + type="text" + value={field.key} + onChange={(e) => + handleUpdateCustomField(index, "key", e.target.value) + } + placeholder="Field name" + className="field-key-input" + /> + <input + type="text" + value={field.value} + onChange={(e) => + handleUpdateCustomField(index, "value", e.target.value) + } + placeholder="Field value" + className="field-value-input" + /> + <button + onClick={() => handleRemoveCustomField(index)} + className="remove-field-btn" + title="Remove field" + > + × + </button> + </div> + ))} + <button onClick={handleAddCustomField} className="add-field-btn"> + + Add Custom Field + </button> + </div> + + <div className="form-actions"> + <button + onClick={handleSave} + disabled={isSaving} + className="save-btn" + > + {isSaving ? "Saving..." : "Save Profile"} + </button> + <button + onClick={handleCancel} + disabled={isSaving} + className="cancel-btn" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="profile-view"> + <div className="profile-picture"> + <Avatar p={ship} size={120} picOnly={true} /> + </div> + + <div className="profile-info"> + <h3>{name || ship}</h3> + {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> + )} + </div> + ); +}; + +export default ProfileEditor; diff --git a/front/src/components/Sigil.tsx b/front/src/components/Sigil.tsx index 4978a72..cbc2e57 100644 --- a/front/src/components/Sigil.tsx +++ b/front/src/components/Sigil.tsx @@ -1,4 +1,4 @@ -import comet from "@/assets/icons/comet.svg"; +import Icon from "@/components/Icon"; import { auraToHex } from "@/logic/utils"; import { isValidPatp } from "urbit-ob"; import { sigil } from "urbit-sigils"; @@ -7,19 +7,19 @@ import { reactRenderer } from "urbit-sigils"; interface SigilProps { patp: string; size: number; - color?: string; + bg?: string; + fg?: string; } const Sigil = (props: SigilProps) => { - const color = props.color ? auraToHex(props.color) : "black"; - if (!isValidPatp(props.patp)) return <div className="sigil bad-sigil">X</div>; - else if (props.patp.length > 28) + const bg = props.bg ? auraToHex(props.bg) : "var(--color-background)"; + const fg = props.fg ? auraToHex(props.fg) : "var(--color-primary)"; + if (props.patp.length > 28) return ( - <img + <Icon + name="comet" + size={props.size} className="comet-icon" - src={comet} - alt="" - style={{ width: `${props.size}px`, height: `${props.size}px` }} /> ); else if (props.patp.length > 15) @@ -41,7 +41,7 @@ const Sigil = (props: SigilProps) => { patp: props.patp, renderer: reactRenderer, size: props.size, - colors: [color, "white"], + colors: [bg, fg], })} </> ); diff --git a/front/src/components/composer/Composer.tsx b/front/src/components/composer/Composer.tsx index 795188e..daa5af6 100644 --- a/front/src/components/composer/Composer.tsx +++ b/front/src/components/composer/Composer.tsx @@ -15,7 +15,10 @@ function Composer({ replying?: Poast; }) { const [loc, navigate] = useLocation(); - const { api, composerData } = useLocalState(); + const { api, composerData } = useLocalState((s) => ({ + api: s.api, + composerData: s.composerData, + })); const our = api!.airlock.our!; const [input, setInput] = useState(replying ? `${replying}: ` : ""); async function poast(e: FormEvent<HTMLFormElement>) { @@ -44,8 +47,8 @@ function Composer({ const placeHolder = isAnon ? "> be me" : "What's going on in Urbit"; return ( <form id="composer" onSubmit={poast}> - <div className="sigil"> - <Sigil patp={our} size={48} /> + <div className="sigil avatar"> + <Sigil patp={our} size={46} /> </div> {composerData && composerData.type === "reply" && ( diff --git a/front/src/components/layout/Sidebar.tsx b/front/src/components/layout/Sidebar.tsx index 4055454..d237fb5 100644 --- a/front/src/components/layout/Sidebar.tsx +++ b/front/src/components/layout/Sidebar.tsx @@ -1,20 +1,13 @@ import { RADIO, versionNum } from "@/logic/constants"; import { useLocation } from "wouter"; import useLocalState from "@/state/state"; -import key from "@/assets/icons/key.svg"; import logo from "@/assets/icons/logo.png"; -import home from "@/assets/icons/home.svg"; -import bell from "@/assets/icons/bell.svg"; -import settings from "@/assets/icons/settings.svg"; -import messages from "@/assets/icons/messages.svg"; -import profile from "@/assets/icons/profile.svg"; -import pals from "@/assets/icons/pals.svg"; -import rumors from "@/assets/icons/rumors.svg"; +import Icon from "@/components/Icon"; import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; function SlidingMenu() { const [_, navigate] = useLocation(); - const { api } = useLocalState(); + const { api } = useLocalState((s) => ({ api: s.api })); function goto(to: string) { navigate(to); } @@ -26,21 +19,25 @@ function SlidingMenu() { </div> <h3>Feeds</h3> <div className="opt" role="link" onClick={() => goto(`/feed/global`)}> - <img src={home} alt="" /> + <Icon name="home" size={20} /> <div>Home</div> </div> <div className="opt" role="link" onClick={() => goto(`/hark`)}> - <img src={bell} alt="" /> + <Icon name="bell" size={20} /> <div>Activity</div> </div> <hr /> - <div className="opt" role="link" onClick={() => goto("/chat")}> - <img src={messages} alt="" /> + <div + className="opt tbd" + role="link" + // onClick={() => goto("/chat")} + > + <Icon name="messages" size={20} /> <div>Messages</div> </div> <div className="opt" role="link" onClick={() => goto("/pals")}> - <img src={pals} alt="" /> + <Icon name="pals" size={20} /> <div>Pals</div> </div> <hr /> @@ -49,29 +46,12 @@ function SlidingMenu() { role="link" onClick={() => goto(`/feed/${api!.airlock.our}`)} > - <img src={profile} alt="" /> + <Icon name="profile" size={20} /> <div>Profile</div> </div> - <div className="opt" role="link" onClick={() => goto("/feed/anon")}> - <img src={rumors} alt="" /> - <div>Rumors</div> - </div> - <hr /> - <div className="opt" role="link" onClick={() => goto("/radio")}> - <div className="img">{RADIO}</div> - <div>Radio</div> - </div> <hr /> - <div - className="opt" - role="link" - onClick={() => (window.location.href = "/cookies")} - > - <img src={key} alt="" /> - <div>Logins</div> - </div> <div className="opt" role="link" onClick={() => goto("/sets")}> - <img src={settings} alt="" /> + <Icon name="settings" size={20} /> <div>Settings</div> </div> <ThemeSwitcher /> diff --git a/front/src/components/modals/Modal.tsx b/front/src/components/modals/Modal.tsx index 7dd688c..e7bae78 100644 --- a/front/src/components/modals/Modal.tsx +++ b/front/src/components/modals/Modal.tsx @@ -2,7 +2,7 @@ import useLocalState from "@/state/state"; import { useEffect, useRef, useState } from "react"; function Modal({ children }: any) { - const { setModal } = useLocalState(); + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); function onKey(event: any) { if (event.key === "Escape") setModal(null); } diff --git a/front/src/components/modals/ShipModal.tsx b/front/src/components/modals/ShipModal.tsx index 86bffbb..e823a3a 100644 --- a/front/src/components/modals/ShipModal.tsx +++ b/front/src/components/modals/ShipModal.tsx @@ -1,13 +1,16 @@ import type { Ship } from "@/types/urbit"; import Modal from "./Modal"; import Avatar from "../Avatar"; -import copyIcon from "@/assets/icons/copy.svg"; +import Icon from "@/components/Icon"; import useLocalState from "@/state/state"; import { useLocation } from "wouter"; import toast from "react-hot-toast"; export default function ({ ship }: { ship: Ship }) { - const { setModal, api } = useLocalState(); + const { setModal, api } = useLocalState((s) => ({ + setModal: s.setModal, + api: s.api, + })); const [_, navigate] = useLocation(); function close() { setModal(null); @@ -22,12 +25,12 @@ export default function ({ ship }: { ship: Ship }) { <div id="ship-modal"> <div className="flex"> <Avatar p={ship} size={60} /> - <img + <Icon + name="copy" + size={20} className="copy-icon cp" - role="link" onClick={copy} - src={copyIcon} - alt="" + title="Copy ship name" /> </div> <div className="buttons f1"> diff --git a/front/src/components/post/Body.tsx b/front/src/components/post/Body.tsx index 2e4e2f8..e8b659c 100644 --- a/front/src/components/post/Body.tsx +++ b/front/src/components/post/Body.tsx @@ -6,7 +6,7 @@ import type { Media as MediaType, ExternalContent, } from "@/types/trill"; -import crow from "@/assets/icons/crow.svg"; +import Icon from "@/components/Icon"; import type { PostProps } from "./Post"; import Media from "./Media"; import JSONContent, { YoutubeSnippet } from "./External"; @@ -168,7 +168,7 @@ function Ref({ r, nest }: { r: Reference; nest: number }) { nest: nest + 1, className: "quote-in-post", })(Quote); - return <Card logo={crow}>{comp}</Card>; + return <Card logo="crow">{comp}</Card>; } return <></>; } diff --git a/front/src/components/post/Card.tsx b/front/src/components/post/Card.tsx index 37f4911..9309423 100644 --- a/front/src/components/post/Card.tsx +++ b/front/src/components/post/Card.tsx @@ -1,8 +1,11 @@ -export default function ({ children, logo, cn}: { cn?: string; logo: string; children: any }) { +import Icon from "@/components/Icon"; +import type { IconName } from "@/components/Icon"; + +export default function ({ children, logo, cn}: { cn?: string; logo: IconName; children: any }) { const className = "trill-post-card" + (cn ? ` ${cn}`: "") return ( <div className={className}> - <img src={logo} alt="" className="trill-post-card-logo" /> + <Icon name={logo} size={20} className="trill-post-card-logo" /> {children} </div> ); diff --git a/front/src/components/post/External.tsx b/front/src/components/post/External.tsx index 0ea1500..d52aec7 100644 --- a/front/src/components/post/External.tsx +++ b/front/src/components/post/External.tsx @@ -1,5 +1,4 @@ import type { ExternalContent } from "@/types/trill"; -import youtube from "@/assets/icons/youtube.svg"; import Card from "./Card"; interface JSONProps { @@ -32,7 +31,7 @@ export function YoutubeSnippet({ href, id }: { href: string; id: string }) { const thumbnail = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; // todo styiling return ( - <Card logo={youtube} cn="youtube-thumbnail"> + <Card logo="youtube" cn="youtube-thumbnail"> <a href={href}> <img src={thumbnail} alt="" /> </a> diff --git a/front/src/components/post/Footer.tsx b/front/src/components/post/Footer.tsx index 3b48241..3e4bbdc 100644 --- a/front/src/components/post/Footer.tsx +++ b/front/src/components/post/Footer.tsx @@ -1,7 +1,5 @@ import type { PostProps } from "./Post"; -import reply from "@/assets/icons/reply.svg"; -import quote from "@/assets/icons/quote.svg"; -import repost from "@/assets/icons/rt.svg"; +import Icon from "@/components/Icon"; import { useState } from "react"; import useLocalState from "@/state/state"; import { useLocation } from "wouter"; @@ -15,7 +13,11 @@ function Footer({ poast, refetch }: PostProps) { const [_showMenu, setShowMenu] = useState(false); const [location, navigate] = useLocation(); const [reposting, _setReposting] = useState(false); - const { api, setComposerData, setModal } = useLocalState(); + const { api, setComposerData, setModal } = useLocalState((s) => ({ + api: s.api, + setComposerData: s.setComposerData, + setModal: s.setModal, + })); const our = api!.airlock.our!; function doReply(e: React.MouseEvent) { e.stopPropagation(); @@ -126,13 +128,13 @@ function Footer({ poast, refetch }: PostProps) { <span role="link" onMouseUp={showReplyCount} className="reply-count"> {displayCount(childrenCount)} </span> - <img role="link" onMouseUp={doReply} src={reply} alt="" /> + <Icon name="reply" size={20} onClick={doReply} /> </div> <div className="icon"> <span role="link" onMouseUp={showQuoteCount} className="quote-count"> {displayCount(poast.engagement.quoted.length)} </span> - <img role="link" onMouseUp={doQuote} src={quote} alt="" /> + <Icon name="quote" size={20} onClick={doQuote} /> </div> <div className="icon"> <span @@ -145,15 +147,15 @@ function Footer({ poast, refetch }: PostProps) { {reposting ? ( <p>...</p> ) : myRP ? ( - <img - role="link" + <Icon + name="repost" + size={20} className="my-rp" - onMouseUp={cancelRP} - src={repost} + onClick={cancelRP} title="cancel repost" /> ) : ( - <img role="link" onMouseUp={sendRP} src={repost} title="repost" /> + <Icon name="repost" size={20} onClick={sendRP} title="repost" /> )} </div> <div className="icon" role="link" onMouseUp={doReact}> diff --git a/front/src/components/post/Header.tsx b/front/src/components/post/Header.tsx index e541fa5..4e72fe8 100644 --- a/front/src/components/post/Header.tsx +++ b/front/src/components/post/Header.tsx @@ -4,7 +4,7 @@ import { useLocation } from "wouter"; import useLocalState from "@/state/state"; function Header(props: PostProps) { const [_, navigate] = useLocation(); - const { profiles } = useLocalState(); + const profiles = useLocalState((s) => s.profiles); const profile = profiles.get(props.poast.author); // console.log("profile", profile); // console.log(props.poast.author.length, "length"); diff --git a/front/src/components/post/Loader.tsx b/front/src/components/post/Loader.tsx index f3c4715..a23bea1 100644 --- a/front/src/components/post/Loader.tsx +++ b/front/src/components/post/Loader.tsx @@ -14,7 +14,7 @@ function PostData(props: { nest?: number; // nested quotes className?: string; }) { - const { api } = useLocalState(); + const { api } = useLocalState((s) => ({ api: s.api })); const { host, id, nest } = props; const [enest, setEnest] = useState(nest); useEffect(() => { diff --git a/front/src/components/post/Post.tsx b/front/src/components/post/Post.tsx index e61efb0..277c119 100644 --- a/front/src/components/post/Post.tsx +++ b/front/src/components/post/Post.tsx @@ -47,7 +47,7 @@ export default Post; function TrillPost(props: PostProps) { const { poast, profile, fake } = props; - const { setModal } = useLocalState(); + const setModal = useLocalState((s) => s.setModal); const [_, navigate] = useLocation(); function openThread(_e: React.MouseEvent) { const sel = window.getSelection()?.toString(); @@ -64,7 +64,7 @@ function TrillPost(props: PostProps) { </div> ) : ( <div className="avatar sigil cp" role="link" onMouseUp={openModal}> - <Sigil patp={poast.author} size={42} /> + <Sigil patp={poast.author} size={46} /> </div> ); return ( diff --git a/front/src/components/post/Reactions.tsx b/front/src/components/post/Reactions.tsx index 58662cd..aabab61 100644 --- a/front/src/components/post/Reactions.tsx +++ b/front/src/components/post/Reactions.tsx @@ -14,7 +14,7 @@ import soy from "@/assets/reacts/soy.png"; import chad from "@/assets/reacts/chad.png"; import pika from "@/assets/reacts/pika.png"; import facepalm from "@/assets/reacts/facepalm.png"; -import emoji from "@/assets/icons/emoji.svg"; +import Icon from "@/components/Icon"; import emojis from "@/logic/emojis.json"; import Modal from "../modals/Modal"; import useLocalState from "@/state/state"; @@ -93,7 +93,7 @@ export function stringToReact(s: string) { if (s === "pepesad") return <img className="react-img" src={pepesad} alt="" />; if (s === "") - return <img className="react-img no-react" src={emoji} alt="" />; + return <Icon name="emoji" size={20} className="react-img no-react" />; if (s === "cringe") return <img className="react-img" src={cringe} alt="" />; if (s === "cry") return <img className="react-img" src={cry} alt="" />; if (s === "crywojak") return <img className="react-img" src={cry} alt="" />; diff --git a/front/src/components/post/wrappers/Nostr.tsx b/front/src/components/post/wrappers/Nostr.tsx index bdc5ba9..2782fb8 100644 --- a/front/src/components/post/wrappers/Nostr.tsx +++ b/front/src/components/post/wrappers/Nostr.tsx @@ -4,7 +4,7 @@ import useLocalState from "@/state/state"; export default NostrPost; function NostrPost({ data }: { data: NostrPost }) { - const { profiles } = useLocalState(); + const { profiles } = useLocalState((s) => ({ profiles: s.profiles })); const profile = profiles.get(data.event.pubkey); return <Post poast={data.post} profile={profile} />; diff --git a/front/src/components/post/wrappers/NostrIcon.tsx b/front/src/components/post/wrappers/NostrIcon.tsx index 0c368fb..30fbfe9 100644 --- a/front/src/components/post/wrappers/NostrIcon.tsx +++ b/front/src/components/post/wrappers/NostrIcon.tsx @@ -1,9 +1,12 @@ -import nostrIcon from "@/assets/icons/nostr.svg"; +import Icon from "@/components/Icon"; import useLocalState from "@/state/state"; import toast from "react-hot-toast"; import type { Poast } from "@/types/trill"; export default function ({ poast }: { poast: Poast }) { - const { relays, api, keys } = useLocalState(); + const { relays, api } = useLocalState((s) => ({ + relays: s.relays, + api: s.api, + })); async function sendToRelay(e: React.MouseEvent) { e.stopPropagation(); @@ -16,7 +19,7 @@ export default function ({ poast }: { poast: Poast }) { return ( <div className="icon" role="link" onMouseUp={sendToRelay}> - <img role="link" src={nostrIcon} title="repost" /> + <Icon name="nostr" size={20} title="relay to nostr" /> </div> ); } diff --git a/front/src/logic/api.ts b/front/src/logic/api.ts index 52635e5..cf44073 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:8080"; +export const URL = import.meta.env.PROD ? "" : "http://localhost:8083"; export async function start(): Promise<Urbit> { const airlock = new Urbit(URL, ""); diff --git a/front/src/logic/requests/nostrill.ts b/front/src/logic/requests/nostrill.ts index 6334c34..74fcb87 100644 --- a/front/src/logic/requests/nostrill.ts +++ b/front/src/logic/requests/nostrill.ts @@ -1,16 +1,26 @@ import type Urbit from "urbit-api"; -import type { Cursor, PostID } from "@/types/trill"; +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 { AsyncRes } from "@/types/ui"; // Subscribe type Handler = (date: any) => void; export default class IO { airlock; + subs: Map<string, number> = new Map(); constructor(airlock: Urbit) { this.airlock = airlock; } + private async thread(threadName: string, json: any) { + return this.airlock.thread({ + body: json, + inputMark: "json", + outputMark: "json", + threadName, + }); + } private async poke(json: any) { return this.airlock.poke({ app: "nostrill", mark: "json", json }); } @@ -18,10 +28,15 @@ export default class IO { return this.airlock.scry({ app: "nostrill", path }); } private async sub(path: string, handler: Handler) { + const has = this.subs.get(path); + if (has) return; + const err = (err: any, _id: string) => console.log(err, "error on nostrill subscription"); - const quit = (data: any) => + const quit = (data: any) => { console.log(data, "nostrill subscription kicked"); + this.subs.delete(path); + }; const res = await this.airlock.subscribe({ app: "nostrill", path, @@ -29,6 +44,7 @@ export default class IO { err, quit, }); + this.subs.set(path, res); console.log(res, "subscribed to nostrill agent"); } async unsub(sub: number) { @@ -115,23 +131,50 @@ export default class IO { return await this.poke({ fols: json }); } // profiles - async createProfile(pubkey: string, profile: UserProfile) { - const json = { add: { pubkey, profile } }; + async createProfile(profile: UserProfile) { + const json = { add: profile }; return await this.poke({ prof: json }); } - async createKey() { - const json = { add: null }; - return await this.poke({ keys: json }); + async deleteProfile() { + const json = { del: null }; + return await this.poke({ prof: json }); } - async removeKey(pubkey: string) { - const json = { del: pubkey }; - return await this.poke({ keys: json }); + async cycleKeys() { + return await this.poke({ keys: null }); } // relaying + async addRelay(url: string) { + const json = { add: url }; + return await this.poke({ rela: json }); + } + async deleteRelay(url: string) { + const json = { del: url }; + return await this.poke({ rela: json }); + } + async syncRelays() { + // TODO make it choosable? + const json = { sync: null }; + return await this.poke({ rela: json }); + } async relayPost(host: string, id: string, relays: string[]) { const json = { send: { host, id, relays } }; return await this.poke({ rela: json }); } + // threads + // + async peekFeed(host: string): AsyncRes<FC> { + try { + const json = { begs: { feed: host } }; + const res: any = await this.thread("beg", json); + console.log("peeking feed", res); + 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 }; + } catch (e) { + return { error: `${e}` }; + } + } } // notifications diff --git a/front/src/main.tsx b/front/src/main.tsx index 5d4a2be..9200210 100644 --- a/front/src/main.tsx +++ b/front/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import App from "./App.tsx"; createRoot(document.getElementById("root")!).render( - <StrictMode> - <App /> - </StrictMode>, + // <StrictMode> + <App />, + // </StrictMode>, ); diff --git a/front/src/pages/Feed.tsx b/front/src/pages/Feed.tsx index 65dee64..5902162 100644 --- a/front/src/pages/Feed.tsx +++ b/front/src/pages/Feed.tsx @@ -8,6 +8,8 @@ import { useParams } from "wouter"; import spinner from "@/assets/triangles.svg"; 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"; @@ -88,11 +90,89 @@ function Global() { return <p>Error</p>; } function Nostr() { - const { nostrFeed } = useLocalState(); + const { nostrFeed, api } = useLocalState((s) => ({ + nostrFeed: s.nostrFeed, + api: s.api, + })); + const [isSyncing, setIsSyncing] = useState(false); const feed = eventsToFc(nostrFeed); console.log({ feed }); const refetch = () => feed; - return <PostList data={feed} refetch={refetch} />; + + const handleResync = async () => { + if (!api) return; + + setIsSyncing(true); + try { + await api.syncRelays(); + toast.success("Nostr feed sync initiated"); + } catch (error) { + toast.error("Failed to sync Nostr feed"); + console.error("Sync error:", error); + } finally { + setIsSyncing(false); + } + }; + + // Show empty state with resync option when no feed data + if (!feed || !feed.feed || Object.keys(feed.feed).length === 0) { + return ( + <div className="nostr-empty-state"> + <div className="empty-content"> + <Icon name="nostr" size={48} color="textMuted" /> + <h3>No Nostr Posts</h3> + <p> + Your Nostr feed appears to be empty. Try syncing with your relays to + fetch the latest posts. + </p> + <button + onClick={handleResync} + disabled={isSyncing} + className="resync-btn" + > + {isSyncing ? ( + <> + <img src={spinner} alt="Loading" className="btn-spinner" /> + Syncing... + </> + ) : ( + <> + <Icon name="settings" size={16} /> + Sync Relays + </> + )} + </button> + </div> + </div> + ); + } + + // Show feed with resync button in header + return ( + <div className="nostr-feed"> + <div className="nostr-header"> + <div className="feed-info"> + <h4>Nostr Feed</h4> + <span className="post-count"> + {Object.keys(feed.feed).length} posts + </span> + </div> + <button + onClick={handleResync} + disabled={isSyncing} + className="resync-btn-small" + title="Sync with Nostr relays" + > + {isSyncing ? ( + <img src={spinner} alt="Loading" className="btn-spinner-small" /> + ) : ( + <Icon name="settings" size={16} /> + )} + </button> + </div> + <PostList data={feed} refetch={refetch} /> + </div> + ); } export default Loader; diff --git a/front/src/pages/Settings.tsx b/front/src/pages/Settings.tsx index e0f1da9..6b6f7bd 100644 --- a/front/src/pages/Settings.tsx +++ b/front/src/pages/Settings.tsx @@ -1,89 +1,206 @@ import useLocalState from "@/state/state"; -import type { UserProfile } from "@/types/nostril"; import { useState } from "react"; +import toast from "react-hot-toast"; +import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; +import Icon from "@/components/Icon"; +import "@/styles/Settings.css"; function Settings() { - const { UISettings, keys, profiles, relays, api } = useLocalState(); + const { key, relays, api } = useLocalState((s) => ({ + key: s.key, + relays: s.relays, + api: s.api, + })); const [newRelay, setNewRelay] = useState(""); - async function saveSetting( - bucket: string, - key: string, - value: string | boolean | number | string[], - ) { - const json = { - "put-entry": { - desk: "trill", - "bucket-key": bucket, - "entry-key": key, - value, - }, - }; - // const res = await poke("settings", "settings-event", json); - // if (res) refetchSettings(); - } + const [isAddingRelay, setIsAddingRelay] = useState(false); + const [isCyclingKey, setIsCyclingKey] = useState(false); + async function removeRelay(url: string) { - console.log({ url }); + try { + await api?.deleteRelay(url); + toast.success("Relay removed"); + } catch (error) { + toast.error("Failed to remove relay"); + console.error("Remove relay error:", error); + } } + async function addNewRelay() { - // - // await addnr(newRelay); - } - async function removeProfile(pubkey: string) { - api!.removeKey(pubkey); + if (!newRelay.trim()) { + toast.error("Please enter a relay URL"); + return; + } + + setIsAddingRelay(true); + try { + const valid = ["wss:", "ws:"]; + const url = new URL(newRelay); + if (!valid.includes(url.protocol)) { + toast.error("Invalid Relay URL - must use wss:// or ws://"); + return; + } + + await api?.addRelay(newRelay); + toast.success("Relay added"); + setNewRelay(""); + } catch (error) { + toast.error("Invalid relay URL or failed to add relay"); + console.error("Add relay error:", error); + } finally { + setIsAddingRelay(false); + } } - async function createProfile() { - // - api!.createKey(); + + async function cycleKey() { + setIsCyclingKey(true); + try { + await api?.cycleKeys(); + toast.success("Key cycled successfully"); + } catch (error) { + toast.error("Failed to cycle key"); + console.error("Cycle key error:", error); + } finally { + setIsCyclingKey(false); + } } + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + addNewRelay(); + } + }; + return ( - <div id="settings"> - <h1>Settings</h1> - <div className="setting"> - <label>Pubkeys</label> - {keys.map((k) => { - const profile = profiles.get(k); - const profileDiv = !profile ? ( - <div className="profile"> - <div>Pubkey: {k}</div> - <p>No profile set</p>) - </div> - ) : ( - <div className="profile"> - {profile.picture && <img src={profile.picture} />} - <div>Name: {profile.name}</div> - <div>Pubkey: {k}</div> - <div>About: {profile.about}</div> - <button onClick={() => removeProfile(k)}>x</button> + <div className="settings-page"> + <div className="settings-header"> + <h1>Settings</h1> + <p>Manage your Nostrill configuration and preferences</p> + </div> + + <div className="settings-content"> + {/* Appearance Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="settings" size={20} /> + <h2>Appearance</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Theme</label> + <p>Choose your preferred color theme</p> + </div> + <div className="setting-control"> + <ThemeSwitcher /> + </div> </div> - ); - return ( - <div className="options flex" key={k}> - {profileDiv} + </div> + </div> + + {/* Identity Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="key" size={20} /> + <h2>Identity</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Nostr Public Key</label> + <p>Your unique identifier on the Nostr network</p> + </div> + <div className="setting-control"> + <div className="key-display"> + <code className="pubkey">{key || "No key generated"}</code> + <button + onClick={cycleKey} + disabled={isCyclingKey} + className="cycle-btn" + title="Generate new key pair" + > + {isCyclingKey ? ( + <Icon name="settings" size={16} /> + ) : ( + <Icon name="settings" size={16} /> + )} + {isCyclingKey ? "Cycling..." : "Cycle Key"} + </button> + </div> + </div> </div> - ); - })} - <div className="options flex"> - <button onClick={createProfile}>Create New</button> + </div> </div> - </div> - <div className="setting"> - <label>Nostr Relays</label> - {Object.keys(relays).map((r) => ( - // TODO: add connect button to connect and disc to relay one by one - <div className="options flex" key={r}> - <div>{r}</div> - <button onClick={() => removeRelay(r)}>x</button> + + {/* Nostr Relays Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="nostr" size={20} /> + <h2>Nostr Relays</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Connected Relays</label> + <p>Manage your Nostr relay connections</p> + </div> + <div className="setting-control"> + <div className="relay-list"> + {Object.keys(relays).length === 0 ? ( + <div className="no-relays"> + <Icon name="nostr" size={24} color="textMuted" /> + <p>No relays configured</p> + </div> + ) : ( + Object.keys(relays).map((url) => ( + <div key={url} className="relay-item"> + <div className="relay-info"> + <span className="relay-url">{url}</span> + <span className="relay-status">Connected</span> + </div> + <button + onClick={() => removeRelay(url)} + className="remove-relay-btn" + title="Remove relay" + > + × + </button> + </div> + )) + )} + + <div className="add-relay-form"> + <div className="relay-input-group"> + <input + type="text" + value={newRelay} + onChange={(e) => setNewRelay(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="wss://relay.example.com" + className="relay-input" + /> + <button + onClick={addNewRelay} + disabled={isAddingRelay || !newRelay.trim()} + className="add-relay-btn" + > + {isAddingRelay ? ( + <> + <Icon name="settings" size={16} /> + Adding... + </> + ) : ( + <> + <Icon name="settings" size={16} /> + Add Relay + </> + )} + </button> + </div> + </div> + </div> + </div> + </div> </div> - ))} - <div className="options flex"> - <label>Add new</label> - <input - type="text" - value={newRelay} - onChange={(e) => setNewRelay(e.target.value)} - /> - <button onClick={addNewRelay}>Add</button> </div> </div> </div> diff --git a/front/src/pages/User.tsx b/front/src/pages/User.tsx index a1e26f1..e209bb3 100644 --- a/front/src/pages/User.tsx +++ b/front/src/pages/User.tsx @@ -1,20 +1,138 @@ // import spinner from "@/assets/icons/spinner.svg"; import Composer from "@/components/composer/Composer"; import PostList from "@/components/feed/PostList"; -import useLocalState from "@/state/state"; +import ProfileEditor from "@/components/ProfileEditor"; +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"; function UserFeed({ p }: { p: Ship }) { - const { api, following } = useLocalState(); - const feed = following.get(api!.airlock.our!); + const { api } = useLocalState((s) => ({ + api: s.api, + })); + // auto updating on SSE doesn't work if we do shallow + const { following } = useStore(); + const feed = following.get(p); const refetch = () => feed; - if (p === api!.airlock.our) - return ( - <div id="feed-proper"> - <Composer /> - <PostList data={feed!} refetch={refetch} /> - </div> - ); + const isOwnProfile = p === api?.airlock.our; + const isFollowing = following.has(p); + + const [isFollowLoading, setIsFollowLoading] = useState(false); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [fc, setFC] = useState<FC>(); + + const handleFollow = async () => { + if (!api) return; + + setIsFollowLoading(true); + try { + if (isFollowing) { + await api.unfollow(p); + toast.success(`Unfollowed ${p}`); + } else { + await api.follow(p); + toast.success(`Now following ${p}`); + } + } catch (error) { + toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} ${p}`); + console.error("Follow error:", error); + } finally { + setIsFollowLoading(false); + } + }; + + const handleRequestAccess = async () => { + if (!api) return; + + setIsAccessLoading(true); + try { + const res = await api.peekFeed(p); + toast.success(`Access request sent to ${p}`); + if ("error" in res) toast.error(res.error); + else setFC(res.ok); + } catch (error) { + toast.error(`Failed to request access from ${p}`); + console.error("Access request error:", error); + } finally { + setIsAccessLoading(false); + } + }; + + return ( + <div id="user-page"> + <ProfileEditor ship={p} /> + + {!isOwnProfile && ( + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${isFollowing ? "following" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {isFollowing ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={isFollowing ? "bell" : "pals"} size={16} /> + {isFollowing ? "Unfollow" : "Follow"} + </> + )} + </button> + + <button + onClick={handleRequestAccess} + disabled={isAccessLoading} + className="action-btn access" + > + {isAccessLoading ? ( + <> + <Icon name="settings" size={16} /> + Requesting... + </> + ) : ( + <> + <Icon name="key" size={16} /> + Request Access + </> + )} + </button> + </div> + )} + + {feed ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={feed} refetch={refetch} /> + </div> + ) : fc ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={fc} refetch={refetch} /> + </div> + ) : null} + + {!isOwnProfile && !feed && !fc && ( + <div id="other-user-feed"> + <div className="empty-feed-message"> + <Icon name="messages" size={48} color="textMuted" /> + <h3>No Posts Available</h3> + <p> + This user's posts are not publicly visible. + {!isFollowing && " Try following them"} or request temporary + access to see their content. + </p> + </div> + </div> + )} + </div> + ); } export default UserFeed; diff --git a/front/src/state/state.ts b/front/src/state/state.ts index 01b8ea1..2e747ea 100644 --- a/front/src/state/state.ts +++ b/front/src/state/state.ts @@ -6,6 +6,7 @@ import { create } from "zustand"; import type { UserProfile } from "@/types/nostrill"; import type { Event } from "@/types/nostr"; import type { FC, Poast } from "@/types/trill"; +import { useShallow } from "zustand/shallow"; // TODO handle airlock connection issues // the SSE pipeline has a "status-update" event FWIW // type AirlockState = "connecting" | "connected" | "failed"; @@ -27,7 +28,7 @@ export type LocalState = { }; const creator = create<LocalState>(); -const useLocalState = creator((set, get) => ({ +export const useStore = creator((set, get) => ({ isNew: false, api: null, init: async () => { @@ -78,4 +79,8 @@ const useLocalState = creator((set, get) => ({ setComposerData: (composerData) => set({ composerData }), })); -export default useLocalState; +const useShallowStore = <T extends (state: LocalState) => any>( + selector: T, +): ReturnType<T> => useStore(useShallow(selector)); + +export default useShallowStore; diff --git a/front/src/styles/ProfileEditor.css b/front/src/styles/ProfileEditor.css new file mode 100644 index 0000000..c1b65e5 --- /dev/null +++ b/front/src/styles/ProfileEditor.css @@ -0,0 +1,325 @@ +.profile-editor { + align-items: center; + padding: 20px; + background: var(--color-surface); + border-radius: 8px; + margin-bottom: 20px; +} + +.profile-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.profile-header h2 { + margin: 0; + color: var(--color-text); +} + +.edit-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.2s; +} + +.edit-btn:hover { + opacity: 0.9; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 500; + color: var(--color-text); +} + +.form-group input, +.form-group textarea { + padding: 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background); + color: var(--color-text); + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.picture-preview { + width: 100px; + height: 100px; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--color-border); + margin-top: 10px; +} + +.picture-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.custom-fields { + display: flex; + flex-direction: column; + gap: 10px; +} + +.custom-field-row { + display: flex; + gap: 10px; + align-items: center; +} + +.field-key-input, +.field-value-input { + flex: 1; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background); + color: var(--color-text); +} + +.remove-field-btn { + padding: 4px 8px; + background: var(--color-error); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; + font-size: 16px; + font-weight: bold; + min-width: 28px; + height: 28px; +} + +.remove-field-btn:hover { + opacity: 0.8; +} + +.add-field-btn { + padding: 10px; + background: transparent; + color: var(--color-primary); + border: 1px dashed var(--color-primary); + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.add-field-btn:hover { + background: var(--color-surface); +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.save-btn, +.cancel-btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: opacity 0.2s; +} + +.save-btn { + background: var(--color-primary); + color: white; +} + +.cancel-btn { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.save-btn:disabled, +.cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.save-btn:hover:not(:disabled), +.cancel-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.profile-view, +.view-mode { + display: flex; + gap: 20px; +} + +.profile-picture { + width: 120px; + height: 120px; + border-radius: 50%; + overflow: hidden; + border: 3px solid var(--color-border); + flex-shrink: 0; +} + +.profile-picture img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-info { + flex: 1; +} + +.profile-info h3 { + margin: 0 0 10px 0; + color: var(--color-text); +} + +.profile-about { + color: var(--color-text-secondary); + line-height: 1.5; + margin-bottom: 20px; +} + +.profile-custom-fields { + margin-top: 20px; +} + +.profile-custom-fields h4 { + margin: 0 0 10px 0; + color: var(--color-text); +} + +.custom-field-view { + display: flex; + gap: 10px; + margin-bottom: 8px; +} + +.field-key { + font-weight: 500; + color: var(--color-text); +} + +.field-value { + color: var(--color-text-secondary); +} + +/* User Actions */ +.user-actions { + display: flex; + gap: 12px; + margin-bottom: 20px; + padding: 16px; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border: 1px solid; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + background: transparent; +} + +.action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-btn.follow { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.action-btn.follow:hover:not(:disabled) { + background: var(--color-primary); + color: white; +} + +.action-btn.following { + border-color: var(--color-success); + color: var(--color-success); + background: var(--color-success); + color: white; +} + +.action-btn.following:hover:not(:disabled) { + background: var(--color-error); + border-color: var(--color-error); +} + +.action-btn.access { + border-color: var(--color-secondary); + color: var(--color-secondary); +} + +.action-btn.access:hover:not(:disabled) { + background: var(--color-secondary); + color: white; +} + +/* Empty feed message */ +.empty-feed-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 60px 20px; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.empty-feed-message h3 { + margin: 20px 0 10px 0; + color: var(--color-text); + font-size: 20px; +} + +.empty-feed-message p { + color: var(--color-text-secondary); + line-height: 1.5; + max-width: 400px; +}
\ No newline at end of file diff --git a/front/src/styles/Settings.css b/front/src/styles/Settings.css new file mode 100644 index 0000000..bb1f46e --- /dev/null +++ b/front/src/styles/Settings.css @@ -0,0 +1,339 @@ +.settings-page { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.settings-header { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--color-border); +} + +.settings-header h1 { + margin: 0 0 8px 0; + color: var(--color-text); + font-size: 32px; + font-weight: 600; +} + +.settings-header p { + margin: 0; + color: var(--color-text-secondary); + font-size: 16px; +} + +.settings-content { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Settings Sections */ +.settings-section { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px; + background: var(--color-background); + border-bottom: 1px solid var(--color-border); +} + +.section-header h2 { + margin: 0; + color: var(--color-text); + font-size: 20px; + font-weight: 600; +} + +.section-content { + padding: 0; +} + +/* Setting Items */ +.setting-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 24px; + gap: 20px; +} + +.setting-item:not(:last-child) { + border-bottom: 1px solid var(--color-border-light); +} + +.setting-info { + flex: 1; + min-width: 0; +} + +.setting-info label { + display: block; + margin-bottom: 4px; + color: var(--color-text); + font-size: 16px; + font-weight: 500; +} + +.setting-info p { + margin: 0; + color: var(--color-text-secondary); + font-size: 14px; + line-height: 1.4; +} + +.setting-control { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; +} + +/* Key Display */ +.key-display { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: 400px; +} + +.pubkey { + flex: 1; + padding: 10px 12px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + word-break: break-all; + line-height: 1.3; + min-width: 0; +} + +.cycle-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: opacity 0.2s; + white-space: nowrap; +} + +.cycle-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.cycle-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Relay Management */ +.relay-list { + width: 100%; + max-width: 500px; +} + +.no-relays { + display: flex; + flex-direction: column; + align-items: center; + padding: 30px 20px; + text-align: center; + color: var(--color-text-muted); +} + +.no-relays p { + margin: 12px 0 0 0; + color: var(--color-text-muted); +} + +.relay-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + margin-bottom: 8px; + transition: border-color 0.2s; +} + +.relay-item:hover { + border-color: var(--color-primary); +} + +.relay-info { + flex: 1; + min-width: 0; +} + +.relay-url { + display: block; + color: var(--color-text); + font-size: 14px; + font-weight: 500; + word-break: break-all; + margin-bottom: 2px; +} + +.relay-status { + display: inline-block; + color: var(--color-success); + font-size: 12px; + padding: 2px 6px; + background: var(--color-surface); + border-radius: 3px; + border: 1px solid var(--color-success); +} + +.remove-relay-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--color-error); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.remove-relay-btn:hover { + opacity: 0.8; +} + +/* Add Relay Form */ +.add-relay-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--color-border-light); +} + +.relay-input-group { + display: flex; + gap: 8px; + width: 100%; +} + +.relay-input { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + color: var(--color-text); + font-size: 14px; +} + +.relay-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.relay-input::placeholder { + color: var(--color-text-muted); +} + +.add-relay-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: opacity 0.2s; + white-space: nowrap; +} + +.add-relay-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.add-relay-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .settings-page { + padding: 16px; + } + + .setting-item { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .setting-control { + width: 100%; + justify-content: stretch; + } + + .key-display { + max-width: none; + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .pubkey { + text-align: center; + } + + .relay-input-group { + flex-direction: column; + } + + .section-header { + padding: 16px 20px; + } + + .setting-item { + padding: 20px; + } +} + +@media (max-width: 480px) { + .settings-header h1 { + font-size: 28px; + } + + .section-header h2 { + font-size: 18px; + } + + .settings-page { + padding: 12px; + } +}
\ No newline at end of file diff --git a/front/src/styles/ThemeSwitcher.css b/front/src/styles/ThemeSwitcher.css index 518a00d..6b48545 100644 --- a/front/src/styles/ThemeSwitcher.css +++ b/front/src/styles/ThemeSwitcher.css @@ -153,6 +153,7 @@ position: absolute; top: calc(100% + var(--spacing-xs)); right: 0; + left: 0; min-width: 180px; background-color: var(--color-background); border: 1px solid var(--color-border); @@ -168,6 +169,7 @@ opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); @@ -231,6 +233,7 @@ /* Reduced motion */ @media (prefers-reduced-motion: reduce) { + .theme-switcher-compact, .theme-button, .theme-dropdown-toggle, @@ -238,11 +241,11 @@ .dropdown-arrow { transition: none; } - + .theme-dropdown-menu { animation: none; } - + .theme-switcher-compact:hover { transform: none; } diff --git a/front/src/styles/feed.css b/front/src/styles/feed.css index 417f94b..05f0bb2 100644 --- a/front/src/styles/feed.css +++ b/front/src/styles/feed.css @@ -1,4 +1,139 @@ +.avatar { + border: 1px solid var(--color-text); +} + .avatar, .avatar img { - width: 64px; + width: 48px; + height: 48px; +} + +/* Nostr Feed Styles */ +.nostr-empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + padding: 40px 20px; +} + +.empty-content { + text-align: center; + max-width: 400px; +} + +.empty-content h3 { + margin: 20px 0 10px 0; + color: var(--color-text); + font-size: 24px; +} + +.empty-content p { + color: var(--color-text-secondary); + line-height: 1.5; + margin-bottom: 30px; +} + +.resync-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: 500; + transition: opacity 0.2s ease; +} + +.resync-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.resync-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.nostr-feed { + width: 100%; +} + +.nostr-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--color-surface); + border-radius: 8px; + margin-bottom: 16px; + border: 1px solid var(--color-border); +} + +.feed-info { + display: flex; + align-items: center; + gap: 12px; +} + +.feed-info h4 { + margin: 0; + color: var(--color-text); + font-size: 18px; +} + +.post-count { + color: var(--color-text-secondary); + font-size: 14px; + background: var(--color-background); + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--color-border); +} + +.resync-btn-small { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--color-text); +} + +.resync-btn-small:hover:not(:disabled) { + background: var(--color-surface-hover); + border-color: var(--color-primary); +} + +.resync-btn-small:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-spinner, +.btn-spinner-small { + width: 16px; + height: 16px; + animation: spin 1s linear infinite; +} + +.btn-spinner-small { + width: 14px; + height: 14px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } }
\ No newline at end of file diff --git a/front/src/styles/styles.css b/front/src/styles/styles.css index c2b05d6..ede283d 100644 --- a/front/src/styles/styles.css +++ b/front/src/styles/styles.css @@ -211,11 +211,16 @@ h6 { gap: 1rem; margin: 1rem 0; + & img { width: 24px; height: 24px; } } + + .opt.tbd { + opacity: 0.4; + } } & main { diff --git a/front/src/types/ui.ts b/front/src/types/ui.ts index c0c61a1..4596236 100644 --- a/front/src/types/ui.ts +++ b/front/src/types/ui.ts @@ -1,6 +1,9 @@ import type { NostrMetadata } from "./nostrill"; import type { Poast } from "./trill"; import type { Tweet } from "./twatter"; +import type { Ship } from "./urbit"; +export type Result<T> = { ok: T } | { error: string }; +export type AsyncRes<T> = Promise<Result<T>>; export type Timestamp = number; export type UrbitTime = string; diff --git a/shim/ws-shim/src/server.ts b/shim/ws-shim/src/server.ts index f375fc7..4632704 100644 --- a/shim/ws-shim/src/server.ts +++ b/shim/ws-shim/src/server.ts @@ -1,41 +1,10 @@ -import EventEmitter from "events"; import Urbit from "urbit-http"; -const SHIP_URL = "http://localhost:8080"; -const api = new Urbit(SHIP_URL, ""); -let sub: number; -// const emitter = new EventEmitter(); - -// //github.com/oven-sh/bun/issues/13811 -// function sse(req: Request, channel: string): Response { -// const stream = new ReadableStream({ -// type: "direct", -// pull(controller: ReadableStreamDirectController) { -// let id = +(req.headers.get("last-event-id") ?? 1); -// const handler = async (event: string, data: unknown): Promise<void> => { -// await controller.write(`id:${id}\n`); -// await controller.write(`event:${event}\n`); -// if (data) await controller.write(`data:${JSON.stringify(data)}\n\n`); -// await controller.flush(); -// id++; -// emitter.on(channel, handler); -// if (req.signal.aborted) { -// emitter.off(channel, handler); -// controller.close(); -// } -// }; -// return new Promise(() => void 0); -// }, -// }); -// return new Response(stream, { -// status: 200, -// headers: { "Content-Type": "text/event-stream" }, -// }); -// } +let SHIP_URL: URL; function emit(res: ShimResponse): void { // emitter.emit(channel, event, data); const body = JSON.stringify({ ws: res }); - fetch(SHIP_URL + "/nostr-shim", { + fetch(SHIP_URL! + "/nostr-shim", { method: "PUT", headers: { "Content-type": "application/json" }, body, @@ -48,6 +17,13 @@ const server = Bun.serve({ //http routes: { "/shim": async (req: Request) => { + console.log("req", req); + const uri = req.headers.get("origin"); + console.log({ uri }); + const url = URL.parse(uri!)!; + url.pathname = ""; + SHIP_URL = url; + const data = (await req.json()) as ShimRequest; console.log({ data }); if ("ws" in data) { diff --git a/shim/ws-shim/src/test.ts b/shim/ws-shim/src/test.ts index e0f7784..bd76cf5 100644 --- a/shim/ws-shim/src/test.ts +++ b/shim/ws-shim/src/test.ts @@ -5,143 +5,9 @@ const ids = [ "db2815575eddeed24f122de70023c861ec63d0473c40b899d8883194edf122b3", "1ada56a84ffc94a3cd8ad68432c3cc0ed3e66ac8633226e92cc1a1686efa15a8", "1a4e12c3c8d2c560c068ee71f51afb84eefda4ec2593ab160312265d626aaa72", - "f0907001d0349d8216c54106d7d3ae400466da204f08a29a69bd0a3d3d1f5cff", - "6a9265f37d944d64d3db918b3b5f06cd2151b428412e1d15672f4cfe16cb17ac", - "9844d8b23a5a5e51b922b25dec8e574a40a68e964fb116e1c283f045ddeb32c", - "fd0724d5238656a3a760c2b56f2eb9d9da7217936d7c51c865584e354485f664", - "1389f670203855c01f575c49f0830aabd7ed01c0fe7cab8f6ee9184393f1f067", - "ac668dc63d8aa38770ef79c2d5a98300d40e9e6daf91f3cd26b942587c70c790", - "f032c4871f82213419dc834db7c469813b1bc75b0f9256a47de90a0f7d28fac5", - "316141d2349969fe1b1d708c4045c455d2608950d7d6d32fb7f99ca622138c34", - "5e9c557df1e9e184e31ce1a8507497dbc79714aedaf4bb386981f8be4eb1dcb5", - "784e0a3af7b11279c612a94726db0056860aabb4e53c614e80fd7e02d409150f", - "4fcc1a0d933afaea7f4fe094cab42b433d0ae0748bcb55e685d5ac3da4191197", - "cf58063ffba4a0f25db862faa9f88e07177fb87c2e4c56b23303b561f7a34376", - "cd750bb5f99a89181f59234c1e5824da72dce4a0fbc87d0f29ef3e62e1de0040", - "ffcc0ed56c747a858224f3d2c122b1430a1df8648dba57544d5a50d976b504bb", - "d08be10b0338b3ab9dc49a31c8d5482289438f4dfbdb9dd844bc1b4990f1536f", - "576cbbddd18301191cca632b464092412362afbf31853d987cd461864b1328ec", - "a0ec2197b96c0e34ba04b89ee37d8fa2202bc02e77ea6a27322e7388afbc470e", - "4d4de8378e1f7238a5b00cbac70547e49a12db82121cb949459478124a799f03", - "d1ab588433fc684540d55e311f1ea4790dfcd793a66b83c5e05e34e319dd421d", - "564811aab801279ae7a473afe37557cfba61816480a5791079a1ca3bb166b038", - "fa5b72846f95271fc83966194f4e7bb63cec91d8412ea00a64f1d8ca304f9d56", - "b4e9c5d587ce36e87105e257eb9042070e415511ef58e29d40fc15e9de3a310d", - "c6c64bd7034cabb1f4d9efd47e76c7d18c631a033442b22d46c3a96614398f10", - "d0fcb5fed102c8779c23d57b4e159402ccc6297f5f8076f72865aa782713f05f", - "20d2505d8a74f0aaad2c1a7766897e717fcc016836cf0469ac54e5ea5458e0c2", - "f345c73a5e44837f88935151d9e34d0340b7a7cd40649f8d446a4445ef99ee7d", - "3b7ef95b5ed5b676b98cef84ab6e4ec9690068b8573a4f524fc6e07a4a88346a", - "735743bd4b332c8786b4592de2fa8fc2155f995e51a8977c8a784c5361d62bc0", - "816bd14613d9c01260a278db8b209b7031a9f91592daee205a74c8f8e475c3d", - "ac13a127f7c140ce29da2547f5262a311f4a6ceef09e1a9cd7ce291f2e6e62cb", - "b8388f25ad94e4c5ef09d424d6d24f7cfa156b863ee82cca4d28f74db476c81f", - "798d46bcde8ac14f5dd196ceab38b0cc806cfd97814dc30213b2fab37deeb41a", - "5864e73def8a4c697e79275ad545ba595fae0c3edb44b5639c9f971433eb4a7e", - "c834c49b93a56dee7800d299ce77f694aef72e3e9b536af360515a9d8226d411", - "307eede13672e33004de6e5d66c6a1a40408a34b6b095399d94a6028f4fdf498", - "6d26e33e9b1ac3e2924879ec39a8f565aebdd004b28ca0377e52a4ef9f86302d", - "5cc263a10f2b0c008b4f3074ffa0ee057c40f1490d2e0717715958e7307aa558", - "192f9b30a0e1d5d4ef16031eaa257d9d84f19c75431cdb3159ee5494c81eeaa8", - "e5e823f3b827c1951d870ef1c402947f1aa6466f313499daab18f60b1519c4a6", - "9895c3aafb063434b7507ae771e6ec4ea1faa5bcbbd5fcc2932a44031eac3547", - "b15411553c1e86f2e851e67e3de767bb9f017da7fd73542f9cb13ca85a2b9cc5", - "2971206110b600d0b0f2120dc98404c2fa0086ea8ece6ed68b4320f585026b30", - "6506597b23bbdb6d0949c16a7d432b8286566895dfe2b9d7e0dc9333ead0d645", - "a6c3c5ad654dcdc9515260426a476d69d8ce3b73151f660adad29ff444047f9a", - "7aa5492fe997029882a68f733c15cc09393b7f69c0608715fcbad813ad1c1fcf", - "6f5ccf726c09f097c4dd0279bb017a211caf9f1416520284f58314dea662d83a", - "f54a6019b86623e2afba6cf405a78a47dbc248fb12f11032e453cd1bb2c4cade", - "2380bdff9700736e12cb6038b7f7897bced9915e102a7e1715cbc2a93e85de08", - "5f12edfb089ec7da0e2d75003531f7dc19e52a5e0bb99fa448415a763a9d0466", - "968a97769cdc23893c407171b3d83b63cca25b88e838d6f489ba8874fc378642", - "e52552ffc80c4947dd1477fe06690ecee3544a24a2ec99b320de8386226cd57", - "30e39614bf11a831fe8e5abd4ff34e0d0f19337e505ea16e7b55770208f1a65b", - "ea2f2b92e8fe184a6eed5f15f042b1ef96a29ccf60b69be8585e84355e97d9b", - "606ce93b81816ceeb5d1582551c83edbb2324ddc3ea6c355502fb008e949b4c5", - "942e798d00041f0d923bfb7f438556f069500b17e4e37ea7a2c524c021913043", - "fade41bedfd5889dd7f1ee65c4c6daf8128a1cbfaebde0074b67d4650ce70af7", - "6be71a0b8facbd5c877f84173f1a251f4980be03395583e4c2fd20c52e2a4394", - "25323e64eaa402475e0b0c5b29c17bf38fa393cd2af469107f3b7a6d7a87d5bd", - "fd1ffe1c90265a9c5e8660bc5be8b9b547a27fffefa34f9d2d7c3bc4184864e2", - "8f849302b357d36535a9107bfe6cd0299c3e4f9e5cb6d9dd6da91e014529d7e0", - "c179af3a8482b96549dab5552f951f4eb9ccd4b17160941c16c3bf6be49b9fd1", - "db240bc1dcbdccbd39d48628ee2f849d8ab610f589015f9cc847ead13f0ed965", - "6b2c895da7ec15f58bd820dc8b2c71451e300b10119abfd74a2f56c0b34f9691", - "f62dc1addae6efbebb9270a38040ee36d8ddfd9faa06c0965d129311a3603049", - "ab18e7c0831991736f037ad9b4485888c9613e2632b8868bc4fd103d42f8ab95", - "e8ecef3ce401b129abbb90a691dd58cfe412b3cc91096ab938687d6a31790f5b", - "c5b72ee7a99992d6d69f1fa88448fd6114e02047563a17ff4b06b60cf95f1bc1", - "c03eede09d30f728c072b9a51d4196928b75ad97464a58123de63e2fb88ce6db", - "815a357554af46d2409b050bf5b8576ea4433f93be0022d60111f9666810fec1", - "5a12e5bf51e06e08ed407440c638db83f01e50a5e7ef60b2b6a5c4c97c638d72", - "9a63d5370d55feab709b5a89057cc958dcc27f8dc97fc31d756e254fca3ab95d", - "295f6bf83dd3786be112649b876bd7fc85092a26e371c32de1610e83aa0b52e2", - "2c49e4b7539af2c04106100b1ef9fb4e78256f579eab536795d6643de0995af6", - "9603af83c7a7f6d6bbb29e077d547daa108f82a88266f5ffa352dc32fc8fc2ad", - "ae351496525d73aa86f4bc75eff3838d416da3b7b8560631f1997bcee197df43", - "13c9788b5acb21a796c91ee98d4ec59212a3dd298dd679b63eceddd846fffc4e", - "ee1aa84638e8f0f1d569b41a7b086d115de8a60df51fd9be7bab5bbb9148452c", - "7eab180ce19c973aec552d8d3fd929f8c9887b70524d31afed455557bb128de9", - "48c8f8c17c54d18b5ba8030302ec6c69a24298ec802f059f594c27221209ae77", - "33b4f14d5941c4831217ed5f1133fe236379cf51e43d34e21cfa5f3b550b1e5b", - "ea052981db488cb53b880fa44904def82ca6817fce6750f4b67ae75471e768d6", - "5c3db160634a6dcd482ee6fde99ac1f3e3bfa98ea7362461ba020b86c2220f20", - "cf3ee418d7b53b25cf6e5114f5f57585d9597c9352af4d55835d5820f55d58c3", - "55d512494d2fe01303b724452d2ec80cba26cf431cea0c91117ec091204d46c1", - "17a23ffd90f53ec2ba760c418b8663f896fb307018fa8b71a3ee6d0f2dff0e0e", - "2b860e2a76738c8e3af1ba7be4df56b7ca49e817d9abc8367a74c42660b50109", - "dcb46a25511e3ec3ced2329d894ccc3da4840d66aab85c073d48d99d2d766030", - "79771a7db2d8c8a8a611a814328e2d2e4fc6d6f2c146d93181ffbe5a6226cc8c", - "8d73454d355f2835f8e97c53ef5da56e6f2e17ed66aae0670f06ad99e13beca6", - "2a97bd64fe3229c2fdc98039239246a02bb6d223d93f40657affd68a3bef58dd", - "abb8de517e96599bd18e849aa722a2ce1b59f87a1e7fe188fda734f07b4962fb", ]; +const authors: string[] = []; -const authors = [ - // "84659c5e4dbff288ac294b22e2cff54b5af8e2d2071985859298160de5211dd8", - // "8b6050822e1b51fbc70d4fa05e158f0067d24d86e9c831dd86909d22b77f67f6", - // "e9a2a5f535cce1f1447b4219d5f7f30cefea50be5e0017cfd877efe881f589c5", - // "efe5d120df0cc290fa748727fb45ac487caad346d4f2293ab069e8f01fc51981", - // "5afa711a2e4f45294c9ca8033861a0e15a879e6fc1a4a6903640f7faeae0d7b8", - // "8aa70f4433129dadb71330ac89f62b534caa200a9f3ee349a0f4a5593073d1a6", - // "c7e300eb1e297c5331cf57ae1304b48e5adeeb56c492d9f450494b39e94ebe38", - // "675b84fe75e216ab947c7438ee519ca7775376ddf05dadfba6278bd012e1d728", - - // "efe5d120df0cc290fa748727fb45ac487caad346d4f2293ab069e8f01fc51981", - // "5afa711a2e4f45294c9ca8033861a0e15a879e6fc1a4a6903640f7faeae0d7b8", - // "8aa70f4433129dadb71330ac89f62b534caa200a9f3ee349a0f4a5593073d1a6", - // "c7e300eb1e297c5331cf57ae1304b48e5adeeb56c492d9f450494b39e94ebe38", - // "675b84fe75e216ab947c7438ee519ca7775376ddf05dadfba6278bd012e1d728", - // "99cefa645b00817373239aebb96d2d1990244994e5e565566c82c04b8dc65b54", - // "b7c7f5d30652fbe57aa4ffd373c5fa3912760842c71d260b0f5c498f020f5f03", - // "d60397e8a390b41dd17551b04be27ad26831beb6d55a98a1f14d94ec2fe3fde0", - // "9279276bffb83cee33946e564c3600a32840269a8206d01ddf40c6432baa0bcb", - // "41e368dbaade3de1f0099f58d4e05f8956ad360a9eb7ea210c4272da9ffa04e8", - // "e05cb33ce37bfadfc26a5406e082f84550f63f992df5256dfd08ac62082a99e", - // "16afe0531a7cd932b9a7c6cdceb9b6c19a43b364af4cce7eb6064de2f9bf18d0", - // "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", - // "e185c2ad0b87b3207ac2b96d6b8fb1ff10fbf25f93eef4d04fb6dbb9039f19fb", - // "99c31c5745565d627684dcf231378b77bb509606f550cfd77bc9ac8fe2bc26f6", - // "9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7", - // "cbab7074a03bf89c7dd1623e42e65409e3918662af6c65fe2e38c92ff9f0bd61", - // "b6f856ffc06f6b1d3cc6fbee75adffaed247825b50b929f1bd3483a65b519e34", - // "18f54af1e10c5bb7a35468b0f62b295d12347903c9f95738d065c84bef1402ef", - // "7202985c7e34a7c1c48b93d882a953c5258cf226204ec95bececd8360c792969", - // "db333633799ef7f9dbb2967abcbed657b6c67578459f2bc2bd88d5842a60f954", - // - // - "6e1a6ad5b47e106d3f1be0f2d531580431533b2eb10fad59610594c5455cff7b", - "57dff5343897ca899797b0456d933b637f1051191fd2757889621d48e2f4d622", - "f40c016cf439fe9a1bd8deccf6e11bc3e00655147b5f69691a460972c5f5e7b3", - "9b01d92437819fd91c951ed09fd03036d1c036541f69e17ddfef2e7c7a3f31b7", - "30e39614bf11a831fe8e5abd4ff34e0d0f19337e505ea16e7b55770208f1a65b", - "d5f6d6db022fea369a2fec29541844567f12ad6991ed413c6b4be108a8468dcc", - // "41be274d7dd6cc2bf09bc9bc618cd4476ca1800bebd8888cabdc99f18ba", - "866285e35302b3f3688786bb5a118c8bffd98c255c8634f6a172e0abd63ddc73", - "7877d504a89e7464576d9c06ba2d06fb1de2b2e017ac107de2710216ef24df01", - "65eec5d230d6bb899db4d7d947f4fc82b0e8965b08fcd683a6478962337ebcdb", // -]; async function wsClient(url: string) { console.log("connecting to relae", url); const relay = new Relay(url, (msg) => { @@ -167,13 +33,12 @@ async function wsClient(url: string) { await relay.connect(); const id = crypto.randomUUID(); const fids = ids.filter((i) => i.length === 64); - const filter = { ids: fids, limit: 500 }; - // const filter = { kinds: [1, 6], limit: 50 }; + // const filter = { ids: fids, limit: 500 }; + const filter = { kinds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], limit: 500 }; // const filter = { // kinds: [0], // authors, // }; - console.log("author count", authors.length); let eventCount = 0; const pubkeys: string[] = []; relay.subscribe(id, [filter], { @@ -211,7 +76,8 @@ async function wsClient(url: string) { // return socket; } -const relays = ["wss://nos.lol", "wss://relay.damus.io"]; +// const relays = ["wss://nos.lol", "wss://relay.damus.io"]; +const relays = ["wss://n.urbit.cloud"]; async function run() { console.log("wth"); |