Merge pull request #4074 from urbit/release/next-userspace

release/next-userspace -> na-release/candidate
This commit is contained in:
matildepark 2020-12-03 23:43:44 -05:00 committed by GitHub
commit 604f0acb57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
91 changed files with 2293 additions and 875 deletions

View File

@ -21,11 +21,15 @@
:: We get ++unix-event and ++pill from /-aquarium :: We get ++unix-event and ++pill from /-aquarium
:: ::
/- aquarium /- aquarium
/+ pill, default-agent /+ pill, default-agent, aqua-azimuth, dbug, verb
=, pill-lib=pill =, pill-lib=pill
=, aquarium =, aquarium
=> $~ |% => $~ |%
+$ state +$ versioned-state
$% state-0
state-1
==
+$ state-0
$: %0 $: %0
pil=pill pil=pill
assembled=* assembled=*
@ -33,7 +37,16 @@
fleet-snaps=(map term (map ship pier)) fleet-snaps=(map term (map ship pier))
piers=(map ship pier) piers=(map ship pier)
== ==
+$ state-1
$: %1
pil=pill
assembled=*
tym=@da
fleet-snaps=(map term fleet)
piers=fleet
==
:: ::
+$ fleet [ships=(map ship pier) azi=az-state]
+$ pier +$ pier
$: snap=* $: snap=*
event-log=(list unix-timed-event) event-log=(list unix-timed-event)
@ -42,9 +55,11 @@
== ==
-- --
:: ::
=| state =| state-1
=* all-state - =* state -
=< =<
%- agent:dbug
%+ verb |
^- agent:gall ^- agent:gall
|_ =bowl:gall |_ =bowl:gall
+* this . +* this .
@ -52,24 +67,37 @@
ac ~(. aqua-core bowl) ac ~(. aqua-core bowl)
def ~(. (default-agent this %|) bowl) def ~(. (default-agent this %|) bowl)
++ on-init `this ++ on-init `this
++ on-save !>(all-state) ++ on-save !>(state)
++ on-load ++ on-load
|= old-state=vase |= old-vase=vase
^- step:agent:gall ^- step:agent:gall
~& prep=%aqua ~& prep=%aqua
=+ new=((soft state) !<(* old-state)) =+ !<(old=versioned-state old-vase)
?~ new =| cards=(list card:agent:gall)
`this |-
`this(all-state u.new) ?- -.old
:: wipe fleets and piers rather than give them falsely nulled azimuth state
::
%0
%_ $
-.old %1
fleet-snaps.old *(map term fleet)
piers.old *fleet
==
::
%1
[cards this(state old)]
==
:: ::
++ on-poke ++ on-poke
|= [=mark =vase] |= [=mark =vase]
^- step:agent:gall ^- step:agent:gall
=^ cards all-state =^ cards state
?+ mark ~|([%aqua-bad-mark mark] !!) ?+ mark ~|([%aqua-bad-mark mark] !!)
%aqua-events (poke-aqua-events:ac !<((list aqua-event) vase)) %aqua-events (poke-aqua-events:ac !<((list aqua-event) vase))
%pill (poke-pill:ac !<(pill vase)) %pill (poke-pill:ac !<(pill vase))
%noun (poke-noun:ac !<(* vase)) %noun (poke-noun:ac !<(* vase))
%azimuth-action (poke-azimuth-action:ac !<(azimuth-action vase))
== ==
[cards this] [cards this]
:: ::
@ -92,7 +120,18 @@
++ on-peek peek:ac ++ on-peek peek:ac
:: ::
++ on-agent on-agent:def ++ on-agent on-agent:def
++ on-arvo on-arvo:def ::
++ on-arvo
|= [=wire sign=sign-arvo]
^- step:agent:gall
?+ wire (on-arvo:def wire sign)
[%wait @ ~]
?> ?=(%wake +<.sign)
=/ wen=@da (slav %da i.t.wire)
=^ cards state
(handle-wake:ac wen)
[cards this]
==
++ on-fail on-fail:def ++ on-fail on-fail:def
-- --
:: ::
@ -110,7 +149,7 @@
:: ::
++ pe ++ pe
|= who=ship |= who=ship
=+ (~(gut by piers) who *pier) =+ (~(gut by ships.piers) who *pier)
=* pier-data - =* pier-data -
|% |%
:: ::
@ -118,7 +157,7 @@
:: ::
++ abet-pe ++ abet-pe
^+ this ^+ this
=. piers (~(put by piers) who pier-data) =. ships.piers (~(put by ships.piers) who pier-data)
this this
:: ::
:: Initialize new ship :: Initialize new ship
@ -248,7 +287,19 @@
this this
:: ::
++ abet-aqua ++ abet-aqua
^- (quip card:agent:gall state) ^- (quip card:agent:gall _state)
::
:: interecept %request effects to handle azimuth subscription
::
=. this
%- emit-cards
%- zing
%+ turn ~(tap by unix-effects)
|= [=ship ufs=(list unix-effect)]
%+ murn ufs
|= uf=unix-effect
(router:aqua-azimuth our.hid ship uf azi.piers)
::
=. this =. this
=/ =path /effect =/ =path /effect
%- emit-cards %- emit-cards
@ -300,20 +351,19 @@
=/ =path /boths/(scot %p ship) =/ =path /boths/(scot %p ship)
[%give %fact ~[path] %aqua-boths !>(`aqua-boths`[ship (flop bo)])] [%give %fact ~[path] %aqua-boths !>(`aqua-boths`[ship (flop bo)])]
:: ::
[(flop cards) all-state] [(flop cards) state]
:: ::
++ emit-cards ++ emit-cards
|= ms=(list card:agent:gall) |= ms=(list card:agent:gall)
=. cards (weld ms cards) =. cards (weld ms cards)
this this
:: ::
::
:: Run all events on all ships until all queues are empty :: Run all events on all ships until all queues are empty
:: ::
++ plow-all ++ plow-all
|- ^+ this |- ^+ this
=/ who =/ who
=/ pers ~(tap by piers) =/ pers ~(tap by ships.piers)
|- ^- (unit ship) |- ^- (unit ship)
?~ pers ?~ pers
~ ~
@ -331,7 +381,7 @@
:: ::
++ poke-pill ++ poke-pill
|= p=pill |= p=pill
^- (quip card:agent:gall state) ^- (quip card:agent:gall _state)
=. this apex-aqua =< abet-aqua =. this apex-aqua =< abet-aqua
=. pil p =. pil p
~& lent=(met 3 (jam boot-ova.pil)) ~& lent=(met 3 (jam boot-ova.pil))
@ -362,7 +412,7 @@
:: ::
++ poke-noun ++ poke-noun
|= val=* |= val=*
^- (quip card:agent:gall state) ^- (quip card:agent:gall _state)
=. this apex-aqua =< abet-aqua =. this apex-aqua =< abet-aqua
^+ this ^+ this
:: Could potentially factor out the three lines of turn-ships :: Could potentially factor out the three lines of turn-ships
@ -391,7 +441,7 @@
=/ txt .^(@ %cx (weld pax /hoon)) =/ txt .^(@ %cx (weld pax /hoon))
[/vane/[vane] [%veer v pax txt]] [/vane/[vane] [%veer v pax txt]]
=> .(this ^+(this this)) => .(this ^+(this this))
=^ ms all-state (poke-pill pil) =^ ms state (poke-pill pil)
(emit-cards ms) (emit-cards ms)
:: ::
[%swap-files ~] [%swap-files ~]
@ -402,7 +452,7 @@
%- unix-event %- unix-event
%- %*(. file-ovum:pill-lib directories slim-dirs) %- %*(. file-ovum:pill-lib directories slim-dirs)
/(scot %p our.hid)/work/(scot %da now.hid) /(scot %p our.hid)/work/(scot %da now.hid)
=^ ms all-state (poke-pill pil) =^ ms state (poke-pill pil)
(emit-cards ms) (emit-cards ms)
:: ::
[%wish hers=* p=@t] [%wish hers=* p=@t]
@ -412,12 +462,14 @@
(wish:(pe who) p.val) (wish:(pe who) p.val)
:: ::
[%unpause-events hers=*] [%unpause-events hers=*]
=. this start-azimuth-timer
%+ turn-ships ((list ship) hers.val) %+ turn-ships ((list ship) hers.val)
|= [who=ship thus=_this] |= [who=ship thus=_this]
=. this thus =. this thus
start-processing-events:(pe who) start-processing-events:(pe who)
:: ::
[%pause-events hers=*] [%pause-events hers=*]
=. this stop-azimuth-timer
%+ turn-ships ((list ship) hers.val) %+ turn-ships ((list ship) hers.val)
|= [who=ship thus=_this] |= [who=ship thus=_this]
=. this thus =. this thus
@ -428,17 +480,47 @@
this this
== ==
:: ::
:: Make changes to azimuth state for the current fleet
::
++ poke-azimuth-action
|= act=azimuth-action
^- (quip card:agent:gall _state)
=. this apex-aqua =< abet-aqua
^+ this
?- -.act
::
%init-azimuth
=. azi.piers *az-state
start-azimuth-timer
::
%spawn
=. state (spawn who.act)
this
::
%breach
:: should we remove the pier from state here?
=. state (breach who.act)
this
::
==
::
:: Apply a list of events tagged by ship :: Apply a list of events tagged by ship
:: ::
++ poke-aqua-events ++ poke-aqua-events
|= events=(list aqua-event) |= events=(list aqua-event)
^- (quip card:agent:gall state) ^- (quip card:agent:gall _state)
=. this apex-aqua =< abet-aqua =. this apex-aqua =< abet-aqua
%+ turn-events events %+ turn-events events
|= [ae=aqua-event thus=_this] |= [ae=aqua-event thus=_this]
=. this thus =. this thus
?- -.ae ?- -.ae
::
%init-ship %init-ship
:: XX Note that the keys that get passed in are unused. The keys field
:: should be deleted now that aqua is capable of managing azimuth state
:: internally. Its been left this way for now until all the ph tests
:: can be rewritten
=/ keys=dawn-event:able:jael (dawn who.ae)
=. this abet-pe:(publish-effect:(pe who.ae) [/ %sleep ~]) =. this abet-pe:(publish-effect:(pe who.ae) [/ %sleep ~])
=/ initted =/ initted
=< plow =< plow
@ -451,7 +533,7 @@
:^ //term/1 %boot & :^ //term/1 %boot &
?~ keys.ae ?~ keys.ae
[%fake who.ae] [%fake who.ae]
[%dawn u.keys.ae] [%dawn keys]
-.userspace-ova.pil -.userspace-ova.pil
[//http-client/0v1n.2m9vh %born ~] [//http-client/0v1n.2m9vh %born ~]
[//http-server/0v1n.2m9vh %born ~] [//http-server/0v1n.2m9vh %born ~]
@ -464,27 +546,36 @@
stop-processing-events:(pe who.ae) stop-processing-events:(pe who.ae)
:: ::
%snap-ships %snap-ships
=. this
%+ turn-ships (turn ~(tap by ships.piers) head)
|= [who=ship thus=_this]
=. this thus
(publish-effect:(pe who) [/ %kill ~])
=. fleet-snaps =. fleet-snaps
%+ ~(put by fleet-snaps) lab.ae %+ ~(put by fleet-snaps) lab.ae
:_ azi.piers
%- malt %- malt
%+ murn hers.ae %+ murn hers.ae
|= her=ship |= her=ship
^- (unit (pair ship pier)) ^- (unit (pair ship pier))
=+ per=(~(get by piers) her) =+ per=(~(get by ships.piers) her)
?~ per ?~ per
~ ~
`[her u.per] `[her u.per]
=. this stop-azimuth-timer
=. piers *fleet
(pe -.hers.ae) (pe -.hers.ae)
:: ::
%restore-snap %restore-snap
=. this =. this
%+ turn-ships (turn ~(tap by piers) head) %+ turn-ships (turn ~(tap by ships.piers) head)
|= [who=ship thus=_this] |= [who=ship thus=_this]
=. this thus =. this thus
(publish-effect:(pe who) [/ %sleep ~]) (publish-effect:(pe who) [/ %kill ~])
=. piers (~(uni by piers) (~(got by fleet-snaps) lab.ae)) =. piers (~(got by fleet-snaps) lab.ae)
=. this start-azimuth-timer
=. this =. this
%+ turn-ships (turn ~(tap by piers) head) %+ turn-ships (turn ~(tap by ships.piers) head)
|= [who=ship thus=_this] |= [who=ship thus=_this]
=. this thus =. this thus
(publish-effect:(pe who) [/ %restore ~]) (publish-effect:(pe who) [/ %restore ~])
@ -537,18 +628,163 @@
^- (unit (unit cage)) ^- (unit (unit cage))
?+ path ~ ?+ path ~
[%x %fleet-snap @ ~] ``noun+!>((~(has by fleet-snaps) i.t.t.path)) [%x %fleet-snap @ ~] ``noun+!>((~(has by fleet-snaps) i.t.t.path))
[%x %ships ~] ``noun+!>((turn ~(tap by piers) head)) [%x %fleets ~] ``noun+!>((turn ~(tap by fleet-snaps) head))
[%x %ships ~] ``noun+!>((turn ~(tap by ships.piers) head))
[%x %pill ~] ``pill+!>(pil) [%x %pill ~] ``pill+!>(pil)
[%x %i @ @ @ @ @ *] [%x %i @ @ @ @ @ *]
=/ who (slav %p i.t.t.path) =/ who (slav %p i.t.t.path)
=/ pier (~(get by piers) who) =/ pier (~(get by ships.piers) who)
?~ pier ?~ pier
~ ~
:^ ~ ~ %noun !> :^ ~ ~ %noun !>
(peek:(pe who) t.t.t.path) (peek:(pe who) t.t.t.path)
[%x %log-info ~]
``noun+!>([lives.azi.piers (lent logs.azi.piers) tym.azi.piers])
== ==
:: ::
:: Trivial scry for mock :: Trivial scry for mock
:: ::
++ scry |=([* *] ~) ++ scry |=([* *] ~)
::
++ handle-wake
|= wen=@da
^- (quip card:agent:gall _state)
=. this apex-aqua =< abet-aqua
?. =(wen tym.azi.piers)
this
=. state (spam-logs 10)
start-azimuth-timer
::
++ start-azimuth-timer
^+ this
=? this !=(tym.azi.piers *@da)
stop-azimuth-timer
=/ until=@da (add now.hid ~s40)
=. tym.azi.piers until
%- emit-cards
[%pass /wait/(scot %da until) %arvo %b %wait until]~
::
++ stop-azimuth-timer
^+ this
=* tym tym.azi.piers
?: =(tym *@da)
this
%- emit-cards
[%pass /wait/(scot %da tym) %arvo %b %rest tym]~
::
++ spam-logs
|= n=@
^- _state
=* loop $
?: =(n 0)
state
=/ new-state=_state
?. (~(has by lives.azi.piers) ~fes)
(spawn ~fes)
(cycle-keys ~fes)
=. state new-state
loop(n (dec n))
::
++ spawn
|= who=@p
^- _state
?< (~(has by lives.azi.piers) who)
=. lives.azi.piers (~(put by lives.azi.piers) who [1 0])
=. logs.azi.piers
%+ weld logs.azi.piers
:_ ~
%- changed-keys:lo:aqua-azimuth
:* who
(get-public:aqua-azimuth who 1 %crypt)
(get-public:aqua-azimuth who 1 %auth)
1
1
==
(spam-logs 10)
::
++ cycle-keys
|= who=@p
^- _state
=/ prev
~| no-such-ship+who
(~(got by lives.azi.piers) who)
=/ lyfe +(lyfe.prev)
=. lives.azi.piers (~(put by lives.azi.piers) who [lyfe rut.prev])
=. logs.azi.piers
%+ weld logs.azi.piers
:_ ~
%- changed-keys:lo:aqua-azimuth
:* who
(get-public:aqua-azimuth who lyfe %crypt)
(get-public:aqua-azimuth who lyfe %auth)
1
lyfe
==
state
::
++ breach
|= who=@p
^- _state
=. state (cycle-keys who)
=/ prev (~(got by lives.azi.piers) who)
=/ rut +(rut.prev)
=. lives.azi.piers (~(put by lives.azi.piers) who [lyfe.prev rut])
=. logs.azi.piers
%+ weld logs.azi.piers
[(broke-continuity:lo:aqua-azimuth who rut) ~]
(spam-logs 10)
::
++ dawn
|= who=ship
^- dawn-event:able:jael
?> ?=(?(%czar %king %duke) (clan:title who))
=/ spon=(list [ship point:azimuth])
%- flop
|- ^- (list [ship point:azimuth])
=/ =ship (^sein:title who)
=/ a-point=[^ship point:azimuth]
=/ spon-spon [& (^sein:title ship)]
=/ life-rift ~|([ship lives.azi.piers] (~(got by lives.azi.piers) ship))
=/ =life lyfe.life-rift
=/ =rift rut.life-rift
=/ =pass
%^ pass-from-eth:azimuth
(as-octs:mimes:html (get-public:aqua-azimuth ship life %crypt))
(as-octs:mimes:html (get-public:aqua-azimuth ship life %auth))
1
:^ ship
*[address address address address]:azimuth
`[life=life pass rift spon-spon ~]
~
?: ?=(%czar (clan:title ship))
[a-point]~
[a-point $(who ship)]
=/ =seed:able:jael
=/ life-rift (~(got by lives.azi.piers) who)
=/ =life lyfe.life-rift
[who life sec:ex:(get-keys:aqua-azimuth who life) ~]
:* seed
spon
get-czars
~[~['arvo' 'netw' 'ork']]
0
`(need (de-purl:html 'http://localhost:8545'))
==
::
:: Should only do galaxies
::
++ get-czars
^- (map ship [rift life pass])
%- malt
%+ murn
~(tap by lives.azi.piers)
|= [who=ship lyfe=life rut=rift]
?. =(%czar (clan:title who))
~
%- some
:^ who rut lyfe
%^ pass-from-eth:azimuth
(as-octs:mimes:html (get-public:aqua-azimuth who lyfe %crypt))
(as-octs:mimes:html (get-public:aqua-azimuth who lyfe %auth))
1
-- --

View File

@ -178,22 +178,7 @@
~[(add-pending rid ship.act)] ~[(add-pending rid ship.act)]
:: ::
%delete %delete
=/ rid=resource ~
(de-path:resource path.act)
=/ group-pokes=(list card)
?: =(our.bol entity.rid)
~[(group-push-poke %remove rid)]
:~ (group-proxy-poke %remove-members rid (sy our.bol ~))
(group-pull-poke %remove rid)
==
;: weld
group-pokes
:~ (contact-hook-poke [%remove path.act])
(group-poke [%remove-group rid ~])
(contact-poke [%delete path.act])
==
(delete-metadata path.act)
==
:: ::
%remove %remove
=/ rid=resource =/ rid=resource
@ -340,13 +325,6 @@
(metadata-hook-poke [%add-owned path]) (metadata-hook-poke [%add-owned path])
== ==
:: ::
++ delete-metadata
|= =path
^- (list card)
:~ (metadata-poke [%remove path [%contacts path]])
(metadata-hook-poke [%remove path])
==
::
++ all-scry ++ all-scry
^- rolodex ^- rolodex
.^(rolodex %gx /(scot %p our.bol)/contact-store/(scot %da now.bol)/all/noun) .^(rolodex %gx /(scot %p our.bol)/contact-store/(scot %da now.bol)/all/noun)

View File

@ -5,7 +5,7 @@
/- glob /- glob
/+ default-agent, verb, dbug /+ default-agent, verb, dbug
|% |%
++ hash 0v4.fpa4r.s6dtc.h8tps.62jv0.qn0fj ++ hash 0v5.0umdn.af5hq.bp84b.66eao.q0b98
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states +$ all-states
$% state-0 $% state-0

View File

@ -748,12 +748,14 @@
[%x %peek-update-log @ @ ~] [%x %peek-update-log @ @ ~]
=/ =ship (slav %p i.t.t.path) =/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path =/ =term i.t.t.t.path
=/ update-log=(unit update-log:store) (~(get by update-logs) [ship term]) =/ m-update-log=(unit update-log:store) (~(get by update-logs) [ship term])
?~ update-log [~ ~] :- ~ :- ~ :- %noun
=/ result=(unit [time update:store]) !> ^- (unit time)
(peek:orm-log:store u.update-log) %+ biff m-update-log
?~ result [~ ~] |= =update-log:store
``noun+!>([~ -.u.result]) =/ result=(unit [=time =update:store])
(peek:orm-log:store update-log)
(bind result |=([=time update:store] time))
== ==
:: ::
++ get-node ++ get-node

View File

@ -166,11 +166,16 @@
?~ existing-notif ?~ existing-notif
notification notification
(merge-notification:ha u.existing-notif notification) (merge-notification:ha u.existing-notif notification)
=/ new-read=?
?~ existing-notif
%.y
read.u.existing-notif
=. read.new %.n
=/ new-timebox=timebox:store =/ new-timebox=timebox:store
(~(put by timebox) index new) (~(put by timebox) index new)
:- (give:ha [/updates]~ %added last-seen index new) :- (give:ha [/updates]~ %added last-seen index new)
%_ state %_ state
+ ?~(existing-notif (upd-unreads:ha index last-seen %.n) +.state) + ?.(new-read +.state (upd-unreads:ha index last-seen %.n))
notifications (put:orm notifications last-seen new-timebox) notifications (put:orm notifications last-seen new-timebox)
== ==
++ read-index ++ read-index

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div> <div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script> <script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script> <script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.c099f574cf3ccea90625.js"></script> <script src="/~landscape/js/bundle/index.2e81ebfd000b4945c3fe.js"></script>
</body> </body>
</html> </html>

View File

@ -15,6 +15,7 @@ class Channel {
} }
init() { init() {
this.debounceInterval = 500;
// unique identifier: current time and random number // unique identifier: current time and random number
// //
this.uid = this.uid =
@ -55,6 +56,20 @@ class Channel {
// disconnect function may be called exactly once. // disconnect function may be called exactly once.
// //
this.outstandingSubscriptions = new Map(); this.outstandingSubscriptions = new Map();
this.outstandingJSON = [];
this.debounceTimer = null;
}
resetDebounceTimer() {
if(this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.debounceTimer = setTimeout(() => {
this.sendJSONToChannel();
}, this.debounceInterval)
} }
setOnChannelError(onError = (err) => {}) { setOnChannelError(onError = (err) => {}) {
@ -71,6 +86,12 @@ class Channel {
}); });
} }
clearQueue() {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
this.sendJSONToChannel();
}
// sends a poke to an app on an urbit ship // sends a poke to an app on an urbit ship
// //
poke(ship, app, mark, json, successFunc, failureFunc) { poke(ship, app, mark, json, successFunc, failureFunc) {
@ -83,14 +104,16 @@ class Channel {
} }
); );
this.sendJSONToChannel({ const j = {
id, id,
action: "poke", action: "poke",
ship, ship,
app, app,
mark, mark,
json json
}); };
this.sendJSONToChannel(j);
} }
// subscribes to a path on an specific app and ship. // subscribes to a path on an specific app and ship.
@ -104,7 +127,8 @@ class Channel {
connectionErrFunc = () => {}, connectionErrFunc = () => {},
eventFunc = () => {}, eventFunc = () => {},
quitFunc = () => {}, quitFunc = () => {},
subAckFunc = () => {}) { subAckFunc = () => {},
) {
let id = this.nextId(); let id = this.nextId();
this.outstandingSubscriptions.set( this.outstandingSubscriptions.set(
id, id,
@ -116,14 +140,17 @@ class Channel {
} }
); );
this.sendJSONToChannel({ const json = {
id, id,
action: "subscribe", action: "subscribe",
ship, ship,
app, app,
path path
}); }
this.resetDebounceTimer();
this.outstandingJSON.push(json);
return id; return id;
} }
@ -131,6 +158,7 @@ class Channel {
// //
delete() { delete() {
let id = this.nextId(); let id = this.nextId();
clearInterval(this.ackTimer);
navigator.sendBeacon(this.channelURL(), JSON.stringify([{ navigator.sendBeacon(this.channelURL(), JSON.stringify([{
id, id,
action: "delete" action: "delete"
@ -154,12 +182,18 @@ class Channel {
// sends a JSON command command to the server. // sends a JSON command command to the server.
// //
sendJSONToChannel(j) { sendJSONToChannel(j) {
if(!j && this.outstandingJSON.length === 0) {
return;
}
let req = new XMLHttpRequest(); let req = new XMLHttpRequest();
req.open("PUT", this.channelURL()); req.open("PUT", this.channelURL());
req.setRequestHeader("Content-Type", "application/json"); req.setRequestHeader("Content-Type", "application/json");
if (this.lastEventId == this.lastAcknowledgedEventId) { if (this.lastEventId == this.lastAcknowledgedEventId) {
let x = JSON.stringify([j]); if(j) {
this.outstandingJSON.push(j);
}
let x = JSON.stringify(this.outstandingJSON);
req.send(x); req.send(x);
} else { } else {
// we add an acknowledgment to clear the server side queue // we add an acknowledgment to clear the server side queue
@ -167,7 +201,10 @@ class Channel {
// The server side puts messages it sends us in a queue until we // The server side puts messages it sends us in a queue until we
// acknowledge that we received it. // acknowledge that we received it.
// //
let payload = [{action: "ack", "event-id": parseInt(this.lastEventId)}]; let payload = [
...this.outstandingJSON,
{action: "ack", "event-id": parseInt(this.lastEventId)}
];
if(j) { if(j) {
payload.push(j) payload.push(j)
} }
@ -176,6 +213,7 @@ class Channel {
this.lastEventId = this.lastAcknowledgedEventId; this.lastEventId = this.lastAcknowledgedEventId;
} }
this.outstandingJSON = [];
this.connectIfDisconnected(); this.connectIfDisconnected();
} }
@ -217,8 +255,11 @@ class Channel {
funcs["subAck"](obj); funcs["subAck"](obj);
} }
} else if (obj.response == "diff") { } else if (obj.response == "diff") {
// ack subscription // ensure we ack before channel clogs
this.sendJSONToChannel(); if((this.lastEventId - this.lastAcknowledgedEventId) > 30) {
this.clearQueue();
}
let funcs = subFuncs; let funcs = subFuncs;
funcs["event"](obj.json); funcs["event"](obj.json);
} else if (obj.response == "quit") { } else if (obj.response == "quit") {

View File

@ -9,11 +9,12 @@
|% |%
+$ card card:agent:gall +$ card card:agent:gall
+$ versioned-state +$ versioned-state
$% state-0 $% [%0 observers=(map serial observer:sur)]
[%1 observers=(map serial observer:sur)]
[%2 observers=(map serial observer:sur)]
== ==
:: ::
+$ serial @uv +$ serial @uv
+$ state-0 [%0 observers=(map serial observer:sur)]
++ got-by-val ++ got-by-val
|= [a=(map serial observer:sur) b=observer:sur] |= [a=(map serial observer:sur) b=observer:sur]
^- serial ^- serial
@ -24,7 +25,7 @@
-- --
:: ::
%- agent:dbug %- agent:dbug
=| state-0 =| [%2 observers=(map serial observer:sur)]
=* state - =* state -
:: ::
^- agent:gall ^- agent:gall
@ -35,14 +36,16 @@
++ on-init ++ on-init
|^ ^- (quip card _this) |^ ^- (quip card _this)
:_ this :_ this
:_ ~ :~ (act [%watch %invite-store /invitatory/graph %invite-accepted-graph])
(act /inv-gra [%watch %invite-store /invitatory/graph %invite-accepted-graph]) (act [%watch %group-store /groups %group-on-leave])
(act [%watch %group-store /groups %group-on-remove-member])
==
:: ::
++ act ++ act
|= [=wire =action:sur] |= =action:sur
^- card ^- card
:* %pass :* %pass
wire /poke
%agent %agent
[our.bowl %observe-hook] [our.bowl %observe-hook]
%poke %poke
@ -56,7 +59,35 @@
++ on-load ++ on-load
|= old-vase=vase |= old-vase=vase
^- (quip card _this) ^- (quip card _this)
`this(state !<(state-0 old-vase)) |^
=/ old-state !<(versioned-state old-vase)
=| cards=(list card)
|-
?: ?=(%2 -.old-state)
[cards this(state old-state)]
?: ?=(%1 -.old-state)
=. cards
:_ cards
(act [%watch %group-store /groups %group-on-leave])
$(-.old-state %2)
=. cards
:_ cards
(act [%watch %group-store /groups %group-on-remove-member])
$(-.old-state %1)
::
++ act
|= =action:sur
^- card
:* %pass
/poke
%agent
[our.bowl %observe-hook]
%poke
%observe-action
!> ^- action:sur
action
==
--
:: ::
++ on-poke ++ on-poke
|= [=mark =vase] |= [=mark =vase]

View File

@ -83,7 +83,7 @@
?. ?=(%s -.jon) ?. ?=(%s -.jon)
[~ state] [~ state]
=/ str=@t +.jon =/ str=@t +.jon
=/ req=request:http (request-darksky str) =/ req=request:http (request-wttr str)
=/ out *outbound-config:iris =/ out *outbound-config:iris
=/ lismov=(list card) =/ lismov=(list card)
[%pass /[(scot %da now.bol)] %arvo %i %request req out]~ [%pass /[(scot %da now.bol)] %arvo %i %request req out]~
@ -102,11 +102,11 @@
^- (list card) ^- (list card)
[%give %fact ~[/all] %json !>((frond:enjs:format %location jon))]~ [%give %fact ~[/all] %json !>((frond:enjs:format %location jon))]~
:: ::
++ request-darksky ++ request-wttr
|= location=@t |= location=@t
^- request:http ^- request:http
=/ base 'https://api.darksky.net/forecast/634639c10670c7376dc66b6692fe57ca/' =/ base 'https://wttr.in/'
=/ url=@t (cat 3 (cat 3 base location) '?units=auto') =/ url=@t (cat 3 (cat 3 base location) '?format=j1')
=/ hed [['Accept' 'application/json']]~ =/ hed [['Accept' 'application/json']]~
[%'GET' url hed *(unit octs)] [%'GET' url hed *(unit octs)]
:: ::
@ -133,8 +133,9 @@
=/ jon=json =/ jon=json
%+ frond:enjs:format %weather %+ frond:enjs:format %weather
%- pairs:enjs:format %- pairs:enjs:format
:~ [%currently (~(got by p.u.ujon) 'currently')] :~ [%current-condition (~(got by p.u.ujon) 'current_condition')]
[%daily (~(got by p.u.ujon) 'daily')] [%weather (~(got by p.u.ujon) 'weather')]
[%nearest-area (~(got by p.u.ujon) 'nearest_area')]
== ==
:- [%give %fact ~[/all] %json !>(jon)]~ :- [%give %fact ~[/all] %json !>(jon)]~
%= state %= state
@ -146,7 +147,7 @@
|= [wir=wire err=(unit tang)] |= [wir=wire err=(unit tang)]
^- (quip card _state) ^- (quip card _state)
?~ err ?~ err
=/ req=request:http (request-darksky location) =/ req=request:http (request-wttr location)
=/ out *outbound-config:iris =/ out *outbound-config:iris
:_ state(timer `(add now.bol ~h3)) :_ state(timer `(add now.bol ~h3))
:~ [%pass /[(scot %da now.bol)] %arvo %i %request req out] :~ [%pass /[(scot %da now.bol)] %arvo %i %request req out]

View File

@ -0,0 +1,4 @@
:- %say
|= [* [her=ship ~] ~]
:- %azimuth-action
[%breach her]

View File

@ -0,0 +1,4 @@
:- %say
|= [* ~ ~]
:- %azimuth-action
[%init-azimuth ~]

View File

@ -3,4 +3,4 @@
:- %say :- %say
|= [* [her=ship ~] ~] |= [* [her=ship ~] ~]
:- %aqua-events :- %aqua-events
[%init-ship her ~]~ [%init-ship her `*dawn-event:able:jael]~

View File

@ -1,6 +1,6 @@
/- aquarium /- aquarium
=, aquarium =, aquarium
:- %say :- %say
|= [* [label=@ta] ~] |= [* [label=@ta ~] ~]
:- %aqua-events :- %aqua-events
[%snap-ships label]~ [%restore-snap label]~

View File

@ -1,7 +1,7 @@
/- aquarium /- aquarium
=, aquarium =, aquarium
:- %say :- %say
|= [[now=@da eny=@uvJ bec=beak] [label=@ta] ships=(list ship)] |= [[now=@da eny=@uvJ bec=beak] [label=@ta ships=(list ship)] ~]
:- %aqua-events :- %aqua-events
=? ships ?=(~ ships) =? ships ?=(~ ships)
.^((list ship) %gx /(scot %p p.bec)/aqua/(scot %da now)/ships/noun) .^((list ship) %gx /(scot %p p.bec)/aqua/(scot %da now)/ships/noun)

View File

@ -0,0 +1,4 @@
:- %say
|= [* [her=ship ~] ~]
:- %azimuth-action
[%spawn her]

View File

@ -0,0 +1,5 @@
:: Print the sponsor of this ship
:- %say
|= [[now=time @ our=ship ^] * ~]
:- %ship
(sein:title our now our)

View File

@ -0,0 +1,241 @@
/- *aquarium
::
|%
::
++ extract-request
|= [uf=unix-effect dest=@t]
^- (unit [num=@ud =request:http])
?. ?=(%request -.q.uf) ~
?. =(dest url.request.q.uf) ~
`[id.q.uf request.q.uf]
::
++ router
|= [our=ship her=ship uf=unix-effect azi=az-state]
^- (unit card:agent:gall)
=, enjs:format
=/ ask (extract-request uf 'http://localhost:8545/')
?~ ask
~
?~ body.request.u.ask
~
=/ req q.u.body.request.u.ask
|^ ^- (unit card:agent:gall)
=/ method (get-method req)
?: =(method 'eth_blockNumber')
:- ~
%+ answer-request req
s+(crip (num-to-hex:ethereum latest-block))
?: =(method 'eth_getBlockByNumber')
:- ~
%+ answer-request req
:- %o
=/ number (hex-to-num:ethereum (get-first-param req))
=/ hash (number-to-hash number)
=/ parent-hash (number-to-hash ?~(number number (dec number)))
%- malt
^- (list (pair term json))
:~ hash+s+(crip (prefix-hex:ethereum (render-hex-bytes:ethereum 32 hash)))
number+s+(crip (num-to-hex:ethereum number))
'parentHash'^s+(crip (num-to-hex:ethereum parent-hash))
==
?: =(method 'eth_getLogs')
:- ~
%+ answer-request req
?^ (get-param-obj-maybe req 'blockHash')
%- logs-by-hash
(get-param-obj req 'blockHash')
%+ logs-by-range
(get-param-obj req 'fromBlock')
(get-param-obj req 'toBlock')
~& [%ph-azimuth-miss req]
~
::
++ latest-block
(add launch:contracts:azimuth (dec (lent logs.azi)))
::
++ get-single-req
|= req=@t
=/ batch
((ar:dejs:format same) (need (de-json:html req)))
?> ?=([* ~] batch)
i.batch
::
++ get-id
|= req=@t
=, dejs:format
%. (get-single-req req)
(ot id+so ~)
::
++ get-method
|= req=@t
=, dejs:format
~| req=req
%. (get-single-req req)
(ot method+so ~)
::
++ get-param-obj
|= [req=@t param=@t]
=, dejs:format
%- hex-to-num:ethereum
=/ array
%. (get-single-req req)
(ot params+(ar (ot param^so ~)) ~)
?> ?=([* ~] array)
i.array
::
++ get-param-obj-maybe
|= [req=@t param=@t]
^- (unit @ud)
=, dejs-soft:format
=/ array
%. (get-single-req req)
(ot params+(ar (ot param^so ~)) ~)
?~ array
~
:- ~
?> ?=([* ~] u.array)
%- hex-to-num:ethereum
i.u.array
::
++ get-first-param
|= req=@t
=, dejs:format
=/ id
%. (get-single-req req)
(ot params+(at so bo ~) ~)
-.id
::
++ answer-request
|= [req=@t result=json]
^- card:agent:gall
=/ resp
%- crip
%- en-json:html
:- %a :_ ~
%- pairs
:~ id+s+(get-id req)
jsonrpc+s+'2.0'
result+result
==
=/ events=(list aqua-event)
:_ ~
:* %event
her
//http-client/0v1n.2m9vh
%receive
num.u.ask
[%start [200 ~] `(as-octs:mimes:html resp) &]
==
:* %pass /aqua-events
%agent [our %aqua]
%poke %aqua-events
!>(events)
==
::
++ number-to-hash
|= =number:block:able:jael
^- @
?: (lth number launch:contracts:azimuth)
(cat 3 0x5364 (sub launch:contracts:azimuth number))
(cat 3 0x5363 (sub number launch:contracts:azimuth))
::
++ hash-to-number
|= =hash:block:able:jael
(add launch:contracts:azimuth (div hash 0x1.0000))
::
++ logs-by-range
|= [from-block=@ud to-block=@ud]
%+ logs-to-json (max launch:contracts:azimuth from-block)
?: (lth to-block launch:contracts:azimuth)
~
%+ swag
?: (lth from-block launch:contracts:azimuth)
[0 +((sub to-block launch:contracts:azimuth))]
:- (sub from-block launch:contracts:azimuth)
+((sub to-block from-block))
logs.azi
::
++ logs-by-hash
|= =hash:block:able:jael
=/ =number:block:able:jael (hash-to-number hash)
(logs-by-range number number)
::
++ logs-to-json
|= [count=@ud selected-logs=(list az-log)]
^- json
:- %a
|- ^- (list json)
?~ selected-logs
~
:_ $(selected-logs t.selected-logs, count +(count))
%- pairs
:~ 'logIndex'^s+'0x0'
'transactionIndex'^s+'0x0'
:+ 'transactionHash' %s
(crip (prefix-hex:ethereum (render-hex-bytes:ethereum 32 `@`0x5362)))
::
:+ 'blockHash' %s
=/ hash (number-to-hash count)
(crip (prefix-hex:ethereum (render-hex-bytes:ethereum 32 hash)))
::
:+ 'blockNumber' %s
(crip (num-to-hex:ethereum count))
::
:+ 'address' %s
(crip (address-to-hex:ethereum azimuth:contracts:azimuth))
::
'type'^s+'mined'
::
'data'^s+data.i.selected-logs
:+ 'topics' %a
%+ turn topics.i.selected-logs
|= topic=@ux
^- json
:- %s
%- crip
%- prefix-hex:ethereum
(render-hex-bytes:ethereum 32 `@`topic)
==
--
::
++ get-keys
|= [who=@p lyfe=life]
^- acru:ames
%+ pit:nu:crub:crypto 32
(can 5 [1 (scot %p who)] [1 (scot %ud lyfe)] ~)
::
++ get-public
|= [who=@p lyfe=life typ=?(%auth %crypt)]
=/ bod (rsh 3 1 pub:ex:(get-keys who lyfe))
=+ [enc=(rsh 8 1 bod) aut=(end 8 1 bod)]
?: =(%auth typ)
aut
enc
::
:: Generate logs
::
++ lo
=, azimuth-events:azimuth
|%
++ broke-continuity
|= [who=ship rut=rift]
^- az-log
:- ~[^broke-continuity who]
%- crip
%- prefix-hex:ethereum
(render-hex-bytes:ethereum 32 `@`rut)
::
++ changed-keys
|= [who=ship enc=@ux aut=@ux crypto=@ud lyfe=life]
^- az-log
:- ~[^changed-keys who]
%- crip
%- prefix-hex:ethereum
;: welp
(render-hex-bytes:ethereum 32 `@`enc)
(render-hex-bytes:ethereum 32 `@`aut)
(render-hex-bytes:ethereum 32 `@`crypto)
(render-hex-bytes:ethereum 32 `@`lyfe)
==
--
--

View File

@ -417,6 +417,7 @@
:: ::
++ remove-group ++ remove-group
|= =json |= =json
^- [resource ~]
?> ?=(%o -.json) ?> ?=(%o -.json)
=/ rid=resource =/ rid=resource
(dejs:resource (~(got by p.json) 'resource')) (dejs:resource (~(got by p.json) 'resource'))

View File

@ -8,6 +8,12 @@
^- form:m ^- form:m
(poke-our %aqua %aqua-events !>(events)) (poke-our %aqua %aqua-events !>(events))
:: ::
++ send-azimuth-action
|= =azimuth-action
=/ m (strand ,~)
^- form:m
(poke-our %aqua %azimuth-action !>(azimuth-action))
::
++ take-unix-effect ++ take-unix-effect
=/ m (strand ,[ship unix-effect]) =/ m (strand ,[ship unix-effect])
^- form:m ^- form:m
@ -34,7 +40,7 @@
=/ m (strand ,~) =/ m (strand ,~)
^- form:m ^- form:m
~& > "starting" ~& > "starting"
;< ~ bind:m (start-threads vane-threads) ;< tids=(map term tid:spider) bind:m (start-threads vane-threads)
;< ~ bind:m (watch-our /effect %aqua /effect) ;< ~ bind:m (watch-our /effect %aqua /effect)
:: Get our very own event with no mistakes in it... yet. :: Get our very own event with no mistakes in it... yet.
:: ::
@ -64,16 +70,20 @@
:: ::
++ start-threads ++ start-threads
|= threads=(list term) |= threads=(list term)
=/ m (strand ,~) =/ m (strand ,(map term tid:spider))
^- form:m ^- form:m
;< =bowl:spider bind:m get-bowl ;< =bowl:spider bind:m get-bowl
=| tids=(map term tid:spider)
|- ^- form:m |- ^- form:m
=* loop $ =* loop $
?~ threads ?~ threads
(pure:m ~) (pure:m tids)
=/ tid
%+ scot %ta
(cat 3 (cat 3 'strand_' i.threads) (scot %uv (sham i.threads eny.bowl)))
=/ poke-vase !>([`tid.bowl ~ i.threads *vase]) =/ poke-vase !>([`tid.bowl ~ i.threads *vase])
;< ~ bind:m (poke-our %spider %spider-start poke-vase) ;< ~ bind:m (poke-our %spider %spider-start poke-vase)
loop(threads t.threads) loop(threads t.threads, tids (~(put by tids) i.threads tid))
:: ::
++ stop-threads ++ stop-threads
|= threads=(list term) |= threads=(list term)
@ -81,6 +91,29 @@
^- form:m ^- form:m
(pure:m ~) (pure:m ~)
:: ::
:: XX +spawn-aqua and +breach-aqua mean do these actions using aqua's internal
:: azimuth management system, eventually these should just replace +spawn
:: +breach
::
++ init-azimuth
=/ m (strand ,~)
^- form:m
(send-azimuth-action %init-azimuth ~)
::
++ spawn-aqua
|= =ship
~& > "spawning {<ship>}"
=/ m (strand ,~)
^- form:m
(send-azimuth-action %spawn ship)
::
++ breach-aqua
|= =ship
~& > "breaching {<ship>}"
=/ m (strand ,~)
^- form:m
(send-azimuth-action %breach ship)
::
++ spawn ++ spawn
|= [=tid:spider =ship] |= [=tid:spider =ship]
~& > "spawning {<ship>}" ~& > "spawning {<ship>}"
@ -127,6 +160,39 @@
(pure:m ~) (pure:m ~)
loop loop
:: ::
++ breach-and-hear-aqua
|= [who=ship her=ship]
=/ m (strand ,~)
;< =bowl:spider bind:m get-bowl
=/ aqua-pax
:- %i
/(scot %p her)/j/(scot %p her)/rift/(scot %da now.bowl)/(scot %p who)/noun
=/ old-rut ;;((unit @) (scry-aqua:util noun our.bowl now.bowl aqua-pax))
=/ new-rut
?~ old-rut
1
+(+.old-rut)
;< ~ bind:m (send-azimuth-action %breach who)
|- ^- form:m
=* loop $
;< ~ bind:m (sleep ~s1)
;< =bowl:spider bind:m get-bowl
=/ aqua-pax
:- %i
/(scot %p her)/j/(scot %p her)/rift/(scot %da now.bowl)/(scot %p who)/noun
=/ rut (scry-aqua:util noun our.bowl now.bowl aqua-pax)
?: =([~ new-rut] rut)
(pure:m ~)
loop
::
++ init-ship
|= =ship
=/ m (strand ,~)
^- form:m
~& > "starting {<ship>}"
;< ~ bind:m (send-events (init:util ship `*dawn-event:able:jael))
(check-ship-booted ship)
::
++ real-ship ++ real-ship
|= [=tid:spider =ship] |= [=tid:spider =ship]
~& > "booting real {<ship>}" ~& > "booting real {<ship>}"

View File

@ -49,13 +49,21 @@
inner-state=vase inner-state=vase
== ==
:: ::
+$ base-state-1
$: base-state-0
failed-kicks=(map resource ship)
==
::
+$ state-0 [%0 base-state-0] +$ state-0 [%0 base-state-0]
:: ::
+$ state-1 [%1 base-state-0] +$ state-1 [%1 base-state-0]
:: ::
+$ state-2 [%2 base-state-1]
::
+$ versioned-state +$ versioned-state
$% state-0 $% state-0
state-1 state-1
state-2
== ==
:: ::
++ default ++ default
@ -141,7 +149,7 @@
++ agent ++ agent
|* =config |* =config
|= =(pull-hook config) |= =(pull-hook config)
=| state-1 =| state-2
=* state - =* state -
^- agent:gall ^- agent:gall
=< =<
@ -150,11 +158,13 @@
og ~(. pull-hook bowl) og ~(. pull-hook bowl)
hc ~(. +> bowl) hc ~(. +> bowl)
def ~(. (default-agent this %|) bowl) def ~(. (default-agent this %|) bowl)
::
++ on-init ++ on-init
^- [(list card:agent:gall) agent:gall] ^- [(list card:agent:gall) agent:gall]
=^ cards pull-hook =^ cards pull-hook
on-init:og on-init:og
[cards this] [cards this]
::
++ on-load ++ on-load
|= =old=vase |= =old=vase
=/ old =/ old
@ -162,10 +172,16 @@
=| cards=(list card:agent:gall) =| cards=(list card:agent:gall)
|^ |^
?- -.old ?- -.old
%1 %2
=^ og-cards pull-hook =^ og-cards pull-hook
(on-load:og inner-state.old) (on-load:og inner-state.old)
[(weld cards og-cards) this(state old)] =. state old
=^ retry-cards state
retry-failed-kicks
:_ this
:(weld cards og-cards retry-cards)
::
%1 $(old [%2 +.old ~])
:: ::
%0 %0
%_ $ %_ $
@ -175,6 +191,22 @@
(weld cards (missing-subscriptions tracking.old)) (weld cards (missing-subscriptions tracking.old))
== ==
== ==
::
++ retry-failed-kicks
=| acc-cards=(list card)
=/ failures=(list [rid=resource =ship])
~(tap by failed-kicks)
=. tracking
(~(uni by tracking) failed-kicks)
=. failed-kicks ~
|- ^- (quip card _state)
?~ failures
[acc-cards state]
=, failures
=^ crds state
(handle-kick:hc i)
$(failures t, acc-cards (weld acc-cards crds))
::
++ missing-subscriptions ++ missing-subscriptions
|= tracking=(map resource ship) |= tracking=(map resource ship)
^- (list card:agent:gall) ^- (list card:agent:gall)
@ -232,15 +264,9 @@
(de-path:resource t.t.t.t.wire) (de-path:resource t.t.t.t.wire)
?+ -.sign (on-agent:def wire sign) ?+ -.sign (on-agent:def wire sign)
%kick %kick
=/ pax=(unit path) =^ cards state
(on-pull-kick:og rid) (handle-kick:hc rid src.bowl)
?^ pax [cards this]
:_ this
~[(watch-resource:hc rid u.pax)]
=. tracking
(~(del by tracking) rid)
:_ this
~[give-update]
:: ::
%watch-ack %watch-ack
?~ p.sign ?~ p.sign
@ -287,6 +313,59 @@
|_ =bowl:gall |_ =bowl:gall
+* og ~(. pull-hook bowl) +* og ~(. pull-hook bowl)
:: ::
++ mule-scry
|= [ref=* raw=*]
=/ pax=(unit path)
((soft path) raw)
?~ pax ~
?. ?=([@ @ @ @ *] u.pax) ~
=/ ship
(slaw %p i.t.u.pax)
=/ ved
(slay i.t.t.t.u.pax)
=/ dat
?~ ved now.bowl
=/ cas=(unit case)
((soft case) p.u.ved)
?~ cas now.bowl
?: ?=(%da -.u.cas)
p.u.cas
now.bowl
:: catch bad gall scries early
?: ?& =((end 3 1 i.u.pax) %g)
?| !=(`our.bowl ship)
!=(dat now.bowl)
==
==
~
``.^(* u.pax)
++ handle-kick
|= [rid=resource =ship]
^- (quip card _state)
=/ res=toon
(mock [|.((on-pull-kick:og rid)) %9 2 %0 1] mule-scry)
=/ pax=(unit path)
!< (unit path)
:- -:!>(*(unit path))
?:(?=(%0 -.res) p.res ~)
=? failed-kicks !?=(%0 -.res)
=/ tang
:+ leaf+"failed kick handler, please report"
leaf+"{<rid>} in {(trip dap.bowl)}"
?: ?=(%2 -.res)
p.res
?> ?=(%1 -.res)
(turn `(list *)`p.res (cork path smyt))
%- (slog tang)
(~(put by failed-kicks) rid ship)
?^ pax
:_ state
(watch-resource rid u.pax)
=. tracking
(~(del by tracking) rid)
:_ state
~[give-update]
::
++ poke-hook-action ++ poke-hook-action
|= =action |= =action
^- [(list card:agent:gall) _state] ^- [(list card:agent:gall) _state]
@ -304,33 +383,35 @@
=. tracking =. tracking
(~(put by tracking) resource ship) (~(put by tracking) resource ship)
:_ state :_ state
~[(watch-resource resource /)] (watch-resource resource /)
:: ::
++ remove ++ remove
|= =resource |= =resource
:- ~[(leave-resource resource)] :- (leave-resource resource)
state(tracking (~(del by tracking) resource)) state(tracking (~(del by tracking) resource))
-- --
:: ::
++ leave-resource ++ leave-resource
|= rid=resource |= rid=resource
^- card ^- (list card)
=/ =ship =/ ship=(unit ship)
(~(got by tracking) rid) (~(get by tracking) rid)
?~ ship ~
=/ =wire =/ =wire
(make-wire pull+resource+(en-path:resource rid)) (make-wire pull+resource+(en-path:resource rid))
[%pass wire %agent [ship push-hook-name.config] %leave ~] [%pass wire %agent [u.ship push-hook-name.config] %leave ~]~
++ watch-resource ++ watch-resource
|= [rid=resource pax=path] |= [rid=resource pax=path]
^- card ^- (list card)
=/ =ship =/ ship=(unit ship)
(~(got by tracking) rid) (~(get by tracking) rid)
?~ ship ~
=/ =path =/ =path
(welp resource+(en-path:resource rid) pax) (welp resource+(en-path:resource rid) pax)
=/ =wire =/ =wire
(make-wire pull+path) (make-wire pull+path)
[%pass wire %agent [ship push-hook-name.config] %watch path] [%pass wire %agent [u.ship push-hook-name.config] %watch path]~
:: ::
++ make-wire ++ make-wire
|= =wire |= =wire

View File

@ -219,6 +219,25 @@
;< ~ bind:m (send-raw-card card) ;< ~ bind:m (send-raw-card card)
(take-poke-ack /poke) (take-poke-ack /poke)
:: ::
++ raw-poke
|= [=dock =cage]
=/ m (strand ,~)
^- form:m
=/ =card:agent:gall [%pass /poke %agent dock %poke cage]
;< ~ bind:m (send-raw-card card)
=/ m (strand ,~)
^- form:m
|= tin=strand-input:strand
?+ in.tin `[%skip ~]
~
`[%wait ~]
::
[~ %agent * %poke-ack *]
?. =(/poke wire.u.in.tin)
`[%skip ~]
`[%done ~]
==
::
++ poke-our ++ poke-our
|= [=term =cage] |= [=term =cage]
=/ m (strand ,~) =/ m (strand ,~)
@ -654,7 +673,8 @@
=/ m (strand ,tid:spider) =/ m (strand ,tid:spider)
^- form:m ^- form:m
;< =bowl:spider bind:m get-bowl ;< =bowl:spider bind:m get-bowl
=/ tid (scot %ta (cat 3 'strand_' (scot %uv (sham file eny.bowl)))) =/ tid
(scot %ta (cat 3 (cat 3 'strand_' file) (scot %uv (sham file eny.bowl))))
=/ poke-vase !>([`tid.bowl `tid file *vase]) =/ poke-vase !>([`tid.bowl `tid file *vase])
;< ~ bind:m (poke-our %spider %spider-start poke-vase) ;< ~ bind:m (poke-our %spider %spider-start poke-vase)
;< ~ bind:m (sleep ~s0) :: wait for thread to start ;< ~ bind:m (sleep ~s0) :: wait for thread to start

View File

@ -5,8 +5,9 @@
++ noun i ++ noun i
++ notification-kind ++ notification-kind
?+ index.p.i ~ ?+ index.p.i ~
[@ ~] `[%link 0] [@ ~] `[%link 0]
[@ @ @ ~] `[%comment 1] [@ @ %1 ~] `[%comment 1]
[@ @ @ ~] `[%edit-comment 1]
== ==
-- --
++ grab ++ grab

View File

@ -8,8 +8,10 @@
:: ::
++ notification-kind ++ notification-kind
?+ index.p.i ~ ?+ index.p.i ~
[@ %1 @ ~] `[%note 0] [@ %1 %1 ~] `[%note 0]
[@ %2 @ @ ~] `[%comment 1] [@ %1 @ ~] `[%edit-note 0]
[@ %2 @ %1 ~] `[%comment 1]
[@ %2 @ @ ~] `[%edit-comment 1]
== ==
-- --
++ grab ++ grab

View File

@ -13,6 +13,12 @@
/+ pill /+ pill
=, pill-lib=pill =, pill-lib=pill
|% |%
+$ az-log [topics=(lest @) data=@t]
+$ az-state
$: logs=(list az-log)
lives=(map ship [lyfe=life rut=rift])
tym=@da
==
++ ph-event ++ ph-event
$% [%test-done p=?] $% [%test-done p=?]
aqua-event aqua-event
@ -29,6 +35,12 @@
[%event who=ship ue=unix-event] [%event who=ship ue=unix-event]
== ==
:: ::
+$ azimuth-action
$% [%init-azimuth ~]
[%spawn who=ship]
[%breach who=ship]
==
::
+$ aqua-effects +$ aqua-effects
[who=ship ufs=(list unix-effect)] [who=ship ufs=(list unix-effect)]
:: ::
@ -57,6 +69,7 @@
[%ergo p=@tas q=mode:clay] [%ergo p=@tas q=mode:clay]
[%sleep ~] [%sleep ~]
[%restore ~] [%restore ~]
[%kill ~]
[%init ~] [%init ~]
[%request id=@ud request=request:http] [%request id=@ud request=request:http]
== ==

View File

@ -85,7 +85,7 @@
-- --
-- --
:: ::
%+ aqua-vane-thread ~[%sleep %restore %doze] %+ aqua-vane-thread ~[%sleep %restore %doze %kill]
|_ =bowl:spider |_ =bowl:spider
+* this . +* this .
++ handle-unix-effect ++ handle-unix-effect
@ -96,6 +96,7 @@
%sleep abet-pe:handle-sleep:(pe bowl who) %sleep abet-pe:handle-sleep:(pe bowl who)
%restore abet-pe:handle-restore:(pe bowl who) %restore abet-pe:handle-restore:(pe bowl who)
%doze abet-pe:(handle-doze:(pe bowl who) ue) %doze abet-pe:(handle-doze:(pe bowl who) ue)
%kill `(~(del by piers) who)
== ==
[cards this] [cards this]
:: ::

View File

@ -26,6 +26,7 @@
%sag ~& [%save-jamfile-to p.b] line %sag ~& [%save-jamfile-to p.b] line
%sav ~& [%save-file-to p.b] line %sav ~& [%save-file-to p.b] line
%url ~& [%activate-url p.b] line %url ~& [%activate-url p.b] line
%klr ~& %unhandled-case-klr ""
== ==
~? !=(~ last-line) last-line ~? !=(~ last-line) last-line
~ ~

View File

@ -100,7 +100,7 @@
-- --
-- --
:: ::
%+ aqua-vane-thread ~[%sleep %restore %thus] %+ aqua-vane-thread ~[%sleep %restore %thus %kill]
|_ =bowl:spider |_ =bowl:spider
+* this . +* this .
++ handle-unix-effect ++ handle-unix-effect
@ -111,6 +111,7 @@
%sleep abet-pe:handle-sleep:(pe bowl who) %sleep abet-pe:handle-sleep:(pe bowl who)
%restore abet-pe:handle-restore:(pe bowl who) %restore abet-pe:handle-restore:(pe bowl who)
%thus abet-pe:(handle-thus:(pe bowl who) ue) %thus abet-pe:(handle-thus:(pe bowl who) ue)
%kill `(~(del by piers) who)
== ==
[cards this] [cards this]
:: ::

View File

@ -0,0 +1,94 @@
/- spider, grp=group-store, gra=graph-store, met=metadata-store, con=contact-store
/+ strandio, res=resource
::
=* strand strand:spider
=* raw-poke raw-poke:strandio
=* scry scry:strandio
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<([=update:grp ~] arg)
?. ?=(%remove-group -.update)
(pure:m !>(~))
;< =bowl:spider bind:m get-bowl:strandio
:: tell group host to remove us as member
::
;< ~ bind:m
%+ raw-poke
[entity.resource.update %group-push-hook]
:- %group-update
!> ^- update:grp
[%remove-members resource.update (silt [our.bowl ~])]
:: stop serving or syncing group updates
::
;< ~ bind:m
%+ raw-poke
[our.bowl %group-push-hook]
:- %push-hook-action
!>([%remove resource.update])
;< ~ bind:m
%+ raw-poke
[our.bowl %group-pull-hook]
:- %pull-hook-action
!>([%remove resource.update])
:: stop serving or syncing contacts associated with group
::
;< ~ bind:m
%+ raw-poke
[our.bowl %contact-hook]
:- %contact-hook-action
!>([%remove (en-path:res resource.update)])
:: remove contact data associated with group
::
;< ~ bind:m
%+ raw-poke
[our.bowl %contact-store]
:- %contact-action
!> ^- contact-action:con
[%delete (en-path:res resource.update)]
:: stop serving or syncing metadata associated with group
::
;< ~ bind:m
%+ raw-poke
[our.bowl %metadata-hook]
:- %metadata-hook-action
!>([%remove (en-path:res resource.update)])
:: get metadata associated with group
::
;< =associations:met bind:m
%+ scry associations:met
;: weld
/gx/metadata-store/group
(en-path:res resource.update)
/noun
==
=/ entries=(list [g=group-path:met m=md-resource:met])
~(tap in ~(key by associations))
|- ^- form:m
=* loop $
?~ entries
(pure:m !>(~))
:: remove metadata associated with group
::
;< ~ bind:m
%+ raw-poke
[our.bowl %metadata-store]
:- %metadata-action
!> ^- metadata-action:met
[%remove g.i.entries m.i.entries]
:: archive graph associated with group
::
;< ~ bind:m
%+ raw-poke
[our.bowl %graph-store]
:- %graph-update
!> ^- update:gra
[%0 now.bowl [%archive-graph (de-path:res app-path.m.i.entries)]]
;< ~ bind:m
%+ raw-poke
[our.bowl %graph-pull-hook]
:- %pull-hook-action
!>([%remove (de-path:res app-path.m.i.entries)])
loop(entries t.entries)

View File

@ -0,0 +1,23 @@
/- spider, grp=group-store
/+ strandio, res=resource
::
=* strand strand:spider
=* raw-poke raw-poke:strandio
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<([=update:grp ~] arg)
?. ?=(%remove-members -.update)
(pure:m !>(~))
;< =bowl:spider bind:m get-bowl:strandio
?. (~(has in ships.update) our.bowl)
(pure:m !>(~))
;< ~ bind:m
%+ raw-poke
[our.bowl %group-store]
:- %group-action
!> ^- action:grp
[%remove-group resource.update ~]
(pure:m !>(~))

View File

@ -0,0 +1,19 @@
/- spider
/+ *ph-io, *ph-util
=, strand=strand:spider
^- thread:spider
|= vase
=/ m (strand ,vase)
;< =bowl:spider bind:m get-bowl
;< ~ bind:m start-simple
;< ~ bind:m init-azimuth
;< ~ bind:m (spawn-aqua ~bud)
;< ~ bind:m (spawn-aqua ~dev)
;< ~ bind:m (init-ship ~bud)
;< ~ bind:m (init-ship ~dev)
;< ~ bind:m (send-hi ~bud ~dev)
;< ~ bind:m (breach-and-hear-aqua ~dev ~bud)
;< ~ bind:m (send-hi-not-responding ~bud ~dev)
;< ~ bind:m (init-ship ~dev)
;< ~ bind:m (wait-for-output ~bud "hi ~dev successful")
(pure:m *vase)

View File

@ -0,0 +1,13 @@
/- spider
/+ *ph-io, *ph-util
=, strand=strand:spider
^- thread:spider
|= vase
=/ m (strand ,vase)
;< =bowl:spider bind:m get-bowl
;< ~ bind:m start-simple
|-
=* loop $
~& >> %looping
;< ~ bind:m (sleep ~s5)
loop

View File

@ -1693,9 +1693,9 @@
"integrity": "sha512-3OPSdf9cejP/TSzWXuBaYbzLtAfBzQnc75SlPLkoPfwpxnv1Bvy9hiWngLY0WnKRR6lMOldnkYQCCuNWeDibYQ==" "integrity": "sha512-3OPSdf9cejP/TSzWXuBaYbzLtAfBzQnc75SlPLkoPfwpxnv1Bvy9hiWngLY0WnKRR6lMOldnkYQCCuNWeDibYQ=="
}, },
"@tlon/indigo-react": { "@tlon/indigo-react": {
"version": "1.2.13", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@tlon/indigo-react/-/indigo-react-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@tlon/indigo-react/-/indigo-react-1.2.5.tgz",
"integrity": "sha512-6qYLjVcGZtDjI+BqS2PRrfAh9mUCDtYwDOHuYuPyV87mdVRAhduBlQ/3tDVlTNWICF9DeAhozeClxalACs5Ipw==", "integrity": "sha512-NOQTwH74l/XXMIfQ4ZzymvZuk1WK1nmO552TmXrQxBUSb7HmdlA8anG5oRrvnLJTkajLCY59McLkDca+lCcvwg==",
"requires": { "requires": {
"@reach/menu-button": "^0.10.5", "@reach/menu-button": "^0.10.5",
"react": "^16.13.1", "react": "^16.13.1",

View File

@ -9,7 +9,7 @@
"@reach/menu-button": "^0.10.5", "@reach/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5", "@reach/tabs": "^0.10.5",
"@tlon/indigo-light": "^1.0.3", "@tlon/indigo-light": "^1.0.3",
"@tlon/indigo-react": "1.2.13", "@tlon/indigo-react": "1.2.15",
"@tlon/sigil-js": "^1.4.2", "@tlon/sigil-js": "^1.4.2",
"aws-sdk": "^2.726.0", "aws-sdk": "^2.726.0",
"big-integer": "^1.6.48", "big-integer": "^1.6.48",

View File

@ -11,7 +11,7 @@ export default class BaseApi<S extends object = {}> {
this.channel.unsubscribe(id); this.channel.unsubscribe(id);
} }
subscribe(path: Path, method, ship = this.ship, app: string, success, fail, quit) { subscribe(path: Path, method, ship = this.ship, app: string, success, fail, quit, queue = false) {
this.bindPaths = _.uniq([...this.bindPaths, path]); this.bindPaths = _.uniq([...this.bindPaths, path]);
return this.channel.subscribe( return this.channel.subscribe(
@ -32,7 +32,9 @@ export default class BaseApi<S extends object = {}> {
}, },
(qui) => { (qui) => {
quit(qui); quit(qui);
} },
() => {},
queue
); );
} }

View File

@ -32,10 +32,6 @@ export default class ContactsApi extends BaseApi<StoreState> {
}); });
} }
delete(path: Path) {
return this.viewAction({ delete: { path } });
}
remove(path: Path, ship: Patp) { remove(path: Path, ship: Patp) {
return this.viewAction({ remove: { path, ship } }); return this.viewAction({ remove: { path, ship } });
} }

View File

@ -26,6 +26,10 @@ export default class GroupsApi extends BaseApi<StoreState> {
return this.proxyAction({ addMembers: { resource, ships } }); return this.proxyAction({ addMembers: { resource, ships } });
} }
removeGroup(resource: Resource) {
return this.storeAction({ removeGroup: { resource } });
}
changePolicy(resource: Resource, diff: Enc<GroupPolicyDiff>) { changePolicy(resource: Resource, diff: Enc<GroupPolicyDiff>) {
return this.proxyAction({ changePolicy: { resource, diff } }); return this.proxyAction({ changePolicy: { resource, diff } });
} }
@ -35,6 +39,7 @@ export default class GroupsApi extends BaseApi<StoreState> {
} }
private storeAction(action: GroupAction) { private storeAction(action: GroupAction) {
return this.action('group-store', 'group-action', action); console.log(action);
return this.action('group-store', 'group-update', action);
} }
} }

View File

@ -18,8 +18,8 @@ export default class LaunchApi extends BaseApi<StoreState> {
return this.launchAction({ 'change-is-shown': { name, isShown }}); return this.launchAction({ 'change-is-shown': { name, isShown }});
} }
weather(latlng: any) { weather(location: string) {
return this.action('weather', 'json', latlng); return this.action('weather', 'json', location);
} }
private launchAction(data) { private launchAction(data) {

View File

@ -106,13 +106,13 @@ export function getLatestCommentRevision(node: GraphNode): [number, Post] {
export function getComments(node: GraphNode): GraphNode { export function getComments(node: GraphNode): GraphNode {
const comments = node.children.get(bigInt(2)); const comments = node.children.get(bigInt(2));
if(!comments) { if(!comments) {
return { post: buntPost(), children: new BigIntOrderedMap() } return { post: buntPost(), children: new BigIntOrderedMap() }
} }
return comments; return comments;
} }
export function getSnippet(body: string) { export function getSnippet(body: string) {
const start = body.slice(0, 400); const start = body.slice(0, body.indexOf('\n', 2));
return start === body ? start : `${start}...`; return (start === body || start.startsWith("![")) ? start : `${start}...`;
} }

View File

@ -9,6 +9,10 @@ export default class BaseSubscription<S extends object> {
this.channel.setOnChannelOpen(this.onChannelOpen.bind(this)); this.channel.setOnChannelOpen(this.onChannelOpen.bind(this));
} }
clearQueue() {
this.channel.clearQueue();
}
delete() { delete() {
this.channel.delete(); this.channel.delete();
} }

View File

@ -43,13 +43,16 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
}; };
start() { start() {
this.subscribe('/all', 'invite-store');
this.subscribe('/groups', 'group-store');
this.subscribe('/primary', 'contact-view');
this.subscribe('/all', 'metadata-store'); this.subscribe('/all', 'metadata-store');
this.subscribe('/all', 's3-store'); this.subscribe('/all', 'invite-store');
this.subscribe('/all', 'launch'); this.subscribe('/all', 'launch');
this.subscribe('/all', 'weather'); this.subscribe('/all', 'weather');
this.subscribe('/groups', 'group-store');
this.clearQueue();
this.subscribe('/primary', 'contact-view');
this.subscribe('/all', 's3-store');
this.subscribe('/keys', 'graph-store'); this.subscribe('/keys', 'graph-store');
this.subscribe('/updates', 'hark-store'); this.subscribe('/updates', 'hark-store');
this.subscribe('/updates', 'hark-graph-hook'); this.subscribe('/updates', 'hark-graph-hook');

View File

@ -14,6 +14,8 @@ import './css/fonts.css';
import light from './themes/light'; import light from './themes/light';
import dark from './themes/old-dark'; import dark from './themes/old-dark';
import { Text, Anchor, Row } from '@tlon/indigo-react';
import { Content } from './landscape/components/Content'; import { Content } from './landscape/components/Content';
import StatusBar from './components/StatusBar'; import StatusBar from './components/StatusBar';
import Omnibox from './components/leap/Omnibox'; import Omnibox from './components/leap/Omnibox';
@ -25,6 +27,7 @@ import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { foregroundFromBackground } from '~/logic/lib/sigil'; import { foregroundFromBackground } from '~/logic/lib/sigil';
const Root = styled.div` const Root = styled.div`
font-family: ${p => p.theme.fonts.sans}; font-family: ${p => p.theme.fonts.sans};
height: 100%; height: 100%;
@ -128,6 +131,9 @@ class App extends React.Component {
const notificationsCount = state.notificationsCount || 0; const notificationsCount = state.notificationsCount || 0;
const doNotDisturb = state.doNotDisturb || false; const doNotDisturb = state.doNotDisturb || false;
const showBanner = localStorage.getItem("2020BreachBanner") || "flex";
let banner = null;
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Helmet> <Helmet>
@ -136,6 +142,21 @@ class App extends React.Component {
: null} : null}
</Helmet> </Helmet>
<Root background={background}> <Root background={background}>
<Row
ref={e => banner = e}
display={showBanner}
justifyContent="space-between"
width='100%'
p='2'
backgroundColor='yellow'>
<Text color='#000000'>
A network-wide breach is scheduled for early December 2020. Please visit <Anchor target="_blank" href="https://urbit.org/breach" color='inherit'>urbit.org/breach</Anchor> for more information.
</Text>
<Text cursor='pointer' fontWeight='500' onClick={() => {
banner.style.display = "none";
localStorage.setItem("2020BreachBanner", "none");
}}>Dismiss</Text>
</Row>
<Router> <Router>
<ErrorBoundary> <ErrorBoundary>
<StatusBarWithRouter <StatusBarWithRouter

View File

@ -3,7 +3,7 @@ import moment from "moment";
import _ from "lodash"; import _ from "lodash";
import { Box, Row, Text, Rule } from "@tlon/indigo-react"; import { Box, Row, Text, Rule } from "@tlon/indigo-react";
import { OverlaySigil } from './overlay-sigil'; import OverlaySigil from '~/views/components/OverlaySigil';
import { uxToHex, cite, writeText } from '~/logic/lib/util'; import { uxToHex, cite, writeText } from '~/logic/lib/util';
import { Envelope, IMessage } from "~/types/chat-update"; import { Envelope, IMessage } from "~/types/chat-update";
import { Group, Association, Contacts, LocalUpdateRemoteContentPolicy } from "~/types"; import { Group, Association, Contacts, LocalUpdateRemoteContentPolicy } from "~/types";
@ -23,9 +23,9 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
)); ));
export const DayBreak = ({ when }) => ( export const DayBreak = ({ when }) => (
<div className="pv3 gray2 b--gray2 flex items-center justify-center f9 w-100"> <Row pb='3' alignItems="center" justifyContent="center" width='100%'>
<p>{moment(when).calendar(null, { sameElse: DATESTAMP_FORMAT })}</p> <Text gray>{moment(when).calendar(null, { sameElse: DATESTAMP_FORMAT })}</Text>
</div> </Row>
); );
interface ChatMessageProps { interface ChatMessageProps {
@ -191,7 +191,7 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
} = this.props; } = this.props;
const datestamp = moment.unix(msg.when / 1000).format(DATESTAMP_FORMAT); const datestamp = moment.unix(msg.when / 1000).format(DATESTAMP_FORMAT);
const contact = msg.author in contacts ? contacts[msg.author] : false; const contact = msg.author in contacts ? contacts[msg.author] : undefined;
const showNickname = !hideNicknames && contact && contact.nickname; const showNickname = !hideNicknames && contact && contact.nickname;
const name = showNickname ? contact.nickname : cite(msg.author); const name = showNickname ? contact.nickname : cite(msg.author);
const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF' const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF'
@ -215,16 +215,16 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
contact={contact} contact={contact}
color={color} color={color}
sigilClass={sigilClass} sigilClass={sigilClass}
association={association}
group={group} group={group}
hideAvatars={hideAvatars} hideAvatars={hideAvatars}
hideNicknames={hideNicknames} hideNicknames={hideNicknames}
scrollWindow={scrollWindow} scrollWindow={scrollWindow}
history={history} history={history}
api={api} api={api}
bg="white"
className="fl pr3 v-top pt1" className="fl pr3 v-top pt1"
/> />
<Box flexGrow='1' display='block' className="clamp-message"> <Box flexGrow={1} display='block' className="clamp-message">
<Box <Box
className="hide-child" className="hide-child"
pt={1} pt={1}
@ -240,12 +240,12 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
className={`mw5 db truncate pointer`} className={`mw5 db truncate pointer`}
ref={e => nameSpan = e} ref={e => nameSpan = e}
onClick={() => { onClick={() => {
writeText(msg.author); writeText(`~${msg.author}`);
copyNotice(name); copyNotice(name);
}} }}
title={`~${msg.author}`} title={`~${msg.author}`}
>{name}</Text> >{name}</Text>
<Text flexShrink='0' gray mono className="v-mid">{timestamp}</Text> <Text flexShrink={0} gray mono className="v-mid">{timestamp}</Text>
<Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text> <Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
</Box> </Box>
<Box fontSize={fontSize ? fontSize : '14px'}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} fontSize={fontSize} /></Box> <Box fontSize={fontSize ? fontSize : '14px'}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} fontSize={fontSize} /></Box>
@ -279,6 +279,11 @@ export const MessageContent = ({ content, remoteContentPolicy, measure, fontSize
}}} }}}
videoProps={{style: { videoProps={{style: {
maxWidth: '18rem' maxWidth: '18rem'
}
}}
textProps={{style: {
fontSize: 'inherit',
textDecoration: 'underline'
}}} }}}
/> />
</Text> </Text>

View File

@ -182,6 +182,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
if (this.props.unreadCount === 0) return; if (this.props.unreadCount === 0) return;
this.props.api.chat.read(this.props.station); this.props.api.chat.read(this.props.station);
this.props.api.hark.readIndex({ chat: { chat: this.props.station, mention: false }}); this.props.api.hark.readIndex({ chat: { chat: this.props.station, mention: false }});
this.props.api.hark.readIndex({ chat: { chat: this.props.station, mention: true }});
} }
fetchMessages(start, end, force = false): Promise<void> { fetchMessages(start, end, force = false): Promise<void> {

View File

@ -3,7 +3,7 @@ import { UnControlled as CodeEditor } from 'react-codemirror2';
import { MOBILE_BROWSER_REGEX } from "~/logic/lib/util"; import { MOBILE_BROWSER_REGEX } from "~/logic/lib/util";
import CodeMirror from 'codemirror'; import CodeMirror from 'codemirror';
import { Row, BaseInput } from '@tlon/indigo-react'; import { Row, BaseTextArea } from '@tlon/indigo-react';
import 'codemirror/mode/markdown/markdown'; import 'codemirror/mode/markdown/markdown';
import 'codemirror/addon/display/placeholder'; import 'codemirror/addon/display/placeholder';
@ -167,9 +167,10 @@ export default class ChatEditor extends Component {
color="black" color="black"
> >
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) {MOBILE_BROWSER_REGEX.test(navigator.userAgent)
? <BaseInput ? <BaseTextArea
fontFamily={inCodeMode ? 'Source Code Pro' : 'Inter'} fontFamily={inCodeMode ? 'Source Code Pro' : 'Inter'}
fontSize="14px" fontSize="14px"
lineHeight="tall"
style={{ width: '100%', background: 'transparent', color: 'currentColor' }} style={{ width: '100%', background: 'transparent', color: 'currentColor' }}
placeholder={inCodeMode ? "Code..." : "Message..."} placeholder={inCodeMode ? "Code..." : "Message..."}
onKeyUp={event => { onKeyUp={event => {

View File

@ -1,100 +0,0 @@
import React, { PureComponent } from 'react';
import { Sigil } from '~/logic/lib/sigil';
import {
ProfileOverlay,
OVERLAY_HEIGHT
} from './profile-overlay';
import { Box, BaseImage } from '@tlon/indigo-react';
export class OverlaySigil extends PureComponent {
constructor() {
super();
this.state = {
clicked: false,
captured: false,
topSpace: 0,
bottomSpace: 0
};
this.containerRef = React.createRef();
this.profileShow = this.profileShow.bind(this);
this.profileHide = this.profileHide.bind(this);
this.updateContainerOffset = this.updateContainerOffset.bind(this);
this.updateContainerInterval = null;
}
profileShow() {
this.updateContainerOffset();
this.setState({ profileClicked: true });
this.props.scrollWindow.addEventListener('scroll', this.updateContainerOffset);
}
profileHide() {
this.setState({ profileClicked: false });
this.props.scrollWindow.removeEventListener('scroll', this.updateContainerOffset, true);
}
updateContainerOffset() {
if (this.containerRef && this.containerRef.current) {
const container = this.containerRef.current;
const scrollWindow = this.props.scrollWindow;
const bottomSpace = scrollWindow.scrollHeight - container.offsetTop - scrollWindow.scrollTop;
const topSpace = scrollWindow.offsetHeight - bottomSpace - OVERLAY_HEIGHT;
this.setState({
topSpace,
bottomSpace
});
}
}
componentWillUnmount() {
this.props.scrollWindow?.removeEventListener('scroll', this.updateContainerOffset, true);
}
render() {
const { props, state } = this;
const { hideAvatars } = props;
const img = (props.contact && (props.contact.avatar !== null) && !hideAvatars)
? <BaseImage display='inline-block' src={props.contact.avatar} height={16} width={16} />
: <Sigil
ship={props.ship}
size={16}
color={props.color}
classes={props.sigilClass}
icon
padded
/>;
return (
<Box
cursor='pointer'
position='relative'
onClick={this.profileShow}
className={props.className}
ref={this.containerRef}
>
{state.profileClicked && (
<ProfileOverlay
ship={props.ship}
contact={props.contact}
color={props.color}
topSpace={state.topSpace}
bottomSpace={state.bottomSpace}
association={props.association}
group={props.group}
onDismiss={this.profileHide}
hideAvatars={hideAvatars}
hideNicknames={props.hideNicknames}
history={props.history}
api={props.api}
/>
)}
{img}
</Box>
);
}
}

View File

@ -10,7 +10,10 @@ import Tiles from './components/tiles';
import Tile from './components/tiles/tile'; import Tile from './components/tiles/tile';
import Welcome from './components/welcome'; import Welcome from './components/welcome';
import Groups from './components/Groups'; import Groups from './components/Groups';
import ModalButton from './components/ModalButton';
import { writeText } from '~/logic/lib/util'; import { writeText } from '~/logic/lib/util';
import { NewGroup } from "~/views/landscape/components/NewGroup";
import { JoinGroup } from "~/views/landscape/components/JoinGroup";
const ScrollbarLessBox = styled(Box)` const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important; scrollbar-width: none !important;
@ -39,19 +42,18 @@ export default function LaunchApp(props) {
pt={0} pt={0}
> >
<Tile <Tile
bg="transparent" bg="white"
color="green" color="scales.black20"
to="/~landscape/home" to="/~landscape/home"
p={0} p={0}
> >
<Box p={2} height='100%' width='100%' bg='green'> <Box p={2} height='100%' width='100%' bg='scales.black20'>
<Row alignItems='center'> <Row alignItems='center'>
<Icon <Icon
color="white" color="black"
// fill="rgba(0,0,0,0)" icon="Mail"
icon="Boot"
/> />
<Text ml="1" mt='1px' color="white">DMs + Drafts</Text> <Text ml="1" mt='1px' color="black">DMs + Drafts</Text>
</Row> </Row>
</Box> </Box>
</Tile> </Tile>
@ -62,7 +64,23 @@ export default function LaunchApp(props) {
location={props.userLocation} location={props.userLocation}
weather={props.weather} weather={props.weather}
/> />
<Box display={["none", "block"]} width="100%" gridColumn="1 / -1"></Box> <ModalButton
icon="Plus"
bg="blue"
color="#fff"
text="Join a Group"
style={{ gridColumnStart: 1 }}
>
<JoinGroup {...props} />
</ModalButton>
<ModalButton
icon="CreateGroup"
bg="green"
color="#fff"
text="Create a Group"
>
<NewGroup {...props} />
</ModalButton>
<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} /> <Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />
</Box> </Box>
</ScrollbarLessBox> </ScrollbarLessBox>

View File

@ -34,7 +34,7 @@ export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
return ( return (
<> <>
{groups.map((group) => { {groups.map((group, index) => {
const path = group?.["group-path"]; const path = group?.["group-path"];
const unreadCount = (["chat", "graph"] as const) const unreadCount = (["chat", "graph"] as const)
.map(getUnreads(path)) .map(getUnreads(path))
@ -42,6 +42,7 @@ export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
.reduce(f.add, 0); .reduce(f.add, 0);
return ( return (
<Group <Group
first={index === 0}
unreads={unreadCount} unreads={unreadCount}
path={group?.["group-path"]} path={group?.["group-path"]}
title={group.metadata.title} title={group.metadata.title}
@ -56,11 +57,12 @@ interface GroupProps {
path: string; path: string;
title: string; title: string;
unreads: number; unreads: number;
first: boolean;
} }
function Group(props: GroupProps) { function Group(props: GroupProps) {
const { path, title, unreads } = props; const { path, title, unreads, first = false } = props;
return ( return (
<Tile to={`/~landscape${path}`}> <Tile to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Col height="100%" justifyContent="space-between"> <Col height="100%" justifyContent="space-between">
<Text>{title}</Text> <Text>{title}</Text>
{unreads > 0 && {unreads > 0 &&

View File

@ -0,0 +1,81 @@
import React, { useState, useEffect } from "react"
import { Box, Button, Icon, Text } from "@tlon/indigo-react"
import { NewGroup } from "~/views/landscape/components/NewGroup";
import { JoinGroup } from "~/views/landscape/components/JoinGroup";
const ModalButton = (props) => {
const {
childen,
icon,
text,
bg,
color,
...rest
} = props;
const [modalShown, setModalShown] = useState(false);
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setModalShown(false);
}
}
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [modalShown]);
return (
<>
{modalShown && (
<Box
backgroundColor='scales.black30'
left="0px"
top="0px"
width="100%"
height="100%"
zIndex={4}
position="fixed"
display="flex"
justifyContent="center"
alignItems="center"
onClick={() => setModalShown(false)}
>
<Box
maxWidth="500px"
width="100%"
bg="white"
borderRadius={2}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
onClick={e => e.stopPropagation()}
display="flex"
alignItems="stretch"
flexDirection="column"
>
{props.children}
</Box>
</Box>
)}
<Box
onClick={() => setModalShown(true)}
display="flex"
alignItems="center"
cursor="pointer"
bg={bg}
p={2}
borderRadius={2}
boxShadow="0 0 0px 1px inset"
color="scales.black20"
{...rest}
>
<Icon icon={props.icon} mr={2} color={color}></Icon><Text color={color}>{props.text}</Text>
</Box>
</>
);
}
export default ModalButton;

View File

@ -18,12 +18,13 @@ const SquareBox = styled(Box)`
position: absolute; position: absolute;
top: 0; top: 0;
} }
position: relative;
`; `;
const routeList = defaultApps.map(a => `/~${a}`); const routeList = defaultApps.map(a => `/~${a}`);
export default class Tile extends React.Component { export default class Tile extends React.Component {
render() { render() {
const { bg, to, href, p, boxShadow, ...props } = this.props; const { bg, to, href, p, boxShadow, gridColumnStart, ...props } = this.props;
let childElement = ( let childElement = (
<Box p={typeof p === 'undefined' ? 2 : p} width="100%" height="100%"> <Box p={typeof p === 'undefined' ? 2 : p} width="100%" height="100%">
@ -32,7 +33,7 @@ export default class Tile extends React.Component {
); );
if (to) { if (to) {
if (routeList.indexOf(to) !== -1 || to === '/~landscape/home' || to === '/~profile' || to.startsWith('/~landscape/ship')) { if (routeList.indexOf(to) !== -1 || to === '/~profile' || to.startsWith('/~landscape/')) {
childElement= (<Link to={to}>{childElement}</Link>); childElement= (<Link to={to}>{childElement}</Link>);
} else { } else {
childElement= (<a href={to}>{childElement}</a>); childElement= (<a href={to}>{childElement}</a>);
@ -48,6 +49,7 @@ export default class Tile extends React.Component {
bg={bg || "white"} bg={bg || "white"}
color={props?.color || 'scales.black20'} color={props?.color || 'scales.black20'}
boxShadow={boxShadow || '0 0 0px 1px inset'} boxShadow={boxShadow || '0 0 0px 1px inset'}
style={{ gridColumnStart }}
> >
<Box <Box
{...props} {...props}

View File

@ -1,14 +1,43 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Box, Icon, Text, BaseAnchor, BaseInput } from '@tlon/indigo-react'; import { Box, Icon, Text, BaseAnchor, BaseInput } from '@tlon/indigo-react';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import Tile from './tile'; import Tile from './tile';
export const weatherStyleMap = {
Sunny: 'rgba(67, 169, 255, 0.4)',
PartlyCloudy: 'rgba(178, 211, 255, 0.33)',
Cloudy: 'rgba(136, 153, 176, 0.43)',
VeryCloudy: 'rgba(78, 90, 106, 0.43)',
Fog: 'rgba(100, 119, 128, 0.12)',
LightShowers: 'rgba(121, 148, 185, 0.33)',
LightSleetShowers: 'rgba(114, 130, 153, 0.33)',
LightSleet: 'rgba(155, 164, 177, 0.33)',
ThunderyShowers: 'rgba(53, 77, 103, 0.33)',
LightSnow: 'rgba(179, 182, 200, 0.33)',
HeavySnow: 'rgba(179, 182, 200, 0.33)',
LightRain: 'rgba(58, 79, 107, 0.33)',
HeavyShowers: 'rgba(36, 54, 77, 0.33)',
HeavyRain: 'rgba(5, 9, 13, 0.39)',
LightSnowShowers: 'rgba(174, 184, 198, 0.33)',
HeavySnowShowers: 'rgba(55, 74, 107, 0.33)',
ThunderyHeavyRain: 'rgba(45, 56, 66, 0.61)',
ThunderySnowShowers: 'rgba(40, 54, 79, 0.46)',
default: 'transparent'
};
const imperialCountries = [
'United States of America',
'Myanmar',
'Liberia',
];
export default class WeatherTile extends React.Component { export default class WeatherTile extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
latlong: '', location: '',
manualEntry: false, manualEntry: false,
error: false error: false
}; };
@ -17,89 +46,45 @@ export default class WeatherTile extends React.Component {
// geolocation and manual input functions // geolocation and manual input functions
locationSubmit() { locationSubmit() {
navigator.geolocation.getCurrentPosition((res) => { navigator.geolocation.getCurrentPosition((res) => {
const latlng = `${res.coords.latitude},${res.coords.longitude}`; const location = `${res.coords.latitude},${res.coords.longitude}`;
this.setState({ this.setState({
latlng location
}, (err) => { }, (err) => {
console.log(err); console.log(err);
}, { maximumAge: Infinity, timeout: 10000 }); }, { maximumAge: Infinity, timeout: 10000 });
this.props.api.launch.weather(latlng); this.props.api.launch.weather(location);
this.setState({ manualEntry: !this.state.manualEntry }); this.setState({ manualEntry: !this.state.manualEntry });
}); });
} }
manualLocationSubmit() { manualLocationSubmit(event) {
event.preventDefault(); event.preventDefault();
const gpsInput = document.getElementById('gps'); const location = document.getElementById('location').value;
const latlngNoSpace = gpsInput.value.replace(/\s+/g, ''); this.setState({ location }, (err) => {
const latlngParse = /-?[0-9]+(?:\.[0-9]*)?,-?[0-9]+(?:\.[0-9]*)?/g;
if (latlngParse.test(latlngNoSpace)) {
const latlng = latlngNoSpace;
this.setState({ latlng }, (err) => {
console.log(err); console.log(err);
}, { maximumAge: Infinity, timeout: 10000 }); }, { maximumAge: Infinity, timeout: 10000 });
this.props.api.launch.weather(latlng); this.props.api.launch.weather(location);
this.setState({ manualEntry: !this.state.manualEntry }); this.setState({ manualEntry: !this.state.manualEntry });
} else {
this.setState({ error: true });
return false;
}
} }
// set appearance based on weather
setColors(data) {
let weatherStyle = {
bg: '',
text: ''
};
switch (data.currently.icon) { // set appearance based on weather
case 'clear-day': colorFromCondition(data) {
weatherStyle = { bg: '#E9F5FF', text: '#333' }; let weatherDesc = data['current-condition'][0].weatherDesc[0].value;
break; return weatherStyleMap[weatherDesc] || weatherStyleMap.default;
case 'clear-night':
weatherStyle = { bg: '#14263C', text: '#fff' };
break;
case 'rain':
weatherStyle = { bg: '#2E1611', text: '#fff' };
break;
case 'snow':
weatherStyle = { bg: '#F9F9FB', text: '#333' };
break;
case 'sleet':
weatherStyle = { bg: '#EFF1F3', text: '#333' };
break;
case 'wind':
weatherStyle = { bg: '#F7FEF6', text: '#333' };
break;
case 'fog':
weatherStyle = { bg: '#504D44', text: '#fff' };
break;
case 'cloudy':
weatherStyle = { bg: '#EEF1F5', text: '#333' };
break;
case 'partly-cloudy-day':
weatherStyle = { bg: '#F3F6FA', text: '#333' };
break;
case 'partly-cloudy-night':
weatherStyle = { bg: '#283442', text: '#fff' };
break;
default:
weatherStyle = { bg: 'white', text: 'black' };
}
return weatherStyle;
} }
// all tile views // all tile views
renderWrapper(child, renderWrapper(child, backgroundColor = 'white') {
weatherStyle = { bg: 'white', text: 'black' }
) {
return ( return (
<Tile bg={weatherStyle.bg}> <ErrorBoundary>
{child} <Tile bg='white' backgroundColor={backgroundColor}>
</Tile> {child}
</Tile>
</ErrorBoundary>
); );
} }
renderManualEntry() { renderManualEntry(data) {
let secureCheck; let secureCheck;
let error; let error;
if (this.state.error === true) { if (this.state.error === true) {
@ -114,6 +99,10 @@ export default class WeatherTile extends React.Component {
</Text> </Text>
); );
} }
let locationName;
if ('nearest-area' in data) {
locationName = data['nearest-area'][0].areaName[0].value;
}
return this.renderWrapper( return this.renderWrapper(
<Box <Box
display='flex' display='flex'
@ -132,21 +121,13 @@ export default class WeatherTile extends React.Component {
</Text> </Text>
{secureCheck} {secureCheck}
<Text pb={1} mb='auto'> <Text pb={1} mb='auto'>
Please enter your{' '} Please enter your location.
<BaseAnchor {locationName ? ` Current location is near ${locationName}.` : ''}
borderBottom='1px solid'
color='black'
href="https://latitudeandlongitude.org/"
target="_blank"
>
latitude and longitude
</BaseAnchor>
.
</Text> </Text>
{error} {error}
<Box mt='auto' display='flex' marginBlockEnd='0'> <Box mt='auto' display='flex' marginBlockEnd='0'>
<BaseInput <BaseInput
id="gps" id="location"
size="10" size="10"
width='100%' width='100%'
color='black' color='black'
@ -154,11 +135,11 @@ export default class WeatherTile extends React.Component {
backgroundColor='transparent' backgroundColor='transparent'
border='0' border='0'
type="text" type="text"
placeholder="29.55, -95.08" autoFocus
placeholder="GPS, ZIP, City"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); this.manualLocationSubmit(e);
this.manualLocationSubmit(e.target.value);
} }
}} }}
/> />
@ -171,7 +152,7 @@ export default class WeatherTile extends React.Component {
fontSize='0' fontSize='0'
border='0' border='0'
type="submit" type="submit"
onClick={() => this.manualLocationSubmit()} onClick={this.manualLocationSubmit.bind(this)}
value="->" value="->"
/> />
</Box> </Box>
@ -182,7 +163,6 @@ export default class WeatherTile extends React.Component {
renderNoData() { renderNoData() {
return this.renderWrapper( return this.renderWrapper(
<Box <Box
bg='white'
display='flex' display='flex'
flexDirection='column' flexDirection='column'
justifyContent='space-between' justifyContent='space-between'
@ -200,14 +180,16 @@ export default class WeatherTile extends React.Component {
); );
} }
renderWithData(data, weatherStyle) { renderWithData(data) {
const c = data.currently; const locationName = data['nearest-area'][0].areaName[0].value;
const d = data.daily.data[0]; const c = data['current-condition'][0];
const d = data['weather'][0];
const bg = this.colorFromCondition(data);
const sunset = moment.unix(d.sunsetTime); const sunset = moment(d.date + ' ' + d.astronomy[0].sunset, 'YYYY-MM-DD hh:mm A');
const sunsetDiff = sunset.diff(moment(), 'hours'); const sunsetDiff = sunset.diff(moment(), 'hours');
const sunrise = moment.unix(d.sunriseTime); const sunrise = moment(d.date + ' ' + d.astronomy[0].sunrise, 'YYYY-MM-DD hh:mm A');
let sunriseDiff = sunrise.diff(moment(), 'hours'); let sunriseDiff = sunrise.diff(moment(), 'hours');
if (sunriseDiff > 24) { if (sunriseDiff > 24) {
@ -220,6 +202,10 @@ export default class WeatherTile extends React.Component {
? `Sun sets in ${sunsetDiff}h` ? `Sun sets in ${sunsetDiff}h`
: `Sun rises in ${sunriseDiff}h`; : `Sun rises in ${sunriseDiff}h`;
const temp = data['nearest-area'] && imperialCountries.includes(data['nearest-area'][0].country[0].value)
? `${Math.round(c.temp_F)}`
: `${Math.round(c.temp_C)}`;
return this.renderWrapper( return this.renderWrapper(
<Box <Box
width='100%' width='100%'
@ -227,12 +213,12 @@ export default class WeatherTile extends React.Component {
display='flex' display='flex'
flexDirection='column' flexDirection='column'
alignItems='space-between' alignItems='space-between'
title={`${locationName} Weather`}
> >
<Text color={weatherStyle.text}> <Text>
<Icon icon='Weather' color={weatherStyle.text} display='inline' style={{ position: 'relative', top: '.3em' }} /> <Icon icon='Weather' display='inline' style={{ position: 'relative', top: '.3em' }} />
Weather Weather
<Text <Text
color={weatherStyle.text}
cursor='pointer' cursor='pointer'
onClick={() => onClick={() =>
this.setState({ manualEntry: !this.state.manualEntry }) this.setState({ manualEntry: !this.state.manualEntry })
@ -248,42 +234,56 @@ export default class WeatherTile extends React.Component {
display="flex" display="flex"
flexDirection="column" flexDirection="column"
> >
<Text color={weatherStyle.text}>{c.summary}</Text> <Text>{c.weatherDesc[0].value.replace(/([a-z])([A-Z])/g, '$1 $2')}</Text>
<Text color={weatherStyle.text}>{Math.round(c.temperature)}°</Text> <Text>{temp}</Text>
<Text color={weatherStyle.text}>{nextSolarEvent}</Text> <Text>{nextSolarEvent}</Text>
</Box> </Box>
</Box> </Box>, bg);
, weatherStyle);
} }
render() { render() {
const data = this.props.weather ? this.props.weather : {}; const data = this.props.weather ? this.props.weather : {};
if (this.state.manualEntry === true) { if (this.state.manualEntry === true) {
return this.renderManualEntry(); return this.renderManualEntry(data);
} }
if ('currently' in data && 'daily' in data) { if ('currently' in data) { // Old weather source
const weatherStyle = this.setColors(data); this.props.api.launch.weather(this.props.location);
return this.renderWithData(data, weatherStyle); }
if ('current-condition' in data && 'weather' in data) {
return this.renderWithData(data);
} }
if (this.props.location) { if (this.props.location) {
return this.renderWrapper(( return this.renderWrapper(
<Box <Box
p='2'
width='100%' width='100%'
height='100%' height='100%'
backgroundColor='white' backgroundColor='white'
color='black' color='black'
display="flex"
flexDirection="column"
justifyContent="flex-start"
> >
<Icon icon='Weather' color='black' display='inline' style={{ position: 'relative', top: '.3em' }} /> <Text><Icon icon='Weather' color='black' display='inline' style={{ position: 'relative', top: '.3em' }} /> Weather</Text>
<Text>Weather</Text> <Text width='100%' display='flex' flexDirection='column' mt={1}>
<Text pt='2' width='100%' display='flex' flexDirection='column'> Loading, please check again later...
Loading, please check again later... </Text>
<Text mt="auto">
Set new location{' '}
<Text
cursor='pointer'
onClick={() =>
this.setState({ manualEntry: !this.state.manualEntry })
}
>
->
</Text>
</Text> </Text>
</Box> </Box>
)); );
} }
return this.renderNoData(); return this.renderNoData();
} }

View File

@ -1,15 +1,14 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Box, Row, Col, Center, LoadingSpinner } from "@tlon/indigo-react"; import { Box, Row, Col, Center, LoadingSpinner, Text } from "@tlon/indigo-react";
import { Switch, Route, Link } from "react-router-dom"; import { Switch, Route, Link } from "react-router-dom";
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { StoreState } from "~/logic/store/type"; import { StoreState } from "~/logic/store/type";
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { Association, GraphNode } from "~/types";
import { RouteComponentProps } from "react-router-dom"; import { RouteComponentProps } from "react-router-dom";
import { LinkItem } from "./components/link-item"; import { LinkItem } from "./components/LinkItem";
import { LinkSubmit } from "./components/link-submit"; import { LinkSubmit } from "./components/link-submit";
import { LinkPreview } from "./components/link-preview"; import { LinkPreview } from "./components/link-preview";
import { Comments } from "~/views/components/comments"; import { Comments } from "~/views/components/comments";
@ -77,16 +76,18 @@ export function LinkResource(props: LinkResourceProps) {
const contact = contactDetails[node.post.author]; const contact = contactDetails[node.post.author];
return ( return (
<LinkItem <LinkItem
contacts={contacts}
key={date.toString()} key={date.toString()}
resource={resourcePath} resource={resourcePath}
node={node} node={node}
nickname={contact?.nickname}
hideAvatars={hideAvatars} hideAvatars={hideAvatars}
hideNicknames={hideNicknames} hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
baseUrl={resourceUrl} baseUrl={resourceUrl}
color={uxToHex(contact?.color || '0x0')}
group={group} group={group}
path={resource["group-path"]}
api={api} api={api}
mb={3}
/> />
); );
})} })}
@ -95,7 +96,7 @@ export function LinkResource(props: LinkResourceProps) {
}} }}
/> />
<Route <Route
path={relativePath("/:index/:commentId?")} path={relativePath("/:index(\\d+)/:commentId?")}
render={(props) => { render={(props) => {
const index = bigInt(props.match.params.index); const index = bigInt(props.match.params.index);
const editCommentId = props.match.params.commentId || null; const editCommentId = props.match.params.commentId || null;
@ -113,15 +114,21 @@ export function LinkResource(props: LinkResourceProps) {
const contact = contactDetails[node.post.author]; const contact = contactDetails[node.post.author];
return ( return (
<Col width="100%" p={3} maxWidth="640px"> <Col width="100%" p={3} maxWidth="768px">
<Link to={resourceUrl}>{"<- Back"}</Link> <Link to={resourceUrl}><Text bold>{"<- Back"}</Text></Link>
<LinkPreview <LinkItem
resourcePath={resourcePath} contacts={contacts}
post={node.post} key={node.post.index}
nickname={contact?.nickname} resource={resourcePath}
node={node}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames} hideNicknames={hideNicknames}
commentNumber={node.children.size}
remoteContentPolicy={remoteContentPolicy} remoteContentPolicy={remoteContentPolicy}
baseUrl={resourceUrl}
group={group}
path={resource["group-path"]}
api={api}
mt={3}
/> />
<Comments <Comments
ship={ship} ship={ship}
@ -136,6 +143,8 @@ export function LinkResource(props: LinkResourceProps) {
editCommentId={editCommentId} editCommentId={editCommentId}
history={props.history} history={props.history}
baseUrl={`${resourceUrl}/${props.match.params.index}`} baseUrl={`${resourceUrl}/${props.match.params.index}`}
association={association}
group={group}
/> />
</Col> </Col>
); );

View File

@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Row, Col, Anchor, Box, Text, BaseImage, Icon, Action } from '@tlon/indigo-react';
import { Sigil } from '~/logic/lib/sigil';
import { writeText } from '~/logic/lib/util';
import Author from '~/views/components/Author';
import { roleForShip } from '~/logic/lib/group';
import { Contacts, GraphNode, Group, LocalUpdateRemoteContentPolicy, Rolodex } from '~/types';
import GlobalApi from '~/logic/api/global';
import { Dropdown } from '~/views/components/Dropdown';
import RemoteContent from '~/views/components/RemoteContent';
interface LinkItemProps {
node: GraphNode;
resource: string;
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
api: GlobalApi;
group: Group;
path: string;
contacts: Rolodex[];
}
export const LinkItem = (props: LinkItemProps) => {
const {
node,
resource,
hideAvatars,
hideNicknames,
remoteContentPolicy,
api,
group,
path,
contacts,
...rest
} = props;
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);
const author = node.post.author;
const index = node.post.index.split('/')[1];
const size = node.children ? node.children.size : 0;
const contents = node.post.contents;
const hostname = URLparser.exec(contents[1].url) ? URLparser.exec(contents[1].url)[4] : null;
const baseUrl = props.baseUrl || `/~404/${resource}`;
const ourRole = group ? roleForShip(group, window.ship) : undefined;
const [ship, name] = resource.split('/');
const [locationText, setLocationText] = useState('Copy Link Location');
const copyLocation = () => {
setLocationText('Copied');
writeText(contents[1].url);
setTimeout(() => {
setLocationText('Copy Link Location');
}, 2000);
};
const deleteLink = () => {
if (confirm('Are you sure you want to delete this link?')) {
api.graph.removeNodes(`~${ship}`, name, [node.post.index]);
}
};
return (
<Box width="100%" {...rest}>
<Box
lineHeight="tall"
display='flex'
flexDirection='column'
width="100%"
color='washedGray'
border={1}
borderRadius={2}
alignItems="flex-start"
overflow="hidden"
>
<RemoteContent
url={contents[1].url}
text={contents[0].text}
remoteContentPolicy={remoteContentPolicy}
unfold={true}
style={{ alignSelf: 'center' }}
oembedProps={{
p: 2,
className: 'links embed-container',
}}
imageProps={{
marginLeft: 'auto',
marginRight: 'auto',
display: 'block'
}}
textProps={{
overflow: 'hidden',
color: 'black',
display: 'block',
alignSelf: 'center',
style: { textOverflow: 'ellipsis', whiteSpace: 'pre', width: '100%' },
p: 2
}} />
<Text color="gray" p={2} flexShrink={0}>
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={contents[1].url}>
<Box display='flex'>
<Icon icon='ArrowExternal' mr={1} />{hostname}
</Box>
</Anchor>
</Text>
</Box>
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author
showImage
contacts={contacts[path]}
ship={author}
date={node.post['time-sent']}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
group={group}
api={api}
></Author>
<Box ml="auto" mr={1}>
<Link to={`${baseUrl}/${index}`}>
<Box display='flex'>
<Icon color='blue' icon='Chat' />
<Text color='blue' ml={1}>{node.children.size}</Text>
</Box>
</Link>
</Box>
<Dropdown
width="200px"
alignX="right"
alignY="top"
options={
<Col backgroundColor="white" border={1} borderRadius={1} borderColor="lightGray">
<Row alignItems="center" p={1}>
<Action bg="white" m={1} color="black" onClick={copyLocation}>{locationText}</Action>
</Row>
{(ourRole === 'admin' || node.post.author === window.ship) &&
<Row alignItems="center" p={1}>
<Action bg="white" m={1} color="red" destructive onClick={deleteLink}>Delete Link</Action>
</Row>
}
</Col>
}
>
<Icon display="block" icon="Ellipsis" color="gray" />
</Dropdown>
</Row>
</Box>);
};

View File

@ -1,76 +0,0 @@
import React from 'react';
import { Row, Col, Anchor, Box, Text, BaseImage } from '@tlon/indigo-react';
import { Sigil } from '~/logic/lib/sigil';
import { Link } from 'react-router-dom';
import { cite } from '~/logic/lib/util';
import { roleForShip } from '~/logic/lib/group';
export const LinkItem = (props) => {
const {
node,
nickname,
avatar,
resource,
hideAvatars,
hideNicknames,
api,
group
} = props;
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);
const author = node.post.author;
const index = node.post.index.split('/')[1];
const size = node.children ? node.children.size : 0;
const contents = node.post.contents;
const hostname = URLparser.exec(contents[1].url) ? URLparser.exec(contents[1].url)[4] : null;
const showAvatar = avatar && !hideAvatars;
const showNickname = nickname && !hideNicknames;
const img = showAvatar
? <BaseImage display='inline-block' src={props.avatar} height={36} width={36} />
: <Sigil ship={`~${author}`} size={36} color={'#' + props.color} />;
const baseUrl = props.baseUrl || `/~404/${resource}`;
const ourRole = group ? roleForShip(group, window.ship) : undefined;
const [ship, name] = resource.split('/');
return (
<Row minWidth='0' flexShrink='0' width="100%" alignItems="center" py={3} bg="white">
{img}
<Col minWidth='0' height="100%" width='100%' justifyContent="space-between" ml={2}>
<Anchor
lineHeight="tall"
display='flex'
style={{ textDecoration: 'none' }}
href={contents[1].url}
width="100%"
target="_blank"
rel="noopener noreferrer"
>
<Text display='inline-block' overflow='hidden' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{contents[0].text}</Text>
<Text ml="2" color="gray" display='inline-block' flexShrink='0'>{hostname} </Text>
</Anchor>
<Box width="100%">
<Text
fontFamily={showNickname ? 'sans' : 'mono'} pr={2}
>
{showNickname ? nickname : cite(author) }
</Text>
<Link to={`${baseUrl}/${index}`}>
<Text color="gray">{size} comments</Text>
</Link>
{(ourRole === 'admin' || node.post.author === window.ship)
&& (<Text color='red' ml='2' cursor='pointer' onClick={() => api.graph.removeNodes(`~${ship}`, name, [node.post.index])}>Delete</Text>)}
</Box>
</Col>
</Row>
);
};

View File

@ -42,8 +42,12 @@ function describeNotification(description: string, plural: boolean) {
return `added ${pluralize("new link", plural)} to`; return `added ${pluralize("new link", plural)} to`;
case "comment": case "comment":
return `left ${pluralize("comment", plural)} on`; return `left ${pluralize("comment", plural)} on`;
case "edit-comment":
return `updated ${pluralize("comment", plural)} on`;
case "note": case "note":
return `posted ${pluralize("note", plural)} to`; return `posted ${pluralize("note", plural)} to`;
case "edit-note":
return `updated ${pluralize("note", plural)} in`;
case "mention": case "mention":
return "mentioned you on"; return "mentioned you on";
default: default:

View File

@ -2,7 +2,7 @@ import React from "react";
import { Route, Link, Switch } from "react-router-dom"; import { Route, Link, Switch } from "react-router-dom";
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { Box, Text, Row, Col, Icon } from "@tlon/indigo-react"; import { Box, Text, Row, Col, Icon, BaseImage } from "@tlon/indigo-react";
import { Sigil } from "~/logic/lib/sigil"; import { Sigil } from "~/logic/lib/sigil";
import { uxToHex, MOBILE_BROWSER_REGEX } from "~/logic/lib/util"; import { uxToHex, MOBILE_BROWSER_REGEX } from "~/logic/lib/util";
@ -12,7 +12,16 @@ import { ContactCard } from "~/views/landscape/components/ContactCard";
const SidebarItem = ({ children, view, current }) => { const SidebarItem = ({ children, view, current }) => {
const selected = current === view; const selected = current === view;
const color = selected ? "blue" : "black"; const icon = (view) => {
switch(view) {
case 'identity':
return 'Smiley';
case 'settings':
return 'Adjust';
default:
return 'Circle'
}
}
return ( return (
<Link to={`/~profile/${view}`}> <Link to={`/~profile/${view}`}>
<Row <Row
@ -20,10 +29,10 @@ const SidebarItem = ({ children, view, current }) => {
verticalAlign="middle" verticalAlign="middle"
py={1} py={1}
px={3} px={3}
backgroundColor={selected ? "washedBlue" : "white"} backgroundColor={selected ? "washedGray" : "white"}
> >
<Icon mr={2} display="inline-block" icon="Circle" color={color} /> <Icon mr={2} display="inline-block" icon={icon(view)} color='black' />
<Text color={color} fontSize={0}> <Text color='black' fontSize={0}>
{children} {children}
</Text> </Text>
</Row> </Row>
@ -56,6 +65,9 @@ export default function ProfileScreen(props: any) {
history.replace("/~profile/identity"); history.replace("/~profile/identity");
} }
const image = (!props?.hideAvatars && contact?.avatar)
? <BaseImage src={contact.avatar} width='100%' height='100%' style={{ objectFit: 'cover' }} />
: <Sigil ship={`~${ship}`} size={80} color={sigilColor} />;
return ( return (
<Box height="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}> <Box height="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}>
<Box <Box
@ -87,7 +99,7 @@ export default function ProfileScreen(props: any) {
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
> >
<Sigil ship={`~${ship}`} size={80} color={sigilColor} /> {image}
</Box> </Box>
</Box> </Box>
<Box width="100%" py={3} zIndex='2'> <Box width="100%" py={3} zIndex='2'>
@ -104,6 +116,7 @@ export default function ProfileScreen(props: any) {
alignItems="center" alignItems="center"
px={3} px={3}
borderBottom={1} borderBottom={1}
fontSize='0'
borderBottomColor="washedGray" borderBottomColor="washedGray"
> >
<Link to="/~profile">{"<- Back"}</Link> <Link to="/~profile">{"<- Back"}</Link>
@ -119,6 +132,8 @@ export default function ProfileScreen(props: any) {
path="/~/default" path="/~/default"
api={props.api} api={props.api}
s3={props.s3} s3={props.s3}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
/> />
</> </>
)} )}

View File

@ -50,6 +50,8 @@ export function EditPost(props: EditPostProps & RouteComponentProps) {
return ( return (
<PostForm <PostForm
initial={initial} initial={initial}
cancel
history={history}
onSubmit={onSubmit} onSubmit={onSubmit}
submitLabel="Update" submitLabel="Update"
loadingText="Updating..." loadingText="Updating..."

View File

@ -9,7 +9,7 @@ import { Comments } from "~/views/components/Comments";
import { NoteNavigation } from "./NoteNavigation"; import { NoteNavigation } from "./NoteNavigation";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { getLatestRevision, getComments } from '~/logic/lib/publish'; import { getLatestRevision, getComments } from '~/logic/lib/publish';
import { Author } from "./Author"; import Author from "~/views/components/Author";
import { Contacts, GraphNode, Graph, LocalUpdateRemoteContentPolicy } from "~/types"; import { Contacts, GraphNode, Graph, LocalUpdateRemoteContentPolicy } from "~/types";
interface NoteProps { interface NoteProps {

View File

@ -1,17 +1,19 @@
import React from "react"; import React from 'react';
import * as Yup from "yup"; import * as Yup from 'yup';
import { import {
Box,
ManagedTextInputField as Input, ManagedTextInputField as Input,
Row, Row,
Col, Col,
} from "@tlon/indigo-react"; Button
import { AsyncButton } from "../../../components/AsyncButton"; } from '@tlon/indigo-react';
import { Formik, Form, FormikHelpers } from "formik"; import { AsyncButton } from '../../../components/AsyncButton';
import { MarkdownField } from "./MarkdownField"; import { Formik, Form, FormikHelpers } from 'formik';
import { MarkdownField } from './MarkdownField';
interface PostFormProps { interface PostFormProps {
initial: PostFormSchema; initial: PostFormSchema;
cancel?: boolean;
history?: any;
onSubmit: ( onSubmit: (
values: PostFormSchema, values: PostFormSchema,
actions: FormikHelpers<PostFormSchema> actions: FormikHelpers<PostFormSchema>
@ -21,8 +23,8 @@ interface PostFormProps {
} }
const formSchema = Yup.object({ const formSchema = Yup.object({
title: Yup.string().required("Title cannot be blank"), title: Yup.string().required('Title cannot be blank'),
body: Yup.string().required("Post cannot be blank"), body: Yup.string().required('Post cannot be blank')
}); });
export interface PostFormSchema { export interface PostFormSchema {
@ -31,7 +33,7 @@ export interface PostFormSchema {
} }
export function PostForm(props: PostFormProps) { export function PostForm(props: PostFormProps) {
const { initial, onSubmit, submitLabel, loadingText } = props; const { initial, onSubmit, cancel, submitLabel, loadingText, history } = props;
return ( return (
<Col width="100%" height="100%" p={[2, 4]}> <Col width="100%" height="100%" p={[2, 4]}>
@ -41,18 +43,26 @@ export function PostForm(props: PostFormProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
validateOnBlur validateOnBlur
> >
<Form style={{ display: "contents"}}> <Form style={{ display: 'contents' }}>
<Row flexShrink='0' flexDirection={["column-reverse", "row"]} mb={4} gapX={4} justifyContent='space-between'> <Row flexShrink='0' flexDirection={['column-reverse', 'row']} mb={4} gapX={4} justifyContent='space-between'>
<Input maxWidth='40rem' width='100%' flexShrink={[0, 1]} placeholder="Post Title" id="title" /> <Input maxWidth='40rem' width='100%' flexShrink={[0, 1]} placeholder="Post Title" id="title" />
<AsyncButton <Row flexDirection={['column', 'row']} mb={[4,0]}>
<AsyncButton
ml={[0,2]}
flexShrink={0}
primary
loadingText={loadingText}
>
{submitLabel}
</AsyncButton>
{cancel && <Button
ml={[0,2]} ml={[0,2]}
mb={[4,0]} mt={[2,0]}
flexShrink={0} onClick={() => {
primary history.goBack();
loadingText={loadingText} }}
> type="button">Cancel</Button>}
{submitLabel} </Row>
</AsyncButton>
</Row> </Row>
<MarkdownField flexGrow={1} id="body" /> <MarkdownField flexGrow={1} id="body" />
</Form> </Form>

View File

@ -1,26 +1,29 @@
import React from "react"; import React from 'react';
import { Col, Box } from "@tlon/indigo-react"; import { Link } from 'react-router-dom';
import { cite } from "~/logic/lib/util"; import styled from 'styled-components';
import { Note } from "~/types/publish-update"; import { Col, Row, Box, Text, Icon, Image } from '@tlon/indigo-react';
import { Contact } from "~/types/contact-update";
import ReactMarkdown from "react-markdown"; import Author from '~/views/components/Author';
import moment from "moment"; import { GraphNode } from '~/types/graph-update';
import { Link } from "react-router-dom"; import { Contacts, Group } from '~/types';
import styled from "styled-components";
import { GraphNode } from "~/types/graph-update";
import { import {
getComments, getComments,
getLatestRevision, getLatestRevision,
getSnippet, getSnippet
} from "~/logic/lib/publish"; } from '~/logic/lib/publish';
import GlobalApi from '~/logic/api/global';
import ReactMarkdown from 'react-markdown';
interface NotePreviewProps { interface NotePreviewProps {
host: string; host: string;
book: string; book: string;
node: GraphNode; node: GraphNode;
contact?: Contact; hideAvatars?: boolean;
hideNicknames?: boolean; hideNicknames?: boolean;
baseUrl: string; baseUrl: string;
contacts: Contacts;
api: GlobalApi;
group: Group;
} }
const WrappedBox = styled(Box)` const WrappedBox = styled(Box)`
@ -28,29 +31,14 @@ const WrappedBox = styled(Box)`
`; `;
export function NotePreview(props: NotePreviewProps) { export function NotePreview(props: NotePreviewProps) {
const { node, contact } = props; const { node, contacts, hideAvatars, hideNicknames, group } = props;
const { post } = node; const { post } = node;
if (!post) { if (!post) {
return null; return null;
} }
let name = post?.author;
if (contact && !props.hideNicknames) {
name = contact.nickname.length > 0 ? contact.nickname : post?.author;
}
if (name === post?.author) {
name = cite(post?.author);
}
const numComments = getComments(node).children.size; const numComments = getComments(node).children.size;
const commentDesc = const url = `${props.baseUrl}/note/${post.index.split('/')[1]}`;
numComments === 0
? "No Comments"
: numComments === 1
? "1 Comment"
: `${numComments} Comments`;
const date = moment(post["time-sent"]).fromNow();
const url = `${props.baseUrl}/note/${post.index.split("/")[1]}`;
// stubbing pending notification-store // stubbing pending notification-store
const isRead = true; const isRead = true;
@ -60,32 +48,53 @@ export function NotePreview(props: NotePreviewProps) {
const snippet = getSnippet(body); const snippet = getSnippet(body);
return ( return (
<Link to={url}> <Box width='100%'>
<Col mb={4}> <Link to={url}>
<WrappedBox mb={1}>{title}</WrappedBox> <Col
<WrappedBox mb={1}> lineHeight='tall'
<ReactMarkdown width='100%'
unwrapDisallowed color={isRead ? 'washedGray' : 'blue'}
allowedTypes={["text", "root", "break", "paragraph"]} border={1}
source={snippet} borderRadius={2}
/> alignItems='flex-start'
</WrappedBox> overflow='hidden'
<Box color="gray" display="flex"> p='2'
<Box >
mr={3} <WrappedBox mb={2}><Text bold fontSize='0'>{title}</Text></WrappedBox>
fontFamily={ <WrappedBox>
contact?.nickname && !props.hideNicknames ? "sans" : "mono" <Text fontSize='14px'>
} <ReactMarkdown
> unwrapDisallowed
{name} allowedTypes={['text', 'root', 'break', 'paragraph', 'image']}
</Box> renderers={{
<Box color={isRead ? "gray" : "green"} mr={3}> image: props => <Image src={props.src} maxHeight='300px' style={{ objectFit: 'cover' }} />
{date} }}
</Box> source={snippet}
<Box mr={3}>{commentDesc}</Box> />
<Box>{rev.valueOf() === 1 ? `1 Revision` : `${rev} Revisions`}</Box> </Text>
</WrappedBox>
</Col>
</Link>
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author
showImage
contacts={contacts}
ship={post?.author}
date={post?.['time-sent']}
hideAvatars={hideAvatars || false}
hideNicknames={hideNicknames || false}
group={group}
api={props.api}
/>
<Box ml="auto" mr={1}>
<Link to={url}>
<Box display='flex'>
<Icon color='blue' icon='Chat' />
<Text color='blue' ml={1}>{numComments}</Text>
</Box>
</Link>
</Box> </Box>
</Col> </Row>
</Link> </Box>
); );
} }

View File

@ -6,7 +6,7 @@ import { RouteComponentProps } from "react-router-dom";
import Note from "./Note"; import Note from "./Note";
import { EditPost } from "./EditPost"; import { EditPost } from "./EditPost";
import { GraphNode, Graph, Contacts, LocalUpdateRemoteContentPolicy } from "~/types"; import { GraphNode, Graph, Contacts, LocalUpdateRemoteContentPolicy, Group } from "~/types";
interface NoteRoutesProps { interface NoteRoutesProps {
ship: string; ship: string;
@ -24,8 +24,6 @@ interface NoteRoutesProps {
} }
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) { export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {
const { ship, book, noteId } = props;
const baseUrl = props.baseUrl || '/~404'; const baseUrl = props.baseUrl || '/~404';
const rootUrl = props.rootUrl || '/~404'; const rootUrl = props.rootUrl || '/~404';

View File

@ -1,15 +1,11 @@
import React, { PureComponent } from "react"; import React from 'react';
import { Link, RouteComponentProps, Route, Switch } from "react-router-dom"; import { Link, RouteComponentProps } from 'react-router-dom';
import { NotebookPosts } from "./NotebookPosts"; import { NotebookPosts } from './NotebookPosts';
import { roleForShip } from "~/logic/lib/group"; import { Box, Button, Text, Row, Col } from '@tlon/indigo-react';
import { Box, Button, Text, Row, Col } from "@tlon/indigo-react"; import { Groups } from '~/types/group-update';
import { Groups } from "~/types/group-update"; import { Contacts, Rolodex } from '~/types/contact-update';
import { Contacts, Rolodex } from "~/types/contact-update"; import GlobalApi from '~/logic/api/global';
import GlobalApi from "~/logic/api/global"; import { Associations, Graph, Association } from '~/types';
import styled from "styled-components";
import { Associations, Graph, Association } from "~/types";
import { deSig } from "~/logic/lib/util";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
interface NotebookProps { interface NotebookProps {
api: GlobalApi; api: GlobalApi;
@ -22,9 +18,9 @@ interface NotebookProps {
contacts: Rolodex; contacts: Rolodex;
groups: Groups; groups: Groups;
hideNicknames: boolean; hideNicknames: boolean;
hideAvatars: boolean;
baseUrl: string; baseUrl: string;
rootUrl: string; rootUrl: string;
associations: Associations;
} }
interface NotebookState { interface NotebookState {
@ -39,39 +35,42 @@ export function Notebook(props: NotebookProps & RouteComponentProps) {
notebookContacts, notebookContacts,
groups, groups,
hideNicknames, hideNicknames,
hideAvatars,
association, association,
graph, graph
} = props; } = props;
const { metadata } = association; const { metadata } = association;
const group = groups[association?.["group-path"]]; const group = groups[association?.['group-path']];
if (!group) return null; // Waitin on groups to populate if (!group) {
return null; // Waitin on groups to populate
}
const relativePath = (p: string) => props.baseUrl + p; const relativePath = (p: string) => props.baseUrl + p;
const contact = notebookContacts?.[ship]; const contact = notebookContacts?.[ship];
const role = group ? roleForShip(group, window.ship) : undefined;
const isOwn = `~${window.ship}` === ship; const isOwn = `~${window.ship}` === ship;
let isWriter = true;
const isWriter = if (group.tags?.publish?.[`writers-${book}`]) {
isOwn || group.tags?.publish?.[`writers-${book}`]?.has(window.ship); isWriter = isOwn || group.tags?.publish?.[`writers-${book}`]?.has(window.ship);
}
const showNickname = contact?.nickname && !hideNicknames; const showNickname = contact?.nickname && !hideNicknames;
return ( return (
<Col gapY="4" pt={4} mx="auto" px={3} maxWidth="500px"> <Col gapY="4" pt={4} mx="auto" px={3} maxWidth="768px">
<Row justifyContent="space-between"> <Row justifyContent="space-between">
<Box> <Box>
<Text> {metadata?.title}</Text> <Text display='block'>{metadata?.title}</Text>
<br />
<Text color="lightGray">by </Text> <Text color="lightGray">by </Text>
<Text fontFamily={showNickname ? "sans" : "mono"}> <Text fontFamily={showNickname ? 'sans' : 'mono'}>
{showNickname ? contact?.nickname : ship} {showNickname ? contact?.nickname : ship}
</Text> </Text>
</Box> </Box>
{isWriter && ( {isWriter && (
<Link to={relativePath("/new")}> <Link to={relativePath('/new')}>
<Button primary style={{ cursor: "pointer" }}> <Button primary style={{ cursor: 'pointer' }}>
New Post New Post
</Button> </Button>
</Link> </Link>
@ -82,9 +81,12 @@ export function Notebook(props: NotebookProps & RouteComponentProps) {
graph={graph} graph={graph}
host={ship} host={ship}
book={book} book={book}
contacts={!!notebookContacts ? notebookContacts : {}} contacts={notebookContacts ? notebookContacts : {}}
hideNicknames={hideNicknames} hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
baseUrl={props.baseUrl} baseUrl={props.baseUrl}
api={props.api}
group={group}
/> />
</Col> </Col>
); );

View File

@ -1,15 +1,19 @@
import React, { Component } from "react"; import React from 'react';
import { Col } from "@tlon/indigo-react"; import { Col } from '@tlon/indigo-react';
import { NotePreview } from "./NotePreview"; import { NotePreview } from './NotePreview';
import { Contacts, Graph } from "~/types"; import { Contacts, Graph, Group } from '~/types';
import GlobalApi from '~/logic/api/global';
interface NotebookPostsProps { interface NotebookPostsProps {
contacts: Contacts; contacts: Contacts;
graph: Graph; graph: Graph;
host: string; host: string;
book: string; book: string;
hideNicknames?: boolean;
baseUrl: string; baseUrl: string;
hideAvatars?: boolean;
hideNicknames?: boolean;
api: GlobalApi;
group: Group;
} }
export function NotebookPosts(props: NotebookPostsProps) { export function NotebookPosts(props: NotebookPostsProps) {
@ -22,10 +26,11 @@ export function NotebookPosts(props: NotebookPostsProps) {
key={date.toString()} key={date.toString()}
host={props.host} host={props.host}
book={props.book} book={props.book}
contact={props.contacts[node.post.author]} contacts={props.contacts}
node={node} node={node}
hideNicknames={props.hideNicknames}
baseUrl={props.baseUrl} baseUrl={props.baseUrl}
api={props.api}
group={props.group}
/> />
) )
)} )}

View File

@ -1,7 +1,7 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { RouteComponentProps, Route, Switch } from "react-router-dom"; import { RouteComponentProps, Route, Switch } from "react-router-dom";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { import {
Association, Association,
Associations, Associations,
Graphs, Graphs,
@ -55,15 +55,18 @@ export function NotebookRoutes(
<Route <Route
path={baseUrl} path={baseUrl}
exact exact
render={(routeProps) => ( render={(routeProps) => {
<Notebook if (!graph) {
return <Center height="100%"><LoadingSpinner /></Center>;
}
return <Notebook
{...props} {...props}
graph={graph} graph={graph}
contacts={notebookContacts} contacts={notebookContacts}
association={props.association} association={props.association}
rootUrl={rootUrl} rootUrl={rootUrl}
baseUrl={baseUrl} /> baseUrl={baseUrl} />;
)} }}
/> />
<Route <Route
path={relativePath("/new")} path={relativePath("/new")}
@ -83,7 +86,7 @@ export function NotebookRoutes(
path={relativePath("/note/:noteId")} path={relativePath("/note/:noteId")}
render={(routeProps) => { render={(routeProps) => {
const { noteId } = routeProps.match.params; const { noteId } = routeProps.match.params;
const noteIdNum = bigInt(noteId) const noteIdNum = bigInt(noteId);
if(!graph) { if(!graph) {
return <Center height="100%"><LoadingSpinner /></Center>; return <Center height="100%"><LoadingSpinner /></Center>;

View File

@ -4,6 +4,7 @@ import { ShipSearch } from '~/views/components/ShipSearch';
import { Formik, Form, FormikHelpers } from 'formik'; import { Formik, Form, FormikHelpers } from 'formik';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
import { AsyncButton } from '~/views/components/AsyncButton'; import { AsyncButton } from '~/views/components/AsyncButton';
import { cite } from '~/logic/lib/util';
export class Writers extends Component { export class Writers extends Component {
render() { render() {
@ -27,6 +28,7 @@ export class Writers extends Component {
actions.setStatus({ error: e.message }); actions.setStatus({ error: e.message });
} }
}; };
const writers = Array.from(groups?.[association?.['group-path']]?.tags.publish?.[`writers-${name}`] || new Set()).map(e => cite(`~${e}`)).join(', ');
return ( return (
<Box maxWidth='512px'> <Box maxWidth='512px'>
@ -49,6 +51,10 @@ export class Writers extends Component {
</AsyncButton> </AsyncButton>
</Form> </Form>
</Formik> </Formik>
{writers.length > 0 && <>
<Text display='block' mt='2'>Current writers:</Text>
<Text mt='2' display='block' mono>{writers}</Text>
</>}
</Box> </Box>
); );
} }

View File

@ -187,7 +187,7 @@
color:var(--gray); color:var(--gray);
} }
.md p { .md {
line-height: 1.5; line-height: 1.5;
} }
.md code, .md pre { .md code, .md pre {
@ -201,7 +201,7 @@
border-bottom-width: 1px; border-bottom-width: 1px;
} }
md img { .md img {
margin-bottom: 8px; margin-bottom: 8px;
} }

View File

@ -5,6 +5,8 @@ import Helmet from 'react-helmet';
import { History } from './components/history'; import { History } from './components/history';
import { Input } from './components/input'; import { Input } from './components/input';
import { Box, Col } from '@tlon/indigo-react';
import Api from './api'; import Api from './api';
import Store from './store'; import Store from './store';
import Subscription from './subscription'; import Subscription from './subscription';
@ -47,30 +49,30 @@ export default class TermApp extends Component {
<Helmet> <Helmet>
<title>OS1 - Terminal</title> <title>OS1 - Terminal</title>
</Helmet> </Helmet>
<div <Box
style={{ height: '100%' }} height='100%'
> >
<Route <Route
exact exact
path="/~term/" path="/~term/"
render={(props) => { render={(props) => {
return ( return (
<div className="w-100 h-100 flex-m flex-l flex-xl"> <Box
<div width='100%'
className="db dn-m dn-l dn-xl inter dt w-100" height='100%'
style={{ height: 40 }} display='flex'
> >
</div> <Col
<div p='3'
className={ backgroundColor='white'
'pa3 bg-white bg-gray0-d black white-d mono w-100 f8 relative' + width='100%'
' h-100-m40-s b--gray2 br1 flex-auto flex flex-column ' + minHeight='0'
'mh4-m mh4-l mh4-xl mb4-m mb4-l mb4-xl ba-m ba-l ba-xl' color='washedGray'
} borderRadius='2'
style={{ mx={['0','3']}
lineHeight: '1.4', mb={['0','3']}
cursor: 'text' border={['0','1']}
}} cursor='text'
> >
<History log={this.state.lines.slice(0, -1)} /> <History log={this.state.lines.slice(0, -1)} />
<Input <Input
@ -80,12 +82,12 @@ export default class TermApp extends Component {
store={this.store} store={this.store}
line={this.state.lines.slice(-1)[0]} line={this.state.lines.slice(-1)[0]}
/> />
</div> </Col>
</div> </Box>
); );
}} }}
/> />
</div> </Box>
</> </>
); );
} }

View File

@ -1,4 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Box } from '@tlon/indigo-react';
import Line from './line'; import Line from './line';
@ -9,16 +10,22 @@ export class History extends Component {
render() { render() {
return ( return (
<div <Box
className="h-100 relative flex flex-column-reverse overflow-container flex-auto" height='100%'
minHeight='0'
display='flex'
flexDirection='column-reverse'
overflowY='scroll'
style={{ resize: 'none' }} style={{ resize: 'none' }}
> >
<div style={{ marginTop: 'auto' }}> <Box
mt='auto'
>
{this.props.log.map((line, i) => { {this.props.log.map((line, i) => {
return <Line key={i} line={line} />; return <Line key={i} line={line} />;
})} })}
</div> </Box>
</div> </Box>
); );
} }
} }

View File

@ -1,7 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Row, Box, BaseInput } from '@tlon/indigo-react'; import { Row, Box, BaseInput } from '@tlon/indigo-react';
import { cite } from '~/logic/lib/util';
import { Spinner } from '~/views/components/Spinner';
export class Input extends Component { export class Input extends Component {
constructor(props) { constructor(props) {
@ -85,15 +83,19 @@ export class Input extends Component {
} }
return ( return (
<Row flexGrow='1' position='relative'> <Row flexGrow='1' position='relative'>
<Box flexShrink='0' className="w-100"> <Box flexShrink='0' width='100%' color='black' fontSize='0'>
<BaseInput <BaseInput
autoFocus autoFocus
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
color='black'
minHeight='0'
display='inline-block'
width='100%'
spellCheck="false" spellCheck="false"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
className="mono ml1 flex-auto dib w-100" className="mono"
id="term" id="term"
cursor={this.props.cursor} cursor={this.props.cursor}
onKeyDown={this.keyPress} onKeyDown={this.keyPress}

View File

@ -1,5 +1,5 @@
import React, { Component, useMemo } from 'react'; import React from 'react';
import { Box, Text } from '@tlon/indigo-react'; import { Text } from '@tlon/indigo-react';
export default React.memo(({line}) => { export default React.memo(({line}) => {
@ -59,11 +59,10 @@ export default React.memo(({line}) => {
// render line // render line
// //
return ( return (
<Text mono display='flex' fontSize='14px' <Text mono display='flex' fontSize='0'
style={{ overflowWrap: 'break-word', whiteSpace: 'pre-wrap' }} style={{ overflowWrap: 'break-word', whiteSpace: 'pre-wrap' }}
> >
{text} {text}
</Text> </Text>
); );
}); });

View File

@ -14,10 +14,3 @@ input#term {
90% { opacity: 0; } 90% { opacity: 0; }
100% { opacity: 0; } 100% { opacity: 0; }
} }
/* responsive */
@media all and (max-width: 34.375em) {
.h-100-m40-s {
height: calc(100% - 40px);
}
}

View File

@ -1,9 +1,14 @@
import React, {ReactNode} from "react"; import React, {ReactNode} from "react";
import moment from "moment"; import moment from "moment";
import { Row, Box } from "@tlon/indigo-react";
import { Sigil } from "~/logic/lib/sigil" import { Sigil } from "~/logic/lib/sigil"
import { uxToHex, cite } from "~/logic/lib/util"; import { uxToHex, cite } from "~/logic/lib/util";
import { Contacts } from "~/types/contact-update"; import { Contacts, Rolodex } from "~/types/contact-update";
import { Row, Box } from "@tlon/indigo-react"; import OverlaySigil from "./OverlaySigil";
import { Group, Association } from "~/types";
import GlobalApi from "~/logic/api/global";
import { useHistory } from "react-router-dom";
interface AuthorProps { interface AuthorProps {
contacts: Contacts; contacts: Contacts;
@ -13,16 +18,18 @@ interface AuthorProps {
hideAvatars: boolean; hideAvatars: boolean;
hideNicknames: boolean; hideNicknames: boolean;
children?: ReactNode; children?: ReactNode;
group: Group;
api: GlobalApi;
} }
export function Author(props: AuthorProps) { export default function Author(props: AuthorProps) {
const { contacts, ship = '', date, showImage } = props; const { contacts, ship = '', date, showImage, hideAvatars, hideNicknames, group, api } = props;
let contact = null; const history = useHistory();
let contact;
if (contacts) { if (contacts) {
contact = ship in contacts ? contacts[ship] : null; contact = ship in contacts ? contacts[ship] : null;
} }
const color = contact?.color ? `#${uxToHex(contact?.color)}` : "#000000"; const color = contact?.color ? `#${uxToHex(contact?.color)}` : "#000000";
const showAvatar = !props.hideAvatars && contact?.avatar;
const showNickname = !props.hideNicknames && contact?.nickname; const showNickname = !props.hideNicknames && contact?.nickname;
const name = showNickname ? contact?.nickname : cite(ship); const name = showNickname ? contact?.nickname : cite(ship);
@ -31,18 +38,19 @@ export function Author(props: AuthorProps) {
<Row alignItems="center" width="auto"> <Row alignItems="center" width="auto">
{showImage && ( {showImage && (
<Box> <Box>
{showAvatar ? ( <OverlaySigil
<img src={contact?.avatar} height={16} width={16} className="dib" /> ship={ship}
) : ( contact={contact}
<Sigil color={color}
ship={ship} sigilClass={''}
size={16} group={group}
icon hideAvatars={hideAvatars}
padded hideNicknames={hideNicknames}
color={color} history={history}
classes={contact?.color ? '' : "mix-blend-diff"} api={api}
/> bg="white"
)} className="fl v-top pt1"
/>
</Box> </Box>
)} )}
<Box <Box

View File

@ -1,13 +1,13 @@
import React from 'react'; import React, { useState } from 'react';
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Contacts } from '~/types/contact-update'; import { Contacts } from '~/types/contact-update';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { Box, Row, Text } from '@tlon/indigo-react'; import { Box, Row, Text } from '@tlon/indigo-react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Author } from '~/views/apps/publish/components/Author'; import Author from '~/views/components/Author';
import { GraphNode, TextContent } from '~/types/graph-update'; import { GraphNode, TextContent } from '~/types/graph-update';
import tokenizeMessage from '~/logic/lib/tokenizeMessage'; import tokenizeMessage from '~/logic/lib/tokenizeMessage';
import { LocalUpdateRemoteContentPolicy } from '~/types'; import { LocalUpdateRemoteContentPolicy, Group } from '~/types';
import { MentionText } from '~/views/components/MentionText'; import { MentionText } from '~/views/components/MentionText';
import { getLatestCommentRevision } from '~/logic/lib/publish'; import { getLatestCommentRevision } from '~/logic/lib/publish';
@ -27,10 +27,11 @@ interface CommentItemProps {
hideNicknames: boolean; hideNicknames: boolean;
hideAvatars: boolean; hideAvatars: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy; remoteContentPolicy: LocalUpdateRemoteContentPolicy;
group: Group;
} }
export function CommentItem(props: CommentItemProps) { export function CommentItem(props: CommentItemProps) {
const { ship, contacts, name, api, remoteContentPolicy, comment } = props; const { ship, contacts, name, api, remoteContentPolicy, comment, group } = props;
const [revNum, post] = getLatestCommentRevision(comment); const [revNum, post] = getLatestCommentRevision(comment);
const disabled = props.pending || window.ship !== post?.author; const disabled = props.pending || window.ship !== post?.author;
@ -52,6 +53,9 @@ export function CommentItem(props: CommentItemProps) {
date={post?.['time-sent']} date={post?.['time-sent']}
hideAvatars={props.hideAvatars} hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames} hideNicknames={props.hideNicknames}
remoteContentPolicy={remoteContentPolicy}
group={group}
api={api}
> >
{!disabled && ( {!disabled && (
<Box display="inline-block" verticalAlign="middle"> <Box display="inline-block" verticalAlign="middle">

View File

@ -9,7 +9,7 @@ import { FormikHelpers } from 'formik';
import { GraphNode } from '~/types/graph-update'; import { GraphNode } from '~/types/graph-update';
import { createPost, createBlankNodeWithChildPost } from '~/logic/api/graph'; import { createPost, createBlankNodeWithChildPost } from '~/logic/api/graph';
import { getLatestCommentRevision } from '~/logic/lib/publish'; import { getLatestCommentRevision } from '~/logic/lib/publish';
import { LocalUpdateRemoteContentPolicy } from '~/types'; import { LocalUpdateRemoteContentPolicy, Group } from '~/types';
import { scanForMentions } from '~/logic/lib/graph'; import { scanForMentions } from '~/logic/lib/graph';
interface CommentsProps { interface CommentsProps {
@ -23,10 +23,11 @@ interface CommentsProps {
hideAvatars: boolean; hideAvatars: boolean;
hideNicknames: boolean; hideNicknames: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy; remoteContentPolicy: LocalUpdateRemoteContentPolicy;
group: Group;
} }
export function Comments(props: CommentsProps) { export function Comments(props: CommentsProps) {
const { comments, ship, name, api, baseUrl, history} = props; const { comments, ship, name, api, baseUrl, history, group } = props;
const onSubmit = async ( const onSubmit = async (
{ comment }, { comment },

View File

@ -35,6 +35,8 @@ interface DropdownSearchExtraProps<C> {
onSelect: (c: C) => void; onSelect: (c: C) => void;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: any) => void;
} }
type DropdownSearchProps<C> = PropFunc<typeof Box> & type DropdownSearchProps<C> = PropFunc<typeof Box> &
@ -51,6 +53,8 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
renderCandidate, renderCandidate,
disabled, disabled,
placeholder, placeholder,
onChange = () => {},
onBlur = () => {},
...rest ...rest
} = props; } = props;
@ -101,8 +105,9 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
}; };
}, [textarea.current, next, back, onEnter]); }, [textarea.current, next, back, onEnter]);
const onChange = useCallback( const changeCallback = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => { (e: ChangeEvent<HTMLTextAreaElement>) => {
onChange(e);
search(e.target.value); search(e.target.value);
setQuery(e.target.value); setQuery(e.target.value);
}, },
@ -128,11 +133,12 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
<Box {...rest} position="relative" zIndex={9}> <Box {...rest} position="relative" zIndex={9}>
<Input <Input
ref={textarea} ref={textarea}
onChange={onChange} onChange={changeCallback}
value={query} value={query}
autocomplete="off" autocomplete="off"
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
onBlur={onBlur}
/> />
{dropdown.length !== 0 && query.length !== 0 && ( {dropdown.length !== 0 && query.length !== 0 && (
<Box <Box

View File

@ -0,0 +1,141 @@
import React, { PureComponent } from 'react';
import { Sigil } from '~/logic/lib/sigil';
import { Contact, Group } from '~/types';
import {
ProfileOverlay,
OVERLAY_HEIGHT
} from './ProfileOverlay';
import { Box, BaseImage, ColProps } from '@tlon/indigo-react';
type OverlaySigilProps = ColProps & {
ship: string;
contact?: Contact;
color: string;
sigilClass: string;
group?: Group;
hideAvatars: boolean;
hideNicknames: boolean;
scrollWindow?: HTMLElement;
history: any;
api: any;
className: string;
}
interface OverlaySigilState {
clicked: boolean;
topSpace: number | 'auto';
bottomSpace: number | 'auto';
}
export default class OverlaySigil extends PureComponent<OverlaySigilProps, OverlaySigilState> {
public containerRef: React.Ref<HTMLDivElement>;
constructor(props) {
super(props);
this.state = {
clicked: false,
topSpace: 0,
bottomSpace: 0
};
this.containerRef = React.createRef();
this.profileShow = this.profileShow.bind(this);
this.profileHide = this.profileHide.bind(this);
this.updateContainerOffset = this.updateContainerOffset.bind(this);
}
profileShow() {
this.updateContainerOffset();
this.setState({ clicked: true });
this.props.scrollWindow?.addEventListener('scroll', this.updateContainerOffset);
}
profileHide() {
this.setState({ clicked: false });
this.props.scrollWindow?.removeEventListener('scroll', this.updateContainerOffset, true);
}
updateContainerOffset() {
if (this.containerRef && this.containerRef.current) {
const container = this.containerRef.current;
const scrollWindow = this.props.scrollWindow;
const bottomSpace = scrollWindow
? scrollWindow.scrollHeight - container.offsetTop - scrollWindow.scrollTop
: 'auto';
const topSpace = scrollWindow
? scrollWindow.offsetHeight - bottomSpace - OVERLAY_HEIGHT
: 0;
this.setState({
topSpace,
bottomSpace
});
}
}
componentWillUnmount() {
this.props.scrollWindow?.removeEventListener('scroll', this.updateContainerOffset, true);
}
render() {
const {
className,
ship,
contact,
color,
group,
hideAvatars,
hideNicknames,
history,
api,
sigilClass,
...rest
} = this.props;
const { state } = this;
const img = (contact && (contact.avatar !== null) && !hideAvatars)
? <BaseImage display='inline-block' src={contact.avatar} height={16} width={16} />
: <Sigil
ship={ship}
size={16}
color={color}
classes={sigilClass}
icon
padded
/>;
return (
<Box
cursor='pointer'
position='relative'
onClick={this.profileShow}
ref={this.containerRef}
className={className}
>
{state.clicked && (
<ProfileOverlay
ship={ship}
contact={contact}
color={color}
topSpace={state.topSpace}
bottomSpace={state.bottomSpace}
group={group}
onDismiss={this.profileHide}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
history={history}
api={api}
{...rest}
/>
)}
{img}
</Box>
);
}
}

View File

@ -1,14 +1,32 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { Contact, Group } from '~/types';
import { cite } from '~/logic/lib/util'; import { cite } from '~/logic/lib/util';
import { Sigil } from '~/logic/lib/sigil'; import { Sigil } from '~/logic/lib/sigil';
import { Box, Col, Button, Text, BaseImage } from '@tlon/indigo-react'; import { Box, Col, Button, Text, BaseImage, ColProps } from '@tlon/indigo-react';
export const OVERLAY_HEIGHT = 250; export const OVERLAY_HEIGHT = 250;
export class ProfileOverlay extends PureComponent { type ProfileOverlayProps = ColProps & {
constructor() { ship: string;
super(); contact?: Contact;
color: string;
topSpace: number | 'auto';
bottomSpace: number | 'auto';
group?: Group;
onDismiss(): void;
hideAvatars: boolean;
hideNicknames: boolean;
history: any;
api: any;
}
export class ProfileOverlay extends PureComponent<ProfileOverlayProps, {}> {
public popoverRef: React.Ref<typeof Col>;
constructor(props) {
super(props);
this.popoverRef = React.createRef(); this.popoverRef = React.createRef();
this.onDocumentClick = this.onDocumentClick.bind(this); this.onDocumentClick = this.onDocumentClick.bind(this);
@ -35,7 +53,19 @@ export class ProfileOverlay extends PureComponent {
} }
render() { render() {
const { contact, ship, color, topSpace, bottomSpace, group, hideNicknames, hideAvatars, history } = this.props; const {
contact,
ship,
color,
topSpace,
bottomSpace,
group = false,
hideNicknames,
hideAvatars,
history,
onDismiss,
...rest
} = this.props;
let top, bottom; let top, bottom;
if (topSpace < OVERLAY_HEIGHT / 2) { if (topSpace < OVERLAY_HEIGHT / 2) {
@ -66,7 +96,7 @@ export class ProfileOverlay extends PureComponent {
/* if (!group.hidden) { /* if (!group.hidden) {
}*/ }*/
const isHidden = group.hidden; const isHidden = group ? group.hidden : false;
return ( return (
<Col <Col
@ -77,6 +107,7 @@ export class ProfileOverlay extends PureComponent {
zIndex='3' zIndex='3'
fontSize='0' fontSize='0'
style={containerStyle} style={containerStyle}
{...rest}
> >
<Box height='160px' width='160px'> <Box height='160px' width='160px'>
{img} {img}

View File

@ -1,12 +1,13 @@
import React, { PureComponent, Fragment } from 'react'; import React, { PureComponent, Fragment } from 'react';
import { LocalUpdateRemoteContentPolicy } from "~/types/local-update"; import { LocalUpdateRemoteContentPolicy } from "~/types/local-update";
import { BaseAnchor, BaseImage, Box, Button } from '@tlon/indigo-react'; import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser'; import { hasProvider } from 'oembed-parser';
import EmbedContainer from 'react-oembed-container'; import EmbedContainer from 'react-oembed-container';
import { memoize } from 'lodash'; import { memoize } from 'lodash';
interface RemoteContentProps { interface RemoteContentProps {
url: string; url: string;
text?: string;
remoteContentPolicy: LocalUpdateRemoteContentPolicy; remoteContentPolicy: LocalUpdateRemoteContentPolicy;
unfold?: boolean; unfold?: boolean;
renderUrl?: boolean; renderUrl?: boolean;
@ -14,6 +15,7 @@ interface RemoteContentProps {
audioProps?: any; audioProps?: any;
videoProps?: any; videoProps?: any;
oembedProps?: any; oembedProps?: any;
textProps?: any;
style?: any; style?: any;
onLoad?(): void; onLoad?(): void;
} }
@ -27,8 +29,6 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i); const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i); const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
const memoizedFetch = memoize(fetch);
export default class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> { export default class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> {
private fetchController: AbortController | undefined; private fetchController: AbortController | undefined;
constructor(props) { constructor(props) {
@ -48,7 +48,8 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
} }
} }
unfoldEmbed() { unfoldEmbed(event: Event) {
event.stopPropagation();
let unfoldState = this.state.unfold; let unfoldState = this.state.unfold;
unfoldState = !unfoldState; unfoldState = !unfoldState;
this.setState({ unfold: unfoldState }); this.setState({ unfold: unfoldState });
@ -57,7 +58,7 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
loadOembed() { loadOembed() {
this.fetchController = new AbortController(); this.fetchController = new AbortController();
memoizedFetch(`https://noembed.com/embed?url=${this.props.url}`, { fetch(`https://noembed.com/embed?url=${this.props.url}`, {
signal: this.fetchController.signal signal: this.fetchController.signal
}) })
.then(response => response.clone().json()) .then(response => response.clone().json())
@ -70,11 +71,13 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
} }
wrapInLink(contents) { wrapInLink(contents) {
const { style } = this.props;
return (<BaseAnchor return (<BaseAnchor
href={this.props.url} href={this.props.url}
style={{ color: 'inherit', textDecoration: 'none' }} style={{ color: 'inherit', textDecoration: 'none', ...style }}
className={`word-break-all ${(typeof contents === 'string') ? 'bb' : ''}`} className={`word-break-all ${(typeof contents === 'string') ? 'bb' : ''}`}
target="_blank" target="_blank"
width="100%"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{contents} {contents}
@ -85,12 +88,14 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
const { const {
remoteContentPolicy, remoteContentPolicy,
url, url,
text,
unfold = false, unfold = false,
renderUrl = true, renderUrl = true,
imageProps = {}, imageProps = {},
audioProps = {}, audioProps = {},
videoProps = {}, videoProps = {},
oembedProps = {}, oembedProps = {},
textProps = {},
style = {}, style = {},
onLoad = () => {}, onLoad = () => {},
...props ...props
@ -113,7 +118,9 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
} else if (isAudio && remoteContentPolicy.audioShown) { } else if (isAudio && remoteContentPolicy.audioShown) {
return ( return (
<> <>
{renderUrl ? this.wrapInLink(url) : null} {renderUrl
? this.wrapInLink(<Text {...textProps}>{text || url}</Text>)
: null}
<audio <audio
controls controls
className="db" className="db"
@ -127,7 +134,9 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
} else if (isVideo && remoteContentPolicy.videoShown) { } else if (isVideo && remoteContentPolicy.videoShown) {
return ( return (
<> <>
{renderUrl ? this.wrapInLink(url) : null} {renderUrl
? this.wrapInLink(<Text {...textProps}>{text || url}</Text>)
: null}
<video <video
controls controls
className="db" className="db"
@ -146,7 +155,11 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
return ( return (
<Fragment> <Fragment>
{renderUrl ? this.wrapInLink(this.state.embed && this.state.embed.title ? this.state.embed.title : url) : null} {renderUrl
? this.wrapInLink(<Text {...textProps}>{(this.state.embed && this.state.embed.title)
? this.state.embed.title
: (text || url)}</Text>)
: null}
{this.state.embed !== 'error' && this.state.embed?.html && !unfold ? <Button {this.state.embed !== 'error' && this.state.embed?.html && !unfold ? <Button
display='inline-flex' display='inline-flex'
border={1} border={1}
@ -176,7 +189,9 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
</Fragment> </Fragment>
); );
} else { } else {
return renderUrl ? this.wrapInLink(url) : null; return renderUrl
? this.wrapInLink(<Text {...textProps}>{text || url}</Text>)
: null;
} }
} }
} }

View File

@ -1,5 +1,5 @@
import React, { useMemo, useCallback } from "react"; import React, { useMemo, useCallback, ChangeEvent, useState, SyntheticEvent, useEffect } from "react";
import { Box, Label, Icon, Text, Row, Col } from "@tlon/indigo-react"; import { Box, Label, Icon, Text, Row, Col, ErrorLabel } from "@tlon/indigo-react";
import _ from "lodash"; import _ from "lodash";
import ob from "urbit-ob"; import ob from "urbit-ob";
import { useField } from "formik"; import { useField } from "formik";
@ -11,6 +11,8 @@ import { cite, deSig } from "~/logic/lib/util";
import { Rolodex, Groups } from "~/types"; import { Rolodex, Groups } from "~/types";
import { HoverBox } from "./HoverBox"; import { HoverBox } from "./HoverBox";
const INVALID_SHIP_ERR = "Invalid ship";
interface InviteSearchProps { interface InviteSearchProps {
autoFocus?: boolean; autoFocus?: boolean;
disabled?: boolean; disabled?: boolean;
@ -45,25 +47,67 @@ const Candidate = ({ title, detail, selected, onClick }) => (
export function ShipSearch(props: InviteSearchProps) { export function ShipSearch(props: InviteSearchProps) {
const { id, label, caption } = props; const { id, label, caption } = props;
const [{ value }, { error }, { setValue, setTouched }] = useField<string[]>( const [{}, meta, { setValue, setTouched, setError: _setError }] = useField<string[]>({
props.id name: id,
multiple: true
});
const setError = _setError as unknown as (s: string | undefined) => void;
const { error, touched } = meta;
const [selected, setSelected] = useState([] as string[]);
const [inputShip, setInputShip] = useState(undefined as string | undefined);
const [inputTouched, setInputTouched] = useState(false);
const checkInput = useCallback((valid: boolean, ship: string | undefined) => {
if(valid) {
setInputShip(ship);
setError(error === INVALID_SHIP_ERR ? undefined : error);
} else {
setError(INVALID_SHIP_ERR);
setInputTouched(false);
}
}, [setError, error, setInputTouched, setInputShip]);
const onChange = useCallback(
(e: any) => {
let ship = `~${deSig(e.target.value) || ""}`;
if(ob.isValidPatp(ship)) {
checkInput(true, ship);
} else {
checkInput(ship.length !== 1, undefined)
}
},
[checkInput]
); );
const onBlur = useCallback(() => {
setInputTouched(true);
}, [setInputTouched]);
const onSelect = useCallback( const onSelect = useCallback(
(s: string) => { (s: string) => {
setTouched(true); setTouched(true);
setValue([...value, s]); checkInput(true, undefined);
s = `~${deSig(s)}`;
setSelected(v => _.uniq([...v, s]))
}, },
[setValue, value] [setTouched, checkInput, setSelected]
); );
const onRemove = useCallback( const onRemove = useCallback(
(s: string) => { (s: string) => {
setValue(value.filter((v) => v !== s)); setSelected(ships => ships.filter(ship => ship !== s))
}, },
[setValue, value] [setSelected]
); );
useEffect(() => {
const newValue = inputShip ? [...selected, inputShip] : selected;
setValue(newValue);
}, [inputShip, selected])
const [peers, nicknames] = useMemo(() => { const [peers, nicknames] = useMemo(() => {
const peerSet = new Set<string>(); const peerSet = new Set<string>();
const contacts = new Map<string, string[]>(); const contacts = new Map<string, string[]>();
@ -125,20 +169,22 @@ export function ShipSearch(props: InviteSearchProps) {
isExact={(s) => { isExact={(s) => {
const ship = `~${deSig(s)}`; const ship = `~${deSig(s)}`;
const result = ob.isValidPatp(ship); const result = ob.isValidPatp(ship);
return result ? deSig(s) : undefined; return result ? deSig(s) ?? undefined : undefined;
}} }}
placeholder="Search for ships" placeholder="Search for ships"
candidates={peers} candidates={peers}
renderCandidate={renderCandidate} renderCandidate={renderCandidate}
disabled={props.maxLength ? value.length >= props.maxLength : false} disabled={props.maxLength ? selected.length >= props.maxLength : false}
search={(s: string, t: string) => search={(s: string, t: string) =>
t.toLowerCase().startsWith(s.toLowerCase()) t.toLowerCase().startsWith(s.toLowerCase())
} }
getKey={(s: string) => s} getKey={(s: string) => s}
onSelect={onSelect} onSelect={onSelect}
onChange={onChange}
onBlur={onBlur}
/> />
<Row minHeight="34px" flexWrap="wrap"> <Row minHeight="34px" flexWrap="wrap">
{value.map((s) => ( {selected.map((s) => (
<Row <Row
fontFamily="mono" fontFamily="mono"
alignItems="center" alignItems="center"
@ -161,6 +207,11 @@ export function ShipSearch(props: InviteSearchProps) {
</Row> </Row>
))} ))}
</Row> </Row>
<ErrorLabel
mt="3"
hasError={error === INVALID_SHIP_ERR ? inputTouched : !!(touched && error)}>
{error}
</ErrorLabel>
</Col> </Col>
); );
} }

View File

@ -52,7 +52,7 @@ export class OmniboxResult extends Component {
} else if (icon === 'profile') { } else if (icon === 'profile') {
graphic = <Sigil color={sigilFill} classes='dib flex-shrink-0 v-mid mr2' ship={window.ship} size={16} icon padded />; graphic = <Sigil color={sigilFill} classes='dib flex-shrink-0 v-mid mr2' ship={window.ship} size={16} icon padded />;
} else if (icon === 'home') { } else if (icon === 'home') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Boot' mr='2' size='16px' color={iconFill} />; graphic = <Icon display='inline-block' verticalAlign='middle' icon='Mail' mr='2' size='16px' color={iconFill} />;
} else if (icon === 'notifications') { } else if (icon === 'notifications') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='16px' color={iconFill} />; graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='16px' color={iconFill} />;
} else { } else {

View File

@ -10,6 +10,7 @@ import {
Box, Box,
Text, Text,
Row, Row,
BaseImage
} from "@tlon/indigo-react"; } from "@tlon/indigo-react";
import { Formik, FormikHelpers } from "formik"; import { Formik, FormikHelpers } from "formik";
import { Contact } from "~/types/contact-update"; import { Contact } from "~/types/contact-update";
@ -25,6 +26,8 @@ interface ContactCardProps {
api: GlobalApi; api: GlobalApi;
s3: S3State; s3: S3State;
rootIdentity: Contact; rootIdentity: Contact;
hideAvatars: boolean;
hideNicknames: boolean;
} }
const formSchema = Yup.object({ const formSchema = Yup.object({
@ -71,12 +74,15 @@ const emptyContact = {
export function ContactCard(props: ContactCardProps) { export function ContactCard(props: ContactCardProps) {
const us = `~${window.ship}`; const us = `~${window.ship}`;
const { contact, rootIdentity } = props; const { contact, rootIdentity } = props;
const onSubmit = async (values: Contact, actions: FormikHelpers<Contact>) => { const onSubmit = async (values: any, actions: FormikHelpers<Contact>) => {
try { try {
if(!contact) { if(!contact) {
const [,,ship] = props.path.split('/'); const [,,ship] = props.path.split('/');
values.color = uxToHex(values.color); values.color = uxToHex(values.color);
await props.api.contacts.share(ship, props.path, us, values) const sharedValues = Object.assign({}, values);
sharedValues.avatar = (values.avatar === "") ? null : { url: values.avatar };
console.log(values);
await props.api.contacts.share(ship, props.path, us, sharedValues);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
return; return;
} }
@ -108,6 +114,11 @@ export function ContactCard(props: ContactCardProps) {
}; };
const hexColor = contact?.color ? `#${uxToHex(contact.color)}` : "#000000"; const hexColor = contact?.color ? `#${uxToHex(contact.color)}` : "#000000";
const image = (!props?.hideAvatars && contact?.avatar)
? <BaseImage src={contact.avatar} width='100%' height='100%' style={{ objectFit: 'cover' }} />
: <Sigil ship={us} size={32} color={hexColor} />;
const nickname = (!props.hideNicknames && contact?.nickname) ? contact.nickname : "";
return ( return (
<Box p={4} height="100%" overflowY="auto"> <Box p={4} height="100%" overflowY="auto">
@ -129,9 +140,11 @@ export function ContactCard(props: ContactCardProps) {
pb={3} pb={3}
alignItems="center" alignItems="center"
> >
<Sigil size={32} classes="" color={hexColor} ship={us} /> <Box height='32px' width='32px'>
{image}
</Box>
<Box ml={2}> <Box ml={2}>
<Text fontFamily="mono">{us}</Text> <Text mono={!Boolean(nickname)}>{nickname}</Text>
</Box> </Box>
</Row> </Row>
<ImageInput id="avatar" label="Avatar" s3={props.s3} /> <ImageInput id="avatar" label="Avatar" s3={props.s3} />

View File

@ -69,7 +69,7 @@ export const Content = (props) => {
<Notifications {...props} /> <Notifications {...props} />
)} )}
/> />
<GraphApp {...props} /> <GraphApp path="/~graph" {...props} />
<Route <Route
render={p => ( render={p => (
<ErrorComponent <ErrorComponent

View File

@ -34,10 +34,13 @@ function DeleteGroup(props: {
const history = useHistory(); const history = useHistory();
const onDelete = async () => { const onDelete = async () => {
const name = props.association['group-path'].split('/').pop(); const name = props.association['group-path'].split('/').pop();
if (prompt(`To confirm deleting this group, type ${name}`) === name) { if (props.owner) {
await props.api.contacts.delete(props.association["group-path"]); const shouldDelete = (prompt(`To confirm deleting this group, type ${name}`) === name);
history.push("/"); if (!shouldDelete) return;
} }
const resource = resourceFromPath(props.association["group-path"])
await props.api.groups.removeGroup(resource);
history.push("/");
}; };
const action = props.owner ? "Delete" : "Leave"; const action = props.owner ? "Delete" : "Leave";

View File

@ -118,7 +118,7 @@ export function GroupSwitcher(props: {
mr={2} mr={2}
color="gray" color="gray"
display="block" display="block"
icon="Boot" icon="Mail"
/> />
<Text>DMs + Drafts</Text> <Text>DMs + Drafts</Text>
</GroupSwitcherItem>} </GroupSwitcherItem>}
@ -127,11 +127,11 @@ export function GroupSwitcher(props: {
associations={props.associations} associations={props.associations}
/> />
<GroupSwitcherItem to="/~landscape/new"> <GroupSwitcherItem to="/~landscape/new">
<Icon mr="2" color="gray" icon="Plus" /> <Icon mr="2" color="gray" icon="CreateGroup" />
<Text> New Group</Text> <Text> New Group</Text>
</GroupSwitcherItem> </GroupSwitcherItem>
<GroupSwitcherItem to="/~landscape/join"> <GroupSwitcherItem to="/~landscape/join">
<Icon mr="2" color="gray" icon="Boot" /> <Icon mr="2" color="gray" icon="Plus" />
<Text> Join Group</Text> <Text> Join Group</Text>
</GroupSwitcherItem> </GroupSwitcherItem>
{workspace.type === "group" && ( {workspace.type === "group" && (

View File

@ -173,6 +173,7 @@ export function GroupsPane(props: GroupsPaneProps) {
{...routeProps} {...routeProps}
api={api} api={api}
baseUrl={baseUrl} baseUrl={baseUrl}
chatSynced={props.chatSynced}
associations={associations} associations={associations}
groups={groups} groups={groups}
group={groupPath} group={groupPath}

View File

@ -1,18 +1,18 @@
import React, { useCallback, useRef, useMemo } from "react"; import React, { useCallback, useRef, useMemo } from "react";
import { Box, Text, Col, Button, Row } from "@tlon/indigo-react"; import { Switch, Route, useHistory } from "react-router-dom";
import { Formik, Form } from "formik";
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Box, Text, Col, Button, Row } from "@tlon/indigo-react";
import { ShipSearch } from "~/views/components/ShipSearch"; import { ShipSearch } from "~/views/components/ShipSearch";
import { Association } from "~/types/metadata-update"; import { Association } from "~/types/metadata-update";
import { Switch, Route, useHistory } from "react-router-dom";
import { Formik, Form } from "formik";
import { AsyncButton } from "~/views/components/AsyncButton"; import { AsyncButton } from "~/views/components/AsyncButton";
import { useOutsideClick } from "~/logic/lib/useOutsideClick"; import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { FormError } from "~/views/components/FormError"; import { FormError } from "~/views/components/FormError";
import { resourceFromPath } from "~/logic/lib/group"; import { resourceFromPath } from "~/logic/lib/group";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { Groups, Rolodex, Workspace } from "~/types"; import { Groups, Rolodex, Workspace } from "~/types";
import { ChipInput } from "~/views/components/ChipInput"; import { deSig } from "~/logic/lib/util";
interface InvitePopoverProps { interface InvitePopoverProps {
baseUrl: string; baseUrl: string;
@ -30,7 +30,7 @@ interface FormSchema {
const formSchema = Yup.object({ const formSchema = Yup.object({
emails: Yup.array(Yup.string().email("Invalid email")), emails: Yup.array(Yup.string().email("Invalid email")),
ships: Yup.array(Yup.string()) ships: Yup.array(Yup.string()).min(1, "Must invite at least one ship")
}); });
export function InvitePopover(props: InvitePopoverProps) { export function InvitePopover(props: InvitePopoverProps) {
@ -48,14 +48,14 @@ export function InvitePopover(props: InvitePopoverProps) {
const onSubmit = async ({ ships, emails }: { ships: string[] }, actions) => { const onSubmit = async ({ ships, emails }: { ships: string[] }, actions) => {
if(props.workspace.type === 'home') { if(props.workspace.type === 'home') {
history.push(`/~landscape/dm/${ships[0]}`); history.push(`/~landscape/dm/${deSig(ships[0])}`);
return; return;
} }
// TODO: how to invite via email? // TODO: how to invite via email?
try { try {
const resource = resourceFromPath(association["group-path"]); const resource = resourceFromPath(association["group-path"]);
await ships.reduce( await ships.reduce(
(acc, s) => acc.then(() => api.contacts.invite(resource, `~${s}`)), (acc, s) => acc.then(() => api.contacts.invite(resource, `~${deSig(s)}`)),
Promise.resolve() Promise.resolve()
); );
actions.setStatus({ success: null }); actions.setStatus({ success: null });
@ -90,16 +90,19 @@ export function InvitePopover(props: InvitePopoverProps) {
borderColor="washedGray" borderColor="washedGray"
borderRadius={1} borderRadius={1}
maxHeight="472px" maxHeight="472px"
width="380px" width="100%"
maxWidth="380px"
mx={[4,0]}
bg="white" bg="white"
> >
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={onSubmit} onSubmit={onSubmit}
validationSchema={formSchema} validationSchema={formSchema}
validateOnBlur
> >
<Form> <Form>
<Col gapY="3" p={3}> <Col gapY="3" pt={3} px={3}>
<Box> <Box>
<Text>Invite to </Text> <Text>Invite to </Text>
<Text fontWeight="800">{title || "DM"}</Text> <Text fontWeight="800">{title || "DM"}</Text>
@ -122,13 +125,12 @@ export function InvitePopover(props: InvitePopoverProps) {
/> */} /> */}
</Col> </Col>
<Row <Row
borderTop={1}
borderTopColor="washedGray"
justifyContent="flex-end" justifyContent="flex-end"
p={3}
> >
<AsyncButton <AsyncButton
border={0} border={0}
color="blue" primary
loadingText="Inviting..." loadingText="Inviting..."
> >
Send Send

View File

@ -79,8 +79,8 @@ export function JoinGroup(props: JoinGroupProps & RouteComponentProps) {
); );
return ( return (
<Body> <>
<Col maxWidth="300px" overflowY="auto" p="3"> <Col overflowY="auto" p="3">
<Box mb={3}> <Box mb={3}>
<Text fontWeight="bold">Join Group</Text> <Text fontWeight="bold">Join Group</Text>
</Box> </Box>
@ -103,6 +103,6 @@ export function JoinGroup(props: JoinGroupProps & RouteComponentProps) {
</Form> </Form>
</Formik> </Formik>
</Col> </Col>
</Body> </>
); );
} }

View File

@ -1,174 +1,221 @@
import React, { useCallback } from 'react'; import React from 'react';
import { import {
Box, Box,
ManagedTextInputField as Input, ManagedTextInputField as Input,
Col, Col,
ManagedRadioButtonField as Radio, ManagedRadioButtonField as Radio,
Text, Text,
} from "@tlon/indigo-react"; Icon,
import { Formik, Form } from "formik"; Row
import * as Yup from "yup"; } from '@tlon/indigo-react';
import GlobalApi from "~/logic/api/global"; import { Formik, Form } from 'formik';
import { AsyncButton } from "~/views/components/AsyncButton"; import * as Yup from 'yup';
import { FormError } from "~/views/components/FormError"; import GlobalApi from '~/logic/api/global';
import { RouteComponentProps } from "react-router-dom"; import { AsyncButton } from '~/views/components/AsyncButton';
import { stringToSymbol, parentPath } from "~/logic/lib/util"; import { FormError } from '~/views/components/FormError';
import GroupSearch from "~/views/components/GroupSearch"; import { RouteComponentProps } from 'react-router-dom';
import { Associations } from "~/types/metadata-update"; import { stringToSymbol, parentPath } from '~/logic/lib/util';
import { useWaitForProps } from "~/logic/lib/useWaitForProps"; import { resourceFromPath } from '~/logic/lib/group';
import { Groups } from "~/types/group-update"; import { Associations } from '~/types/metadata-update';
import { ShipSearch } from "~/views/components/ShipSearch"; import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { Rolodex, Workspace } from "~/types"; import { Groups } from '~/types/group-update';
import { ShipSearch } from '~/views/components/ShipSearch';
import { Rolodex, Workspace } from '~/types';
interface FormSchema { interface FormSchema {
name: string; name: string;
description: string; description: string;
ships: string[]; ships: string[];
moduleType: "chat" | "publish" | "link"; moduleType: 'chat' | 'publish' | 'link';
writers: string[];
} }
const formSchema = Yup.object({ const formSchema = (group, groups) => Yup.object({
name: Yup.string().required('Channel must have a name'), name: Yup.string().required('Channel must have a name'),
description: Yup.string(), description: Yup.string(),
ships: Yup.array(Yup.string()), ships: Yup.array(Yup.string()),
moduleType: Yup.string().required('Must choose channel type') moduleType: Yup.string().required('Must choose channel type'),
writers: Yup.array(Yup.string().test('ingroup', 'Writers must be in group',
value => groups?.[group]?.members?.has(value)))
}); });
interface NewChannelProps { interface NewChannelProps {
api: GlobalApi; api: GlobalApi;
associations: Associations; associations: Associations;
contacts: Rolodex; contacts: Rolodex;
chatSynced: any;
groups: Groups; groups: Groups;
group?: string; group?: string;
workspace: Workspace; workspace: Workspace;
} }
export function NewChannel(props: NewChannelProps & RouteComponentProps) { export function NewChannel(props: NewChannelProps & RouteComponentProps) {
const { history, api, group, workspace } = props; const { history, api, group, workspace, groups } = props;
const waiter = useWaitForProps(props, 5000); const waiter = useWaitForProps(props, 5000);
const onSubmit = async (values: FormSchema, actions) => { const onSubmit = async (values: FormSchema, actions) => {
const resId: string = stringToSymbol(values.name); const resId: string = stringToSymbol(values.name)
+ ((workspace?.type !== 'home') ? `-${Math.floor(Math.random() * 10000)}`
: '');
try { try {
const { name, description, moduleType, ships } = values; const { name, description, moduleType, ships, writers } = values;
switch (moduleType) { switch (moduleType) {
case 'chat': case 'chat':
const appPath = `/~${window.ship}/${resId}`; const appPath = `/~${window.ship}/${resId}`;
const groupPath = group || `/ship${appPath}`; const groupPath = group || `/ship${appPath}`;
await api.chat.create( await api.chat.create(
name, name,
description, description,
appPath, appPath,
groupPath, groupPath,
{ invite: { pending: ships.map(s => `~${s}`) } }, { invite: { pending: ships.map(s => `~${s}`) } },
ships.map(s => `~${s}`), ships.map(s => `~${s}`),
true, true,
false false
); );
break; break;
case "publish": case 'publish':
case "link": if (writers.length > 0) {
if (group) { const resource = resourceFromPath(group);
await api.graph.createManagedGraph( await api.groups.addTag(
resId, resource,
name, { app: 'publish', tag: `writers-${resId}` },
description, writers.map(s => `~${s}`)
group, );
moduleType }
); case 'link':
} else { if (group) {
await api.graph.createUnmanagedGraph( await api.graph.createManagedGraph(
resId, resId,
name, name,
description, description,
{ invite: { pending: ships.map((s) => `~${s}`) } }, group,
moduleType moduleType
); );
} } else {
break; await api.graph.createUnmanagedGraph(
default: resId,
console.log('fallthrough'); name,
} description,
{ invite: { pending: ships.map(s => `~${s}`) } },
moduleType
);
}
break;
default:
console.log('fallthrough');
}
if (!group) { if (!group) {
await waiter(p => Boolean(p?.groups?.[`/ship/~${window.ship}/${resId}`])); await waiter(p => Boolean(p?.groups?.[`/ship/~${window.ship}/${resId}`]));
} }
actions.setStatus({ success: null }); if (moduleType === 'chat') {
const resourceUrl = parentPath(location.pathname); await waiter(p => Boolean(p?.chatSynced?.[`/~${window.ship}/${resId}`]));
history.push( }
`${resourceUrl}/resource/${moduleType}` + actions.setStatus({ success: null });
`${moduleType !== 'chat' ? '/ship' : ''}/~${window.ship}/${resId}` const resourceUrl = parentPath(location.pathname);
); history.push(
} catch (e) { `${resourceUrl}/resource/${moduleType}` +
console.error(e); `${moduleType !== 'chat' ? '/ship' : ''}/~${window.ship}/${resId}`
actions.setStatus({ error: 'Channel creation failed' }); );
} } catch (e) {
}; console.error(e);
return ( actions.setStatus({ error: 'Channel creation failed' });
<Col overflowY="auto" p={3}> }
<Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}> };
{'<- Back'} return (
</Box> <Col overflowY="auto" p={3}>
<Box fontWeight="bold" mb={4} color="black"> <Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}>
New Channel {'<- Back'}
</Box> </Box>
<Formik <Box fontWeight="bold" mb={4} color="black">
validationSchema={formSchema} New Channel
initialValues={{ </Box>
moduleType: 'chat', <Formik
name: '', validationSchema={formSchema(group, groups)}
description: '', initialValues={{
group: '', moduleType: 'chat',
ships: [] name: '',
}} description: '',
onSubmit={onSubmit} group: '',
> ships: [],
<Form> writers: []
<Col }}
maxWidth="348px" onSubmit={onSubmit}
gapY="4" >
> { ({ errors, values }) => <Form>
<Col gapY="2"> <Col
<Box color="black" mb={2}>Channel Type</Box> maxWidth="348px"
<Radio label="Chat" id="chat" name="moduleType" /> gapY="4"
<Radio label="Notebook" id="publish" name="moduleType" /> >
<Radio label="Collection" id="link" name="moduleType" /> <Col gapY="2">
</Col> <Box color="black" mb={2}>Channel Type</Box>
<Input <Radio label="Chat" id="chat" name="moduleType" />
id="name" <Radio label="Notebook" id="publish" name="moduleType" />
label="Name" <Radio label="Collection" id="link" name="moduleType" />
caption="Provide a name for your channel" </Col>
placeholder="eg. My Channel" <Input
/> id="name"
<Input label="Name"
id="description" caption="Provide a name for your channel"
label="Description" placeholder="eg. My Channel"
caption="What's your channel about?" />
placeholder="Channel description" <Input
/> id="description"
{(workspace?.type === 'home') && label="Description"
<ShipSearch caption="What's your channel about?"
groups={props.groups} placeholder="Channel description"
contacts={props.contacts} />
id="ships" {(workspace?.type === 'home') &&
label="Invitees" <ShipSearch
/>} groups={props.groups}
<Box justifySelf="start"> contacts={props.contacts}
<AsyncButton id="ships"
primary label="Invitees"
loadingText="Creating..." />}
type="submit" {(workspace?.type !== 'home' && values.moduleType === 'publish') &&
border <>
> <ShipSearch
Create Channel groups={props.groups}
</AsyncButton> contacts={props.contacts}
</Box> caption="Add writers to restrict who can write to this
<FormError message="Channel creation failed" /> notebook, or leave blank to allow all group members to write"
</Col> id="writers"
</Form> label="Writers"
</Formik> />
</Col> {errors.writers &&
); <>
} <Row>
<Icon
color='white'
mr='2'
backgroundColor='red'
borderRadius='999px'
icon="ExclaimationMarkBold"
/>
<Text color='red'>
{Array.from(new Set([...errors.writers]))}
</Text>
</Row>
</>
}
</>}
<Box justifySelf="start">
<AsyncButton
primary
loadingText="Creating..."
type="submit"
border
>
Create Channel
</AsyncButton>
</Box>
<FormError message="Channel creation failed" />
</Col>
</Form>}
</Formik>
</Col>
);
}

View File

@ -78,8 +78,8 @@ export function NewGroup(props: NewGroupProps & RouteComponentProps) {
); );
return ( return (
<Body> <>
<Col maxWidth="300px" overflowY="auto" p="3"> <Col overflowY="auto" p="3">
<Box mb={3}> <Box mb={3}>
<Text fontWeight="bold">New Group</Text> <Text fontWeight="bold">New Group</Text>
</Box> </Box>
@ -112,6 +112,6 @@ export function NewGroup(props: NewGroupProps & RouteComponentProps) {
</Form> </Form>
</Formik> </Formik>
</Col> </Col>
</Body> </>
); );
} }

View File

@ -347,20 +347,20 @@ function Participant(props: {
</Action> </Action>
{props.role === 'admin' && ( {props.role === 'admin' && (
<> <>
{!isInvite && ( {(!isInvite && contact.patp !== window.ship) && (
<StatelessAsyncAction onClick={onBan} bg="transparent"> <StatelessAsyncAction onClick={onBan} bg="transparent">
<Text color="red">Ban from {title}</Text> <Text color="red">Ban from {title}</Text>
</StatelessAsyncAction> </StatelessAsyncAction>
)} )}
{role === 'admin' ? ( {role === 'admin' ? (
<StatelessAsyncAction onClick={onDemote} bg="transparent"> group?.tags?.role?.admin?.size > 1 && (<StatelessAsyncAction onClick={onDemote} bg="transparent">
Demote from Admin Demote from Admin
</StatelessAsyncAction> </StatelessAsyncAction>)
) : ( ) : (
<> <>
<StatelessAsyncAction onClick={onKick} bg="transparent"> {(contact.patp !== window.ship) && (<StatelessAsyncAction onClick={onKick} bg="transparent">
<Text color="red">Kick from {title}</Text> <Text color="red">Kick from {title}</Text>
</StatelessAsyncAction> </StatelessAsyncAction>)}
<StatelessAsyncAction onClick={onPromote} bg="transparent"> <StatelessAsyncAction onClick={onPromote} bg="transparent">
Promote to Admin Promote to Admin
</StatelessAsyncAction> </StatelessAsyncAction>

View File

@ -144,6 +144,8 @@ export function PopoverRoutes(
contact={props.contacts[window.ship]} contact={props.contacts[window.ship]}
rootIdentity={props.rootIdentity} rootIdentity={props.rootIdentity}
api={props.api} api={props.api}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
path={props.association["group-path"]} path={props.association["group-path"]}
s3={props.s3} s3={props.s3}
/> />

View File

@ -12,6 +12,8 @@ import { NewGroup } from './components/NewGroup';
import { JoinGroup } from './components/JoinGroup'; import { JoinGroup } from './components/JoinGroup';
import { cite } from '~/logic/lib/util'; import { cite } from '~/logic/lib/util';
import { Body } from '../components/Body';
import { Box } from '@tlon/indigo-react';
type LandscapeProps = StoreState & { type LandscapeProps = StoreState & {
@ -95,12 +97,16 @@ export default class Landscape extends Component<LandscapeProps, {}> {
<Route path="/~landscape/new" <Route path="/~landscape/new"
render={routeProps=> { render={routeProps=> {
return ( return (
<NewGroup <Body>
groups={props.groups} <Box maxWidth="300px">
contacts={props.contacts} <NewGroup
api={props.api} groups={props.groups}
{...routeProps} contacts={props.contacts}
/> api={props.api}
{...routeProps}
/>
</Box>
</Body>
); );
}} }}
/> />
@ -115,13 +121,17 @@ export default class Landscape extends Component<LandscapeProps, {}> {
const { ship, name } = routeProps.match.params; const { ship, name } = routeProps.match.params;
const autojoin = ship && name ? `${ship}/${name}` : null; const autojoin = ship && name ? `${ship}/${name}` : null;
return ( return (
<JoinGroup <Body>
groups={props.groups} <Box maxWidth="300px">
contacts={props.contacts} <JoinGroup
api={props.api} groups={props.groups}
autojoin={autojoin} contacts={props.contacts}
{...routeProps} api={props.api}
/> autojoin={autojoin}
{...routeProps}
/>
</Box>
</Body>
); );
}} }}
/> />