Merge pull request #3766 from urbit/lf/hark-redux

hark: notification store
This commit is contained in:
matildepark 2020-11-10 16:13:06 -05:00 committed by GitHub
commit fa71638abd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 4191 additions and 665 deletions

View File

@ -5,7 +5,7 @@
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0v3.29n7b.04srk.3pcv0.1ld5v.vl1io
++ hash 0v6.9vk2h.hr87m.nn63p.8kmo5.k4ljt
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

View File

@ -560,6 +560,14 @@
|^
?> (team:title our.bowl src.bowl)
?+ path (on-peek:def path)
[%x %graph-mark @ @ ~]
=/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path
=/ result=(unit marked-graph:store)
(~(get by graphs) [ship term])
?~ result [~ ~]
``noun+!>(q.u.result)
::
[%x %keys ~]
:- ~ :- ~ :- %graph-update
!>(`update:store`[%0 now.bowl [%keys ~(key by graphs)]])

View File

@ -0,0 +1,180 @@
:: hark-chat-hook: notifications for chat-store [landscape]
::
/- store=hark-store, post, group-store, metadata-store, hook=hark-chat-hook
/+ resource, metadata, default-agent, dbug, chat-store
::
~% %hark-chat-hook-top ..is ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state-0
==
::
+$ state-0
$: %0
watching=(set path)
mentions=_&
==
::
--
::
=| state-0
=* state -
::
=>
|_ =bowl:gall
::
++ give
|= [paths=(list path) =update:hook]
^- (list card)
[%give %fact paths hark-chat-hook-update+!>(update)]~
::
++ watch-chat
^- card
[%pass /chat %agent [our.bowl %chat-store] %watch /updates]
--
%- agent:dbug
^- agent:gall
~% %hark-chat-hook-agent ..card ~
|_ =bowl:gall
+* this .
ha ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
met ~(. metadata bowl)
::
++ on-init
:_ this
~[watch-chat:ha]
::
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
`this(state !<(state-0 old))
::
++ on-watch
|= =path
^- (quip card _this)
=^ cards state
?+ path (on-watch:def path)
::
[%updates ~]
:_ state
%+ give:ha ~
:* %initial
watching
==
==
[cards this]
::
++ on-poke
~/ %hark-chat-hook-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-chat-hook-action
(hark-chat-hook-action !<(action:hook vase))
==
[cards this]
::
++ hark-chat-hook-action
|= =action:hook
^- (quip card _state)
|^
:- (give:ha ~[/updates] action)
?- -.action
%listen (listen +.action)
%ignore (ignore +.action)
%set-mentions (set-mentions +.action)
==
++ listen
|= chat=path
^+ state
state(watching (~(put in watching) chat))
::
++ ignore
|= chat=path
^+ state
state(watching (~(del in watching) chat))
::
++ set-mentions
|= ment=?
^+ state
state(mentions ment)
--
--
::
++ on-agent
~/ %hark-chat-hook-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
|^
?+ -.sign (on-agent:def wire sign)
%kick
:_ this
?. ?=([%chat ~] wire)
~
~[watch-chat:ha]
::
%fact
?. ?=(%chat-update p.cage.sign)
(on-agent:def wire sign)
=^ cards state
(chat-update !<(update:chat-store q.cage.sign))
[cards this]
==
::
++ chat-update
|= =update:chat-store
^- (quip card _state)
:_ state
?+ -.update ~
%message (process-envelope path.update envelope.update)
::
%messages
%- zing
(turn envelopes.update (cury process-envelope path.update))
==
++ is-mention
|= =envelope:chat-store
?. ?=(%text -.letter.envelope) %.n
?& mentions
?= ^
(find (scow %p our.bowl) (trip text.letter.envelope))
==
::
++ is-notification
|= [=path =envelope:chat-store]
?& (~(has in watching) path)
!=(author.envelope our.bowl)
==
::
++ process-envelope
|= [=path =envelope:chat-store]
^- (list card)
=/ mention=?
(is-mention envelope)
?. ?|(mention (is-notification path envelope))
~
=/ =index:store
[%chat path mention]
=/ =contents:store
[%chat ~[envelope]]
~[(poke-store %add index when.envelope %.n contents)]
::
++ poke-store
|= =action:store
^- card
=- [%pass /store %agent [our.bowl %hark-store] %poke -]
hark-action+!>(action)
--
::
++ on-peek on-peek:def
::
++ on-leave on-leave:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

View File

@ -0,0 +1,256 @@
:: hark-graph-hook: notifications for graph-store [landscape]
::
/- store=hark-store, post, group-store, metadata-store, hook=hark-graph-hook
/+ resource, metadata, default-agent, dbug, graph-store
::
~% %hark-graph-hook-top ..is ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state-0
==
::
+$ state-0
$: %0
watching=(set resource)
mentions=_&
watch-on-self=_&
==
::
--
::
=| state-0
=* state -
::
=>
|_ =bowl:gall
::
++ scry
|* [=mold p=path]
?> ?=(^ p)
?> ?=(^ t.p)
.^(mold i.p (scot %p our.bowl) i.t.p (scot %da now.bowl) t.t.p)
::
++ give
|= [paths=(list path) =update:hook]
^- (list card)
[%give %fact paths hark-graph-hook-update+!>(update)]~
::
++ watch-graph
^- card
[%pass /graph %agent [our.bowl %graph-store] %watch /updates]
--
%- agent:dbug
^- agent:gall
~% %hark-graph-hook-agent ..card ~
|_ =bowl:gall
+* this .
ha ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
met ~(. metadata bowl)
::
++ on-init
:_ this
~[watch-graph:ha]
::
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
`this(state !<(state-0 old))
::
++ on-watch
|= =path
^- (quip card _this)
=^ cards state
?+ path (on-watch:def path)
::
[%updates ~]
:_ state
%+ give:ha ~
:* %initial
watching
mentions
watch-on-self
==
==
[cards this]
::
++ on-poke
~/ %hark-graph-hook-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-graph-hook-action
(hark-graph-hook-action !<(action:hook vase))
==
[cards this]
::
++ hark-graph-hook-action
|= =action:hook
^- (quip card _state)
|^
:- (give:ha ~[/updates] action)
?- -.action
%listen (listen +.action)
%ignore (ignore +.action)
%set-mentions (set-mentions +.action)
%set-watch-on-self (set-watch-on-self +.action)
==
++ listen
|= graph=resource
^+ state
state(watching (~(put in watching) graph))
::
++ ignore
|= graph=resource
^+ state
state(watching (~(del in watching) graph))
::
++ set-mentions
|= ment=?
^+ state
state(mentions ment)
::
++ set-watch-on-self
|= self=?
^+ state
state(watch-on-self self)
--
--
::
++ on-agent
~/ %hark-graph-hook-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
|^
?+ -.sign (on-agent:def wire sign)
%kick
:_ this
?. ?=([%graph ~] wire)
~
~[watch-graph:ha]
::
%fact
?. ?=(%graph-update p.cage.sign)
(on-agent:def wire sign)
=^ cards state
(graph-update !<(update:graph-store q.cage.sign))
[cards this]
==
::
++ graph-update
|= =update:graph-store
^- (quip card _state)
?. ?=(%add-nodes -.q.update)
[~ state]
=/ group=resource
(need (group-from-app-resource:met %graph resource.q.update))
=/ =metadata:metadata-store
(need (peek-metadata:met %graph group resource.q.update))
=* rid resource.q.update
=+ %+ scry:ha
,mark=(unit mark)
/gx/graph-store/graph-mark/(scot %p entity.rid)/[name.rid]/noun
=+ %+ scry:ha
,=tube:clay
/cc/[q.byk.bowl]/[(fall mark %graph-validator-link)]/notification-kind
=/ nodes=(list [p=index:graph-store q=node:graph-store])
~(tap by nodes.q.update)
=| cards=(list card)
|^
?~ nodes
[cards state]
=* index p.i.nodes
=* node q.i.nodes
=^ node-cards state
(check-node node tube)
%_ $
nodes t.nodes
cards (weld node-cards cards)
==
::
++ check-node-children
|= [=node:graph-store =tube:clay]
^- (quip card _state)
?: ?=(%empty -.children.node)
[~ state]
=/ children=(list [=atom =node:graph-store])
(tap:orm:graph-store p.children.node)
=| cards=(list card)
|- ^- (quip card _state)
?~ children
[cards state]
=^ new-cards state
(check-node node.i.children tube)
%_ $
cards (weld cards new-cards)
children t.children
==
::
++ check-node
|= [=node:graph-store =tube:clay]
^- (quip card _state)
=^ child-cards state
(check-node-children node tube)
?: =(our.bowl author.post.node)
=^ self-cards state
(self-post node)
:_ state
(weld child-cards self-cards)
=+ !<(notif-kind=(unit @t) (tube !>([0 post.node])))
?~ notif-kind
[child-cards state]
=/ desc=@t
?: (is-mention contents.post.node)
%mention
u.notif-kind
?. ?| =(desc %mention)
(~(has in watching) rid)
==
[child-cards state]
=/ notif-index=index:store
[%graph group rid module.metadata desc]
=/ =contents:store
[%graph (limo post.node ~)]
:_ state
%+ snoc child-cards
(add-unread notif-index [time-sent.post.node %.n contents])
::
++ is-mention
|= contents=(list content:post)
^- ?
?. mentions %.n
?~ contents %.n
?. ?=(%mention -.i.contents)
$(contents t.contents)
?: =(our.bowl ship.i.contents)
%.y
$(contents t.contents)
::
++ self-post
|= =node:graph-store
^- (quip card _state)
?. ?=(%.y watch-on-self)
[~ state]
`state(watching (~(put in watching) rid))
::
++ add-unread
|= [=index:store =notification:store]
^- card
=- [%pass / %agent [our.bowl %hark-store] %poke -]
hark-action+!>([%add index notification])
::
--
--
::
++ on-peek on-peek:def
::
++ on-leave on-leave:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

View File

@ -0,0 +1,169 @@
:: hark-group-hook: notifications for groups [landscape]
::
/- store=hark-store, post, group-store, metadata-store, hook=hark-group-hook
/+ resource, metadata, default-agent, dbug, graph-store
::
~% %hark-group-hook-top ..is ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state-0
==
::
+$ state-0
$: %0
watching=(set resource)
==
::
--
::
=| state-0
=* state -
::
=<
%- agent:dbug
^- agent:gall
~% %hark-group-hook-agent ..card ~
|_ =bowl:gall
+* this .
ha ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
met ~(. metadata bowl)
::
++ on-init
:_ this
:~ watch-metadata:ha
watch-groups:ha
==
::
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
`this(state !<(state-0 old))
::
++ on-watch
|= =path
?. ?=([%updates ~] path)
(on-watch:def path)
:_ this
=; =cage
[%give %fact ~[/updates] cage]~
:- %hark-group-hook-update
!> ^- update:hook
[%initial watching]
::
++ on-poke
~/ %hark-group-hook-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-group-hook-action
(hark-group-hook-action !<(action:hook vase))
==
[cards this]
::
++ hark-group-hook-action
|= =action:hook
^- (quip card _state)
|^
?- -.action
%listen (listen +.action)
%ignore (ignore +.action)
==
++ listen
|= group=resource
^- (quip card _state)
:- (give %listen group)
state(watching (~(put in watching) group))
::
++ ignore
|= group=resource
^- (quip card _state)
:- (give %ignore group)
state(watching (~(del in watching) group))
::
++ give
|= =update:hook
^- (list card)
[%give %fact ~[/updates] %hark-group-hook-update !>(update)]~
--
--
::
++ on-agent
~/ %hark-group-hook-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
|^
?+ -.sign (on-agent:def wire sign)
%kick
:_ this
?+ wire ~
[%group ~] ~[watch-groups:ha]
[%metadata ~] ~[watch-metadata:ha]
==
::
%fact
?+ p.cage.sign (on-agent:def wire sign)
%group-update
=^ cards state
(group-update !<(update:group-store q.cage.sign))
[cards this]
::
%metadata-update
=^ cards state
(metadata-update !<(metadata-update:metadata-store q.cage.sign))
[cards this]
==
==
::
++ group-update
|= =update:group-store
^- (quip card _state)
?. ?=(?(%add-members %remove-members) -.update)
[~ state]
?. (~(has in watching) resource.update)
[~ state]
=/ =contents:store
[%group ~[update]]
=/ =notification:store [now.bowl %.n contents]
=/ =index:store
[%group resource.update -.update]
:_ state
~[(add-unread index notification)]
:: +metadata-update is stubbed for now, for the following reasons
:: - There's no semantic difference in metadata-store between
:: adding and editing a channel
:: - We have no way of retrieving old metadata to e.g. get a
:: channel's old name when it is renamed
++ metadata-update
|= update=metadata-update:metadata-store
^- (quip card _state)
[~ state]
::
++ add-unread
|= [=index:store =notification:store]
^- card
=- [%pass / %agent [our.bowl %hark-store] %poke -]
hark-action+!>([%add index notification])
--
::
++ on-peek on-peek:def
++ on-leave on-leave:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--
|_ =bowl:gall
+* met ~(. metadata bowl)
::
++ watch-groups
^- card
[%pass /group %agent [our.bowl %group-store] %watch /groups]
::
++ watch-metadata
^- card
[%pass /metadata %agent [our.bowl %metadata-store] %watch /updates]
--

View File

@ -0,0 +1,313 @@
:: hark-store: notifications [landscape]
::
/- store=hark-store, post, group-store, metadata-store
/+ resource, metadata, default-agent, dbug, graph-store
::
~% %hark-store-top ..is ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state-0
==
::
+$ state-0
$: %0
=notifications:store
archive=notifications:store
last-seen=@da
dnd=?
==
+$ inflated-state
$: state-0
cache
==
:: $cache: useful to have precalculated, but can be derived from state
:: albeit expensively
+$ cache
$: unread-count=@ud
~
==
::
++ orm ((ordered-map @da timebox:store) lth)
--
::
=| inflated-state
=* state -
::
=<
%- agent:dbug
^- agent:gall
~% %hark-store-agent ..card ~
|_ =bowl:gall
+* this .
ha ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
met ~(. metadata bowl)
::
++ on-init
:_ this
~[autoseen-timer]
::
++ on-save !>(-.state)
++ on-load
|= =old=vase
^- (quip card _this)
=/ old
!<(state-0 old-vase)
`this(-.state old, +.state (inflate-cache old))
::
++ on-watch
|= =path
^- (quip card _this)
|^
?+ path (on-watch:def path)
::
[%updates ~]
:_ this
[%give %fact ~ hark-update+!>(initial-updates)]~
==
::
++ initial-updates
^- update:store
:- %more
^- (list update:store)
:+ [%set-dnd dnd]
[%count unread-count]
%+ weld
%+ turn
(tap-nonempty archive)
(timebox-update &)
%+ turn
(tap-nonempty notifications)
(timebox-update |)
::
++ timebox-update
|= archived=?
|= [time=@da =timebox:store]
^- update:store
[%timebox time archived ~(tap by timebox)]
::
++ tap-nonempty
|= =notifications:store
^- (list [@da timebox:store])
%+ skip (tap:orm notifications)
|=([@da =timebox:store] =(0 ~(wyt by timebox)))
--
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path (on-peek:def path)
::
[%x %recent @ @ ~]
=/ offset=@ud
(slav %ud i.t.t.path)
=/ length=@ud
(slav %ud i.t.t.t.path)
:^ ~ ~ %noun
!> ^- update:store
:- %more
%+ turn
(scag length (slag offset (tap:orm notifications)))
|= [time=@da =timebox:store]
^- update:store
:^ %timebox time %.n
~(tap by timebox)
==
::
++ on-poke
~/ %hark-store-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-action (hark-action !<(action:store vase))
==
[cards this]
::
++ hark-action
|= =action:store
^- (quip card _state)
|^
?- -.action
%add (add +.action)
%archive (do-archive +.action)
%seen seen
%read (read +.action)
%unread (unread +.action)
%set-dnd (set-dnd +.action)
==
++ add
|= [=index:store =notification:store]
^- (quip card _state)
=/ =timebox:store
(gut-orm:ha notifications last-seen)
=/ existing-notif
(~(get by timebox) index)
=/ new=notification:store
?~ existing-notif
notification
(merge-notification:ha u.existing-notif notification)
=/ new-timebox=timebox:store
(~(put by timebox) index new)
:- (give:ha [/updates]~ %added last-seen index new)
%_ state
notifications (put:orm notifications last-seen new-timebox)
unread-count ?~(existing-notif +(unread-count) unread-count)
==
::
++ do-archive
|= [time=@da =index:store]
^- (quip card _state)
=/ =timebox:store
(gut-orm:ha notifications time)
=/ =notification:store
(~(got by timebox) index)
=/ new-timebox=timebox:store
(~(del by timebox) index)
:- (give:ha [/updates]~ %archive time index)
%_ state
unread-count ?.(read.notification (dec unread-count) unread-count)
::
notifications
(put:orm notifications time new-timebox)
::
archive
%^ jub-orm:ha archive time
|= archive-box=timebox:store
^- timebox:store
(~(put by archive-box) index notification(read %.y))
==
::
++ read
|= [time=@da =index:store]
^- (quip card _state)
:- (give:ha [/updates]~ %read time index)
%_ state
unread-count (dec unread-count)
notifications (change-read-status:ha time index %.y)
==
::
++ unread
|= [time=@da =index:store]
^- (quip card _state)
:- (give:ha [/updates]~ %unread time index)
%_ state
unread-count +(unread-count)
notifications (change-read-status:ha time index %.n)
==
::
++ seen
^- (quip card _state)
:_ state(last-seen now.bowl)
:~ cancel-autoseen:ha
autoseen-timer:ha
==
::
++ set-dnd
|= d=?
^- (quip card _state)
:_ state(dnd d)
(give:ha [/updates]~ %set-dnd d)
--
--
::
++ on-agent on-agent:def
::
++ on-leave on-leave:def
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?. ?=([%autoseen ~] wire)
(on-arvo:def wire sign-arvo)
?> ?=([%b %wake *] sign-arvo)
:_ this(last-seen now.bowl)
~[autoseen-timer:ha]
::
++ on-fail on-fail:def
--
|_ =bowl:gall
+* met ~(. metadata bowl)
::
++ merge-notification
|= [existing=notification:store new=notification:store]
^- notification:store
?- -.contents.existing
::
%chat
?> ?=(%chat -.contents.new)
existing(list.contents (weld list.contents.existing list.contents.new))
::
%graph
?> ?=(%graph -.contents.new)
existing(list.contents (weld list.contents.existing list.contents.new))
::
%group
?> ?=(%group -.contents.new)
existing(list.contents (weld list.contents.existing list.contents.new))
==
::
++ change-read-status
|= [time=@da =index:store read=?]
^+ notifications
%^ jub-orm notifications time
|= =timebox:store
%+ ~(jab by timebox) index
|= =notification:store
?> !=(read read.notification)
notification(read read)
:: +key-orm: +key:by for ordered maps
++ key-orm
|= =notifications:store
^- (list @da)
(turn (tap:orm notifications) |=([key=@da =timebox:store] key))
:: +jub-orm: combo +jab/+gut for ordered maps
:: TODO: move to zuse.hoon
++ jub-orm
|= [=notifications:store time=@da fun=$-(timebox:store timebox:store)]
^- notifications:store
=/ =timebox:store
(fun (gut-orm notifications time))
(put:orm notifications time timebox)
:: +gut-orm: +gut:by for ordered maps
:: TODO: move to zuse.hoon
++ gut-orm
|= [=notifications:store time=@da]
^- timebox:store
(fall (get:orm notifications time) ~)
::
++ autoseen-interval ~h3
++ cancel-autoseen
^- card
[%pass /autoseen %arvo %b %rest (add last-seen autoseen-interval)]
::
++ autoseen-timer
^- card
[%pass /autoseen %arvo %b %wait (add now.bowl autoseen-interval)]
::
++ give
|= [paths=(list path) update=update:store]
^- (list card)
[%give %fact paths [%hark-update !>(update)]]~
::
++ inflate-cache
|= state-0
^- cache
:_ ~
%+ roll
(tap:orm notifications)
|= [[time=@da =timebox:store] out=@ud]
=/ unreads ~(tap by timebox)
|-
?~ unreads out
=* notification q.i.unreads
?: read.notification
out
%_ $
unreads t.unreads
::
out +(out)
==
--

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.20fe6ee95061ade450fc.js"></script>
<script src="/~landscape/js/bundle/index.5f9890df6be59a4ff9c5.js"></script>
</body>
</html>

View File

@ -34,6 +34,78 @@
++ enjs
=, enjs:format
|%
::
++ signatures
|= s=^signatures
^- json
[%a (turn ~(tap in s) signature)]
::
++ signature
|= s=^signature
^- json
%- pairs
:~ [%signature s+(scot %ux p.s)]
[%ship (ship q.s)]
[%life (numb r.s)]
==
::
++ index
|= i=^index
^- json
=/ j=^tape ""
|-
?~ i [%s (crip j)]
=/ k=json (numb i.i)
?> ?=(%n -.k)
%_ $
i t.i
j (weld j (weld "/" (trip +.k)))
==
::
++ uid
|= u=^uid
^- json
%- pairs
:~ [%resource (enjs:res resource.u)]
[%index (index index.u)]
==
::
++ content
|= c=^content
^- json
?- -.c
%mention (frond %mention (ship ship.c))
%text (frond %text s+text.c)
%url (frond %url s+url.c)
%reference (frond %reference (uid uid.c))
%code
%+ frond %code
%- pairs
:- [%expression s+expression.c]
:_ ~
:- %output
:: virtualize output rendering, +tank:enjs:format might crash
::
=/ result=(each (list json) tang)
(mule |.((turn output.c tank)))
?- -.result
%& a+p.result
%| a+[a+[%s '[[output rendering error]]']~]~
==
==
::
++ post
|= p=^post
^- json
%- pairs
:~ [%author (ship author.p)]
[%index (index index.p)]
[%time-sent (time time-sent.p)]
[%contents [%a (turn contents.p content)]]
[%hash ?~(hash.p ~ s+(scot %ux u.hash.p))]
[%signatures (signatures signatures.p)]
==
::
++ update
|= upd=^update
^- json
@ -132,20 +204,6 @@
:~ (index [a]~)
(node n)
==
::
++ index
|= i=^index
^- json
=/ j=^tape ""
|-
?~ i [%s (crip j)]
=/ k=json (numb i.i)
?> ?=(%n -.k)
%_ $
i t.i
j (weld j (weld "/" (trip +.k)))
==
::
++ node
|= n=^node
^- json
@ -158,41 +216,7 @@
==
==
::
++ post
|= p=^post
^- json
%- pairs
:~ [%author (ship author.p)]
[%index (index index.p)]
[%time-sent (time time-sent.p)]
[%contents [%a (turn contents.p content)]]
[%hash ?~(hash.p ~ s+(scot %ux u.hash.p))]
[%signatures (signatures signatures.p)]
==
::
++ content
|= c=^content
^- json
?- -.c
%text (frond %text s+text.c)
%url (frond %url s+url.c)
%reference (frond %reference (uid uid.c))
%code
%+ frond %code
%- pairs
:- [%expression s+expression.c]
:_ ~
:- %output
:: virtualize output rendering, +tank:enjs:format might crash
::
=/ result=(each (list json) tang)
(mule |.((turn output.c tank)))
?- -.result
%& a+p.result
%| a+[a+[%s '[[output rendering error]]']~]~
==
==
::
::
++ nodes
|= m=(map ^index ^node)
^- json
@ -210,27 +234,6 @@
^- json
[%a (turn ~(tap in i) index)]
::
++ uid
|= u=^uid
^- json
%- pairs
:~ [%resource (enjs:res resource.u)]
[%index (index index.u)]
==
::
++ signatures
|= s=^signatures
^- json
[%a (turn ~(tap in s) signature)]
::
++ signature
|= s=^signature
^- json
%- pairs
:~ [%signature s+(scot %ux p.s)]
[%ship (ship q.s)]
[%life (numb r.s)]
==
--
--
::
@ -322,7 +325,8 @@
::
++ content
%- of
:~ [%text so]
:~ [%mention (su ;~(pfix sig fed:ag))]
[%text so]
[%url so]
[%reference uid]
[%code eval]

View File

@ -0,0 +1,30 @@
/- sur=hark-chat-hook
^?
=< [. sur]
=, sur
|%
++ dejs
=, dejs:format
|%
++ action
%- of
:~ listen+pa
ignore+pa
set-mentions+bo
==
--
::
++ enjs
=, enjs:format
|%
++ update
|= upd=^update
%+ frond -.upd
?- -.upd
?(%listen %ignore) (path chat.upd)
%set-mentions b+mentions.upd
%initial a+(turn ~(tap in watching.upd) path)
==
--
--

View File

@ -0,0 +1,61 @@
/- sur=hark-graph-hook
/+ graph-store, resource
^?
=< [. sur]
=, sur
|%
++ dejs
=, dejs:format
|%
++ graph-indices
%- ot
:~ graph+dejs-path:resource
indices+(as graph-index)
==
::
++ graph-index
^- $-(json index:graph-store)
(su ;~(pfix net (more net dem)))
::
++ action
%- of
:~ listen+dejs-path:resource
ignore+dejs-path:resource
set-mentions+bo
set-watch-on-self+bo
==
--
::
++ enjs
=, enjs:format
|%
++ graph-indices
|= [graph=resource indices=(set index:graph-store)]
%- pairs
:~ graph+s+(enjs-path:resource graph)
indices+a+(turn ~(tap in indices) index:enjs:graph-store)
==
::
++ action
|= act=^action
^- json
%+ frond -.act
?- -.act
%set-watch-on-self b+watch-on-self.act
%set-mentions b+mentions.act
?(%listen %ignore) s+(enjs-path:resource graph.act)
==
::
++ update
|= upd=^update
^- json
?. ?=(%initial -.upd)
(action upd)
%+ frond -.upd
%- pairs
:~ 'watchOnSelf'^b+watch-on-self.upd
'mentions'^b+mentions.upd
watching+a+(turn ~(tap in watching.upd) |=(r=resource s+(enjs-path:resource r)))
==
--
--

View File

@ -0,0 +1,34 @@
/- sur=hark-group-hook
/+ resource
^?
=< [. sur]
=, sur
|%
++ dejs
=, dejs:format
|%
++ action
%- of
:~ listen+dejs-path:resource
ignore+dejs-path:resource
==
--
::
++ enjs
=, enjs:format
|%
++ res
(cork enjs-path:resource (lead %s))
::
++ update
|= upd=^update
%+ frond -.upd
?- -.upd
?(%listen %ignore) (res group.upd)
::
%initial
:- %a
(turn ~(tap in watching.upd) res)
==
--
--

View File

@ -0,0 +1,212 @@
/- sur=hark-store, post
/+ resource, graph-store, group-store, chat-store
^?
=< [. sur]
=, sur
|%
++ dejs
=, dejs:format
|%
++ index
%- of
:~ graph+graph-index
group+group-index
chat+chat-index
==
::
++ chat-index
%- ot
:~ chat+pa
mention+bo
==
::
++ group-index
%- ot
:~ group+dejs-path:resource
description+so
==
::
++ graph-index
%- ot
:~ group+dejs-path:resource
graph+dejs-path:resource
module+so
description+so
==
:: parse date as @ud
:: TODO: move to zuse
++ sd
|= jon=json
^- @da
?> ?=(%s -.jon)
`@da`(rash p.jon dem:ag)
::
++ notif-ref
^- $-(json [@da ^index])
%- ot
:~ time+sd
index+index
==
::
++ add
|= jon=json
[*^index *notification]
::
++ action
^- $-(json ^action)
%- of
:~ seen+ul
archive+notif-ref
unread+notif-ref
read+notif-ref
add+add
set-dnd+bo
==
--
::
++ enjs
=, enjs:format
|%
++ update
|= upd=^update
^- json
|^
%+ frond -.upd
?+ -.upd a+~
%added (added +.upd)
%timebox (timebox +.upd)
%set-dnd b+dnd.upd
%count (numb count.upd)
%more (more +.upd)
::
?(%archive %read %unread)
(notif-ref +.upd)
==
::
++ added
|= [tim=@da idx=^index not=^notification]
^- json
%- pairs
:~ time+s+(scot %ud tim)
index+(index idx)
notification+(notification not)
==
::
++ notif-ref
|= [tim=@da idx=^index]
^- json
%- pairs
:~ time+s+(scot %ud tim)
index+(index idx)
==
::
++ more
|= upds=(list ^update)
^- json
a+(turn upds update)
::
++ index
|= =^index
%+ frond -.index
|^
?- -.index
%graph (graph-index +.index)
%group (group-index +.index)
%chat (chat-index +.index)
==
::
++ chat-index
|= [chat=^path mention=?]
^- json
%- pairs
:~ chat+(path chat)
mention+b+mention
==
::
++ graph-index
|= [group=resource graph=resource module=@t description=@t]
^- json
%- pairs
:~ group+s+(enjs-path:resource group)
graph+s+(enjs-path:resource graph)
module+s+module
description+s+description
==
::
++ group-index
|= [group=resource description=@t]
^- json
%- pairs
:~ group+s+(enjs-path:resource group)
description+s+description
==
--
::
++ notification
|= ^notification
^- json
%- pairs
:~ time+(time date)
read+b+read
contents+(^contents contents)
==
::
++ contents
|= =^contents
^- json
%+ frond -.contents
|^
?- -.contents
%graph (graph-contents +.contents)
%group (group-contents +.contents)
%chat (chat-contents +.contents)
==
::
++ chat-contents
|= =(list envelope:chat-store)
^- json
:- %a
(turn list envelope:enjs:chat-store)
::
++ graph-contents
|= =(list post:post)
^- json
:- %a
(turn list post:enjs:graph-store)
::
++ group-contents
|= =(list ^group-contents)
^- json
:- %a
%+ murn list
|= =^group-contents
?. ?=(?(%add-members %remove-members) -.group-contents)
~
`(update:enjs:group-store group-contents)
--
::
++ indexed-notification
|= [=^index =^notification]
%- pairs
:~ index+(^index index)
notification+(^notification notification)
==
::
++ timebox
|= [tim=@da arch=? l=(list [^index ^notification])]
^- json
%- pairs
:~ time+s+(scot %ud tim)
archive+b+arch
:- %notifications
^- json
:- %a
%+ turn l
|= [=^index =^notification]
^- json
(indexed-notification index notification)
==
--
--
--

View File

@ -107,6 +107,10 @@
%graph-store
%graph-pull-hook
%graph-push-hook
%hark-store
%hark-graph-hook
%hark-group-hook
%hark-chat-hook
%observe-hook
==
::
@ -210,7 +214,7 @@
==
::
++ on-load
|= [hood-version=?(%1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11) old=any-state]
|= [hood-version=@ud old=any-state]
=< se-abet =< se-view
=. sat old
=. dev (~(gut by bin) ost *source)
@ -243,7 +247,11 @@
=> (se-born | %home %graph-push-hook)
(se-born | %home %graph-pull-hook)
=? ..on-load (lte hood-version %11)
(se-born | %home %observe-hook)
=> (se-born | %home %hark-graph-hook)
=> (se-born | %home %hark-group-hook)
=> (se-born | %home %hark-chat-hook)
=> (se-born | %home %hark-store)
(se-born | %home %observe-hook)
..on-load
::
++ reap-phat :: ack connect

View File

@ -1,6 +1,7 @@
:: metadata: helpers for getting data from the metadata-store
::
/- *metadata-store
/+ res=resource
::
|_ =bowl:gall
++ app-paths-from-group
@ -21,6 +22,27 @@
?. =(app-name.md-resource app-name) ~
`app-path.md-resource
::
++ peek-metadata
|= [app-name=term =group=resource:res =app=resource:res]
^- (unit metadata)
=/ group-cord=cord (scot %t (spat (en-path:res group-resource)))
=/ app-cord=cord (scot %t (spat (en-path:res app-resource)))
=/ our=cord (scot %p our.bowl)
=/ now=cord (scot %da now.bowl)
.^ (unit metadata)
%gx (scot %p our.bowl) %metadata-store (scot %da now.bowl)
%metadata group-cord app-name app-cord /noun
==
::
++ group-from-app-resource
|= [app=term =app=resource:res]
^- (unit resource:res)
=/ app-path (en-path:res app-resource)
=/ group-paths (groups-from-resource app app-path)
?~ group-paths
~
`(de-path:res i.group-paths)
::
++ groups-from-resource
|= =md-resource
^- (list group-path)

View File

@ -37,6 +37,13 @@
%- spat
(en-path resource)
::
++ dejs-path
%- su:dejs:format
;~ pfix
(jest '/ship/')
;~((glue fas) ;~(pfix sig fed:ag) urs:ab)
==
::
++ dejs
=, dejs:format
^- $-(json resource)

View File

@ -3,6 +3,11 @@
++ grow
|%
++ noun i
++ notification-kind
?+ index.p.i ~
[@ ~] `%link
[@ @ ~] `%comment
==
--
++ grab
|%
@ -19,7 +24,7 @@
:: comment on link post; comment text
::
[@ @ ~]
?> ?=([[%text @] ~] contents.p.ip)
?> ?=(^ contents.p.ip)
ip
==
--

View File

@ -3,6 +3,14 @@
++ grow
|%
++ noun i
:: +notification-kind
:: Ignore all containers, only notify on content
::
++ notification-kind
?+ index.p.i ~
[@ %1 @ ~] `%note
[@ %2 @ ~] `%comment
==
--
++ grab
|%

View File

@ -0,0 +1,13 @@
/+ *hark-store
|_ act=action
++ grad %noun
++ grow
|%
++ noun act
--
++ grab
|%
++ noun action
++ json action:dejs
--
--

View File

@ -0,0 +1,13 @@
/+ *hark-chat-hook
|_ act=action
++ grad %noun
++ grow
|%
++ noun act
--
++ grab
|%
++ noun action
++ json action:dejs
--
--

View File

@ -0,0 +1,16 @@
/+ *hark-chat-hook
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json
%+ frond:enjs:format
%hark-chat-hook-update
(update:enjs upd)
--
++ grab
|%
++ noun update
--
--

View File

@ -0,0 +1,13 @@
/+ *hark-graph-hook
|_ act=action
++ grad %noun
++ grow
|%
++ noun act
--
++ grab
|%
++ noun action
++ json action:dejs
--
--

View File

@ -0,0 +1,17 @@
/+ *hark-graph-hook
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json
%+ frond:enjs:format
%hark-graph-hook-update
(update:enjs upd)
--
++ grab
|%
++ noun update
++ json update:dejs
--
--

View File

@ -0,0 +1,13 @@
/+ *hark-group-hook
|_ act=action
++ grad %noun
++ grow
|%
++ noun act
--
++ grab
|%
++ noun action
++ json action:dejs
--
--

View File

@ -0,0 +1,16 @@
/+ *hark-group-hook
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json
%+ frond:enjs:format
%hark-group-hook-update
(update:enjs upd)
--
++ grab
|%
++ noun update
--
--

View File

@ -0,0 +1,15 @@
/+ *hark-store
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json
%+ frond:enjs:format 'harkUpdate'
(update:enjs upd)
--
++ grab
|%
++ noun update
--
--

View File

@ -0,0 +1,15 @@
^?
|%
+$ action
$% [?(%listen %ignore) chat=path]
[%set-mentions mentions=?]
==
::
+$ update
$%
action
$: %initial
watching=(set path)
==
==
--

View File

@ -0,0 +1,20 @@
/- *resource, graph-store
^?
|%
+$ action
$%
[?(%listen %ignore) graph=resource]
[%set-mentions mentions=?]
[%set-watch-on-self watch-on-self=?]
==
::
+$ update
$%
action
$: %initial
watching=(set resource)
mentions=_&
watch-on-self=_&
==
==
--

View File

@ -0,0 +1,11 @@
/- *resource
^?
|%
+$ action
[?(%listen %ignore) group=resource]
::
+$ update
$% action
[%initial watching=(set resource)]
==
--

View File

@ -0,0 +1,50 @@
/- *resource, graph-store, post, group-store, metadata-store, chat-store
^?
|%
+$ index
$% [%graph group=resource graph=resource module=@t description=@t]
[%group group=resource description=@t]
[%chat chat=path mention=?]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$% $>(?(%add-members %remove-members) update:group-store)
metadata-action:metadata-store
==
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post)]
[%group =(list group-contents)]
[%chat =(list envelope:chat-store)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) lth)
::
+$ action
$% [%add =index =notification]
[%archive time=@da index]
[%read time=@da index]
[%unread time=@da index]
[%set-dnd dnd=?]
[%seen ~]
==
::
++ indexed-notification
[index notification]
::
+$ update
$% action
[%more =(list update)]
[%added time=@da =index =notification]
[%timebox time=@da archived=? =(list [index notification])]
[%count count=@ud]
==
--

View File

@ -28,6 +28,7 @@
::
+$ content
$% [%text text=cord]
[%mention =ship]
[%url url=cord]
[%code expression=cord output=(list tank)]
[%reference =uid]

View File

@ -11,6 +11,7 @@ import GroupsApi from './groups';
import LaunchApi from './launch';
import GraphApi from './graph';
import S3Api from './s3';
import {HarkApi} from './hark';
export default class GlobalApi extends BaseApi<StoreState> {
chat = new ChatApi(this.ship, this.channel, this.store);
@ -22,6 +23,7 @@ export default class GlobalApi extends BaseApi<StoreState> {
launch = new LaunchApi(this.ship, this.channel, this.store);
s3 = new S3Api(this.ship, this.channel, this.store);
graph = new GraphApi(this.ship, this.channel, this.store);
hark = new HarkApi(this.ship, this.channel, this.store);
constructor(
public ship: Patp,

View File

@ -3,10 +3,10 @@ import { StoreState } from '../store/type';
import { Patp, Path, PatpNoSig } from '~/types/noun';
import _ from 'lodash';
import {makeResource, resourceFromPath} from '../lib/group';
import {GroupPolicy, Enc, Post, NodeMap} from '~/types';
import {GroupPolicy, Enc, Post, NodeMap, Content} from '~/types';
import { numToUd, unixToDa } from '~/logic/lib/util';
export const createPost = (contents: Object[], parentIndex: string = '') => {
export const createPost = (contents: Content[], parentIndex: string = '') => {
return {
author: `~${window.ship}`,
index: parentIndex + '/' + unixToDa(Date.now()).toString(),

View File

@ -0,0 +1,144 @@
import BaseApi from "./base";
import { StoreState } from "../store/type";
import { dateToDa, decToUd } from "../lib/util";
import {NotifIndex} from "~/types";
import { BigInteger } from 'big-integer';
export class HarkApi extends BaseApi<StoreState> {
private harkAction(action: any): Promise<any> {
return this.action("hark-store", "hark-action", action);
}
private graphHookAction(action: any) {
return this.action("hark-graph-hook", "hark-graph-hook-action", action);
}
private groupHookAction(action: any) {
return this.action("hark-group-hook", "hark-group-hook-action", action);
}
private chatHookAction(action: any) {
return this.action("hark-chat-hook", "hark-chat-hook-action", action);
}
private actOnNotification(frond: string, intTime: BigInteger, index: NotifIndex) {
const time = decToUd(intTime.toString());
return this.harkAction({
[frond]: {
time,
index
}
});
}
async setMentions(mentions: boolean) {
await this.graphHookAction({
'set-mentions': mentions
});
return this.chatHookAction({
'set-mentions': mentions
});
}
setWatchOnSelf(watchSelf: boolean) {
return this.graphHookAction({
'set-watch-on-self': watchSelf
});
}
setDoNotDisturb(dnd: boolean) {
return this.harkAction({
'set-dnd': dnd
});
}
archive(time: BigInteger, index: NotifIndex) {
return this.actOnNotification('archive', time, index);
}
read(time: BigInteger, index: NotifIndex) {
return this.actOnNotification('read', time, index);
}
unread(time: BigInteger, index: NotifIndex) {
return this.actOnNotification('unread', time, index);
}
seen() {
return this.harkAction({ seen: null });
}
mute(index: NotifIndex) {
if('graph' in index) {
const { graph } = index.graph;
return this.ignoreGraph(graph);
}
if('group' in index) {
const { group } = index.group;
return this.ignoreGroup(group);
}
if('chat' in index) {
return this.ignoreChat(index.chat);
}
return Promise.resolve();
}
unmute(index: NotifIndex) {
if('graph' in index) {
return this.listenGraph(index.graph.graph);
}
if('group' in index) {
return this.listenGroup(index.group.group);
}
if('chat' in index) {
return this.listenChat(index.chat);
}
return Promise.resolve();
}
ignoreGroup(group: string) {
return this.groupHookAction({
ignore: group
})
}
ignoreGraph(graph: string) {
return this.graphHookAction({
ignore: graph
})
}
ignoreChat(chat: string) {
return this.chatHookAction({
ignore: chat
});
}
listenGroup(group: string) {
return this.groupHookAction({
listen: group
})
}
listenGraph(graph: string) {
return this.graphHookAction({
listen: graph
})
}
listenChat(chat: string) {
return this.chatHookAction({
listen: chat
});
}
async getTimeSubset(start?: Date, end?: Date) {
const s = start ? dateToDa(start) : "-";
const e = end ? dateToDa(end) : "-";
const result = await this.scry("hark-hook", `/time-subset/${s}/${e}`);
this.store.handleEvent({
data: result,
});
}
}

View File

@ -0,0 +1,24 @@
import { Content } from "~/types";
import urbitOb from "urbit-ob";
export function scanForMentions(text: string) {
const regex = /~([a-z]|-)+/g;
let result: Content[] = [];
let match: RegExpExecArray | null;
let lastPos = 0;
while ((match = regex.exec(text)) !== null) {
const newPos = match.index + match[0].length;
if (urbitOb.isValidPatp(match[0])) {
if (match.index !== lastPos) {
result.push({ text: text.slice(lastPos, match.index) });
}
result.push({ mention: match[0] });
}
lastPos = newPos;
}
const remainder = text.slice(lastPos, text.length);
if (remainder) {
result.push({ text: remainder });
}
return result;
}

View File

@ -55,6 +55,7 @@ const appIndex = function (apps) {
const otherIndex = function() {
const other = [];
other.push(result('DMs + Drafts', '/~landscape/home', 'home', null));
other.push(result('Notifications', '/~notifications', 'inbox', null));
other.push(result('Profile and Settings', '/~profile/identity', 'profile', null));
other.push(result('Log Out', '/~/logout', 'logout', null));

View File

@ -25,6 +25,7 @@ export const Sigil = memo(({ classes = '', color, foreground = '', ship, size, s
display='inline-block'
height={size}
width={size}
className={classes}
/>) : (
<Box
display='inline-block'

View File

@ -1,344 +0,0 @@
import React from 'react';
import _ from 'lodash';
import f from 'lodash/fp';
import bigInt from 'big-integer';
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
const DA_UNIX_EPOCH = bigInt("170141184475152167957503069145530368000"); // `@ud` ~1970.1.1
const DA_SECOND = bigInt("18446744073709551616"); // `@ud` ~s1
export function daToUnix(da) {
// ported from +time:enjs:format in hoon.hoon
const offset = DA_SECOND.divide(bigInt(2000));
const epochAdjusted = offset.add(da.subtract(DA_UNIX_EPOCH));
return Math.round(
epochAdjusted.multiply(bigInt(1000)).divide(DA_SECOND).toJSNumber()
);
}
export function unixToDa(unix) {
const timeSinceEpoch = bigInt(unix).multiply(DA_SECOND).divide(bigInt(1000));
return DA_UNIX_EPOCH.add(timeSinceEpoch);
}
export function appIsGraph(app) {
return app === 'link' || app === 'publish';
}
export function parentPath(path) {
return _.dropRight(path.split('/'), 1).join('/');
}
export function clamp(x,min,max) {
return Math.max(min, Math.min(max, x));
}
// color is a #000000 color
export function adjustHex(color, amount) {
const res = f.flow(
f.split(''), f.chunk(2), // get individual color channels
f.map(c => parseInt(c.join(''), 16)), // as hex
f.map(c => clamp(c + amount, 0, 255).toString(16)), // adjust
f.join('')
)(color.slice(1))
return `#${res}`;
}
export function resourceAsPath(resource) {
const { name, ship } = resource;
return `/ship/~${ship}/${name}`;
}
export function uuid() {
let str = '0v';
str += Math.ceil(Math.random()*8)+'.';
for (let i = 0; i < 5; i++) {
let _str = Math.ceil(Math.random()*10000000).toString(32);
_str = ('00000'+_str).substr(-5,5);
str += _str+'.';
}
return str.slice(0,-1);
}
/*
Goes from:
~2018.7.17..23.15.09..5be5 // urbit @da
To:
(javascript Date object)
*/
export function daToDate(st) {
const dub = function(n) {
return parseInt(n) < 10 ? '0' + parseInt(n) : n.toString();
};
const da = st.split('..');
const bigEnd = da[0].split('.');
const lilEnd = da[1].split('.');
const ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub(lilEnd[0])}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`;
return new Date(ds);
}
/*
Goes from:
(javascript Date object)
To:
~2018.7.17..23.15.09..5be5 // urbit @da
*/
export function dateToDa(d, mil) {
  const fil = function(n) {
    return n >= 10 ? n : '0' + n;
  };
  return (
    `~${d.getUTCFullYear()}.` +
    `${(d.getUTCMonth() + 1)}.` +
    `${fil(d.getUTCDate())}..` +
    `${fil(d.getUTCHours())}.` +
    `${fil(d.getUTCMinutes())}.` +
    `${fil(d.getUTCSeconds())}` +
`${mil ? '..0000' : ''}`
  );
}
export function deSig(ship) {
if(!ship) {
return null;
}
return ship.replace('~', '');
}
export function uxToHex(ux) {
if (ux.length > 2 && ux.substr(0,2) === '0x') {
const value = ux.substr(2).replace('.', '').padStart(6, '0');
return value;
}
const value = ux.replace('.', '').padStart(6, '0');
return value;
}
export function hexToUx(hex) {
const ux = f.flow(
f.chunk(4),
f.map(x => _.dropWhile(x, y => y === 0).join('')),
f.join('.')
)(hex.split(''))
return `0x${ux}`;
}
export function writeText(str) {
return new Promise(((resolve, reject) => {
const range = document.createRange();
range.selectNodeContents(document.body);
document.getSelection().addRange(range);
let success = false;
function listener(e) {
e.clipboardData.setData('text/plain', str);
e.preventDefault();
success = true;
}
document.addEventListener('copy', listener);
document.execCommand('copy');
document.removeEventListener('copy', listener);
document.getSelection().removeAllRanges();
success ? resolve() : reject();
})).catch((error) => {
console.error(error);
});;
};
// trim patps to match dojo, chat-cli
export function cite(ship) {
let patp = ship, shortened = '';
if (patp === null || patp === '') {
return null;
}
if (patp.startsWith('~')) {
patp = patp.substr(1);
}
// comet
if (patp.length === 56) {
shortened = '~' + patp.slice(0, 6) + '_' + patp.slice(50, 56);
return shortened;
}
// moon
if (patp.length === 27) {
shortened = '~' + patp.slice(14, 20) + '^' + patp.slice(21, 27);
return shortened;
}
return `~${patp}`;
}
export function alphabeticalOrder(a,b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
}
// TODO: deprecated
export function alphabetiseAssociations(associations) {
const result = {};
Object.keys(associations).sort((a, b) => {
let aName = a.substr(1);
let bName = b.substr(1);
if (associations[a].metadata && associations[a].metadata.title) {
aName = associations[a].metadata.title !== ''
? associations[a].metadata.title
: a.substr(1);
}
if (associations[b].metadata && associations[b].metadata.title) {
bName = associations[b].metadata.title !== ''
? associations[b].metadata.title
: b.substr(1);
}
return alphabeticalOrder(aName,bName);
}).map((each) => {
result[each] = associations[each];
});
return result;
}
// encode the string into @ta-safe format, using logic from +wood.
// for example, 'some Chars!' becomes '~.some.~43.hars~21.'
//
export function stringToTa(string) {
let out = '';
for (let i = 0; i < string.length; i++) {
const char = string[i];
let add = '';
switch (char) {
case ' ':
add = '.';
break;
case '.':
add = '~.';
break;
case '~':
add = '~~';
break;
default:
const charCode = string.charCodeAt(i);
if (
(charCode >= 97 && charCode <= 122) || // a-z
(charCode >= 48 && charCode <= 57) || // 0-9
char === '-'
) {
add = char;
} else {
// TODO behavior for unicode doesn't match +wood's,
// but we can probably get away with that for now.
add = '~' + charCode.toString(16) + '.';
}
}
out = out + add;
}
return '~.' + out;
}
export function amOwnerOfGroup(groupPath) {
if (!groupPath)
return false;
const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)[2];
return window.ship === groupOwner;
}
export function getContactDetails(contact) {
const member = !contact;
contact = contact || {
nickname: '',
avatar: null,
color: '0x0'
};
const nickname = contact.nickname || '';
const color = uxToHex(contact.color || '0x0');
const avatar = contact.avatar || null;
return { nickname, color, member, avatar };
}
export function stringToSymbol(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const n = str.charCodeAt(i);
if (((n >= 97) && (n <= 122)) ||
((n >= 48) && (n <= 57))) {
result += str[i];
} else if ((n >= 65) && (n <= 90)) {
result += String.fromCharCode(n + 32);
} else {
result += '-';
}
}
result = result.replace(/^[\-\d]+|\-+/g, '-');
result = result.replace(/^\-+|\-+$/g, '');
if (result === '') {
return dateToDa(new Date());
}
return result;
}
export function scrollIsAtTop(container) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return container.scrollTop === 0;
} else if (navigator.userAgent.includes("Safari")) {
return (
container.scrollHeight + Math.round(container.scrollTop) <=
container.clientHeight + 10
);
} else {
return false;
}
}
export function scrollIsAtBottom(container) {
if (
(navigator.userAgent.includes("Safari") &&
navigator.userAgent.includes("Chrome")) ||
navigator.userAgent.includes("Firefox")
) {
return (
container.scrollHeight - Math.round(container.scrollTop) <=
container.clientHeight + 10
);
} else if (navigator.userAgent.includes("Safari")) {
return container.scrollTop === 0;
} else {
return false;
}
}
/**
* Formats a numbers as a `@ud` inserting dot where needed
*/
export function numToUd(num) {
return f.flow(
f.split(''),
f.reverse,
f.chunk(3),
f.reverse,
f.map(s => s.join('')),
f.join('.')
)(num.toString())
}
export function usePreventWindowUnload(shouldPreventDefault, message = "You have unsaved changes. Are you sure you want to exit?") {
React.useEffect(() => {
if (!shouldPreventDefault) return;
const handleBeforeUnload = event => {
event.preventDefault();
return message;
}
window.addEventListener("beforeunload", handleBeforeUnload);
window.onbeforeunload = handleBeforeUnload;
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.onbeforeunload = undefined;
}
}, [shouldPreventDefault]);
}

View File

@ -0,0 +1,360 @@
import { useEffect } from 'react';
import _ from "lodash";
import f from "lodash/fp";
import bigInt, { BigInteger } from "big-integer";
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
export const MOMENT_CALENDAR_DATE = {
sameDay: "[Today]",
nextDay: "[Tomorrow]",
nextWeek: "dddd",
lastDay: "[Yesterday]",
lastWeek: "[Last] dddd",
sameElse: "DD/MM/YYYY",
};
export function appIsGraph(app: string) {
return app === 'publish' || app == 'link';
}
export function parentPath(path: string) {
return _.dropRight(path.split('/'), 1).join('/');
}
const DA_UNIX_EPOCH = bigInt("170141184475152167957503069145530368000"); // `@ud` ~1970.1.1
const DA_SECOND = bigInt("18446744073709551616"); // `@ud` ~s1
export function daToUnix(da: BigInteger) {
// ported from +time:enjs:format in hoon.hoon
const offset = DA_SECOND.divide(bigInt(2000));
const epochAdjusted = offset.add(da.subtract(DA_UNIX_EPOCH));
return Math.round(
epochAdjusted.multiply(bigInt(1000)).divide(DA_SECOND).toJSNumber()
);
}
export function unixToDa(unix: number) {
const timeSinceEpoch = bigInt(unix).multiply(DA_SECOND).divide(bigInt(1000));
return DA_UNIX_EPOCH.add(timeSinceEpoch);
}
export function makePatDa(patda: string) {
return bigInt(udToDec(patda));
}
export function udToDec(ud: string): string {
return ud.replace(/\./g, "");
}
export function decToUd(str: string): string {
return _.trimStart(
f.flow(
f.split(""),
f.reverse,
f.chunk(3),
f.map(f.flow(f.reverse, f.join(""))),
f.reverse,
f.join(".")
)(str),
"0."
);
}
/**
* Clamp a number between a min and max
*/
export function clamp(x: number, min: number, max: number) {
return Math.max(min, Math.min(max, x));
}
// color is a #000000 color
export function adjustHex(color: string, amount: number): string {
return f.flow(
f.split(""),
f.chunk(2), // get RGB channels
f.map((c) => parseInt(c.join(""), 16)), // as hex
f.map((c) => clamp(c + amount, 0, 255).toString(16)), // adjust
f.join(""),
(res) => `#${res}` //format
)(color.slice(1));
}
export function resourceAsPath(resource: any) {
const { name, ship } = resource;
return `/ship/~${ship}/${name}`;
}
export function uuid() {
let str = "0v";
str += Math.ceil(Math.random() * 8) + ".";
for (let i = 0; i < 5; i++) {
let _str = Math.ceil(Math.random() * 10000000).toString(32);
_str = ("00000" + _str).substr(-5, 5);
str += _str + ".";
}
return str.slice(0, -1);
}
/*
Goes from:
~2018.7.17..23.15.09..5be5 // urbit @da
To:
(javascript Date object)
*/
export function daToDate(st: string) {
const dub = function (n: string) {
return parseInt(n) < 10 ? "0" + parseInt(n) : n.toString();
};
const da = st.split("..");
const bigEnd = da[0].split(".");
const lilEnd = da[1].split(".");
const ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub(
lilEnd[0]
)}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`;
return new Date(ds);
}
/*
Goes from:
(javascript Date object)
To:
~2018.7.17..23.15.09..5be5 // urbit @da
*/
export function dateToDa(d: Date, mil: boolean = false) {
const fil = function (n: number) {
return n >= 10 ? n : "0" + n;
};
return (
`~${d.getUTCFullYear()}.` +
`${d.getUTCMonth() + 1}.` +
`${fil(d.getUTCDate())}..` +
`${fil(d.getUTCHours())}.` +
`${fil(d.getUTCMinutes())}.` +
`${fil(d.getUTCSeconds())}` +
`${mil ? "..0000" : ""}`
);
}
export function deSig(ship: string) {
if (!ship) {
return null;
}
return ship.replace("~", "");
}
export function uxToHex(ux: string) {
if (ux.length > 2 && ux.substr(0, 2) === "0x") {
const value = ux.substr(2).replace(".", "").padStart(6, "0");
return value;
}
const value = ux.replace(".", "").padStart(6, "0");
return value;
}
export const hexToUx: (hex: string) => string = f.flow(
f.split(""),
f.chunk(4),
f.map(
f.flow(
f.dropWhile((y) => y === 0),
f.join
)
),
f.join("."),
(x) => `0x${x}`
);
export function writeText(str: string) {
return new Promise<void>((resolve, reject) => {
const range = document.createRange();
range.selectNodeContents(document.body);
document?.getSelection()?.addRange(range);
let success = false;
function listener(e) {
e.clipboardData.setData("text/plain", str);
e.preventDefault();
success = true;
}
document.addEventListener("copy", listener);
document.execCommand("copy");
document.removeEventListener("copy", listener);
document?.getSelection()?.removeAllRanges();
success ? resolve() : reject();
}).catch((error) => {
console.error(error);
});
}
// trim patps to match dojo, chat-cli
export function cite(ship: string) {
let patp = ship,
shortened = "";
if (patp === null || patp === "") {
return null;
}
if (patp.startsWith("~")) {
patp = patp.substr(1);
}
// comet
if (patp.length === 56) {
shortened = "~" + patp.slice(0, 6) + "_" + patp.slice(50, 56);
return shortened;
}
// moon
if (patp.length === 27) {
shortened = "~" + patp.slice(14, 20) + "^" + patp.slice(21, 27);
return shortened;
}
return `~${patp}`;
}
export function alphabeticalOrder(a: string, b: string) {
return a.toLowerCase().localeCompare(b.toLowerCase());
}
// TODO: deprecated
export function alphabetiseAssociations(associations: any) {
const result = {};
Object.keys(associations)
.sort((a, b) => {
let aName = a.substr(1);
let bName = b.substr(1);
if (associations[a].metadata && associations[a].metadata.title) {
aName =
associations[a].metadata.title !== ""
? associations[a].metadata.title
: a.substr(1);
}
if (associations[b].metadata && associations[b].metadata.title) {
bName =
associations[b].metadata.title !== ""
? associations[b].metadata.title
: b.substr(1);
}
return alphabeticalOrder(aName, bName);
})
.map((each) => {
result[each] = associations[each];
});
return result;
}
// encode the string into @ta-safe format, using logic from +wood.
// for example, 'some Chars!' becomes '~.some.~43.hars~21.'
//
export function stringToTa(str: string) {
let out = "";
for (let i = 0; i < str.length; i++) {
const char = str[i];
let add = "";
switch (char) {
case " ":
add = ".";
break;
case ".":
add = "~.";
break;
case "~":
add = "~~";
break;
default:
const charCode = str.charCodeAt(i);
if (
(charCode >= 97 && charCode <= 122) || // a-z
(charCode >= 48 && charCode <= 57) || // 0-9
char === "-"
) {
add = char;
} else {
// TODO behavior for unicode doesn't match +wood's,
// but we can probably get away with that for now.
add = "~" + charCode.toString(16) + ".";
}
}
out = out + add;
}
return "~." + out;
}
export function amOwnerOfGroup(groupPath: string) {
if (!groupPath) return false;
const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)?.[2];
return window.ship === groupOwner;
}
export function getContactDetails(contact: any) {
const member = !contact;
contact = contact || {
nickname: "",
avatar: null,
color: "0x0",
};
const nickname = contact.nickname || "";
const color = uxToHex(contact.color || "0x0");
const avatar = contact.avatar || null;
return { nickname, color, member, avatar };
}
export function stringToSymbol(str: string) {
let result = "";
for (let i = 0; i < str.length; i++) {
const n = str.charCodeAt(i);
if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) {
result += str[i];
} else if (n >= 65 && n <= 90) {
result += String.fromCharCode(n + 32);
} else {
result += "-";
}
}
result = result.replace(/^[\-\d]+|\-+/g, "-");
result = result.replace(/^\-+|\-+$/g, "");
if (result === "") {
return dateToDa(new Date());
}
return result;
}
/**
* Formats a numbers as a `@ud` inserting dot where needed
*/
export function numToUd(num: number) {
return f.flow(
f.split(''),
f.reverse,
f.chunk(3),
f.reverse,
f.map(s => s.join('')),
f.join('.')
)(num.toString())
}
export function usePreventWindowUnload(shouldPreventDefault: boolean, message = "You have unsaved changes. Are you sure you want to exit?") {
useEffect(() => {
if (!shouldPreventDefault) return;
const handleBeforeUnload = event => {
event.preventDefault();
return message;
}
window.addEventListener("beforeunload", handleBeforeUnload);
window.onbeforeunload = handleBeforeUnload;
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
// @ts-ignore
window.onbeforeunload = undefined;
}
}, [shouldPreventDefault]);
}
export function pluralize(text: string, isPlural = false, vowel = false) {
return isPlural ? `${text}s`: `${vowel ? 'an' : 'a'} ${text}`;
}

View File

@ -0,0 +1,285 @@
import {
Notifications,
NotifIndex,
NotificationGraphConfig,
GroupNotificationsConfig,
} from "~/types";
import { makePatDa } from "~/logic/lib/util";
import _ from "lodash";
type HarkState = {
notifications: Notifications;
archivedNotifications: Notifications;
notificationsCount: number;
notificationsGraphConfig: NotificationGraphConfig;
notificationsGroupConfig: GroupNotificationsConfig;
notificationsChatConfig: string[];
};
export const HarkReducer = (json: any, state: HarkState) => {
const data = _.get(json, "harkUpdate", false);
if (data) {
reduce(data, state);
}
const graphHookData = _.get(json, "hark-graph-hook-update", false);
if (graphHookData) {
console.log(graphHookData);
graphInitial(graphHookData, state);
graphIgnore(graphHookData, state);
graphListen(graphHookData, state);
graphWatchSelf(graphHookData, state);
graphMentions(graphHookData, state);
}
const groupHookData = _.get(json, "hark-group-hook-update", false);
if (groupHookData) {
groupInitial(groupHookData, state);
groupListen(groupHookData, state);
groupIgnore(groupHookData, state);
}
const chatHookData = _.get(json, "hark-chat-hook-update", false);
if(chatHookData) {
chatInitial(chatHookData, state);
chatListen(chatHookData, state);
chatIgnore(chatHookData, state);
}
};
function chatInitial(json: any, state: HarkState) {
const data = _.get(json, "initial", false);
if (data) {
state.notificationsChatConfig = data;
}
}
function chatListen(json: any, state: HarkState) {
const data = _.get(json, "listen", false);
if (data) {
state.notificationsChatConfig = [...state.notificationsChatConfig, data];
}
}
function chatIgnore(json: any, state: HarkState) {
const data = _.get(json, "ignore", false);
if (data) {
state.notificationsChatConfig = state.notificationsChatConfig.filter(x => x !== data);
}
}
function groupInitial(json: any, state: HarkState) {
const data = _.get(json, "initial", false);
if (data) {
state.notificationsGroupConfig = data;
}
}
function graphInitial(json: any, state: HarkState) {
const data = _.get(json, "initial", false);
if (data) {
state.notificationsGraphConfig = data;
}
}
function graphListen(json: any, state: HarkState) {
const data = _.get(json, "listen", false);
if (data) {
state.notificationsGraphConfig.watching = [
...state.notificationsGraphConfig.watching,
data,
];
}
}
function graphIgnore(json: any, state: HarkState) {
const data = _.get(json, "ignore", false);
if (data) {
state.notificationsGraphConfig.watching = state.notificationsGraphConfig.watching.filter(
(n) => n !== data
);
}
}
function groupListen(json: any, state: HarkState) {
const data = _.get(json, "listen", false);
if (data) {
state.notificationsGroupConfig = [...state.notificationsGroupConfig, data];
}
}
function groupIgnore(json: any, state: HarkState) {
const data = _.get(json, "ignore", false);
if (data) {
state.notificationsGroupConfig = state.notificationsGroupConfig.filter(
(n) => n !== data
);
}
}
function graphMentions(json: any, state: HarkState) {
const data = _.get(json, "set-mentions", undefined);
if (!_.isUndefined(data)) {
state.notificationsGraphConfig.mentions = data;
}
}
function graphWatchSelf(json: any, state: HarkState) {
const data = _.get(json, "set-watch-on-self", undefined);
if (!_.isUndefined(data)) {
state.notificationsGraphConfig.watchOnSelf = data;
}
}
function reduce(data: any, state: HarkState) {
unread(data, state);
read(data, state);
archive(data, state);
timebox(data, state);
more(data, state);
dnd(data, state);
count(data, state);
added(data, state);
}
function added(json: any, state: HarkState) {
const data = _.get(json, "added", false);
if (data) {
const { index, notification } = data;
const time = makePatDa(data.time);
const timebox = state.notifications.get(time) || [];
const arrIdx = timebox.findIndex((idxNotif) =>
notifIdxEqual(index, idxNotif.index)
);
if (arrIdx !== -1) {
timebox[arrIdx] = { index, notification };
state.notifications.set(time, timebox);
} else {
state.notifications.set(time, [...timebox, { index, notification }]);
state.notificationsCount++;
}
}
}
function count(json: any, state: HarkState) {
const data = _.get(json, "count", false);
if (data !== false) {
state.notificationsCount = data;
}
}
const dnd = (json: any, state: HarkState) => {
const data = _.get(json, "set-dnd", undefined);
if (!_.isUndefined(data)) {
state.doNotDisturb = data;
}
};
const timebox = (json: any, state: HarkState) => {
const data = _.get(json, "timebox", false);
if (data) {
const time = makePatDa(data.time);
if (data.archive) {
state.archivedNotifications.set(time, data.notifications);
} else {
state.notifications.set(time, data.notifications);
}
}
};
function more(json: any, state: HarkState) {
const data = _.get(json, "more", false);
if (data) {
_.forEach(data, (d) => reduce(d, state));
}
}
function notifIdxEqual(a: NotifIndex, b: NotifIndex) {
if ("graph" in a && "graph" in b) {
return (
a.graph.graph === b.graph.graph &&
a.graph.group === b.graph.group &&
a.graph.module === b.graph.module &&
a.graph.description === b.graph.description
);
} else if ("group" in a && "group" in b) {
return (
a.group.group === b.group.group &&
a.group.description === b.group.description
);
} else if ("chat" in a && "chat" in b) {
return a.chat.chat === b.chat.chat &&
a.chat.mention === b.chat.mention;
}
return false;
}
function setRead(
time: string,
index: NotifIndex,
read: boolean,
state: HarkState
) {
const patDa = makePatDa(time);
const timebox = state.notifications.get(patDa);
if (_.isNull(timebox)) {
console.warn("Modifying nonexistent timebox");
return;
}
const arrIdx = timebox.findIndex((idxNotif) =>
notifIdxEqual(index, idxNotif.index)
);
if (arrIdx === -1) {
console.warn("Modifying nonexistent index");
return;
}
timebox[arrIdx].notification.read = read;
state.notifications.set(patDa, timebox);
}
function read(json: any, state: HarkState) {
const data = _.get(json, "read", false);
if (data) {
const { time, index } = data;
state.notificationsCount--;
setRead(time, index, true, state);
}
}
function unread(json: any, state: HarkState) {
const data = _.get(json, "unread", false);
if (data) {
const { time, index } = data;
state.notificationsCount++;
setRead(time, index, false, state);
}
}
function archive(json: any, state: HarkState) {
const data = _.get(json, "archive", false);
if (data) {
const { index } = data;
const time = makePatDa(data.time);
const timebox = state.notifications.get(time);
if (!timebox) {
console.warn("Modifying nonexistent timebox");
return;
}
const [archived, unarchived] = _.partition(timebox, (idxNotif) =>
notifIdxEqual(index, idxNotif.index)
);
state.notifications.set(time, unarchived);
const archiveBox = state.archivedNotifications.get(time) || [];
state.notificationsCount -= archived.filter(
({ notification }) => !notification.read
).length;
state.archivedNotifications.set(time, [
...archiveBox,
...archived.map(({ notification, index }) => ({
notification: { ...notification, read: true },
index,
})),
]);
}
}

View File

@ -5,13 +5,17 @@ import LocalReducer from '../reducers/local';
import ChatReducer from '../reducers/chat-update';
import { StoreState } from './type';
import { Timebox } from '~/types';
import { Cage } from '~/types/cage';
import ContactReducer from '../reducers/contact-update';
import S3Reducer from '../reducers/s3-update';
import { GraphReducer } from '../reducers/graph-update';
import { HarkReducer } from '../reducers/hark-update';
import GroupReducer from '../reducers/group-update';
import LaunchReducer from '../reducers/launch-update';
import ConnectionReducer from '../reducers/connection';
import {OrderedMap} from '../lib/OrderedMap';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
export const homeAssociation = {
"app-path": "/home",
@ -93,6 +97,16 @@ export default class GlobalStore extends BaseStore<StoreState> {
dark: false,
inbox: {},
chatSynced: null,
notifications: new BigIntOrderedMap<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(),
notificationsGroupConfig: [],
notificationsChatConfig: [],
notificationsGraphConfig: {
watchOnSelf: false,
mentions: false,
watching: [],
},
notificationsCount: 0
};
}
@ -107,5 +121,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
this.launchReducer.reduce(data, this.state);
this.connReducer.reduce(data, this.state);
GraphReducer(data, this.state);
HarkReducer(data, this.state);
}
}

View File

@ -9,7 +9,13 @@ import { S3State } from '~/types/s3-update';
import { LaunchState, WeatherState } from '~/types/launch-update';
import { ConnectionStatus } from '~/types/connection';
import {Graphs} from '~/types/graph-update';
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from '~/types/local-update';
import {
Notifications,
NotificationGraphConfig,
GroupNotificationsConfig,
LocalUpdateRemoteContentPolicy,
BackgroundConfig
} from "~/types";
export interface StoreState {
// local state
@ -45,11 +51,18 @@ export interface StoreState {
userLocation: string | null;
// publish state
notebooks: Notebooks;
notebooks: any;
// Chat state
chatInitialized: boolean;
chatSynced: ChatHookUpdate | null;
inbox: Inbox;
pendingMessages: Map<Path, Envelope[]>;
notifications: Notifications;
notificationsGraphConfig: NotificationGraphConfig;
notificationsGroupConfig: GroupNotificationsConfig;
notificationsChatConfig: string[];
notificationsCount: number,
doNotDisturb: boolean;
}

View File

@ -51,6 +51,10 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
this.subscribe('/all', 'launch');
this.subscribe('/all', 'weather');
this.subscribe('/keys', 'graph-store');
this.subscribe('/updates', 'hark-store');
this.subscribe('/updates', 'hark-graph-hook');
this.subscribe('/updates', 'hark-group-hook');
this.subscribe('/updates', 'hark-chat-hook');
}
restart() {

View File

@ -1,12 +1,28 @@
import { Patp } from "./noun";
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
export interface TextContent { text: string; };
export interface UrlContent { url: string; }
export interface CodeContent { expresssion: string; output: string; };
export interface ReferenceContent { uid: string; }
export type Content = TextContent | UrlContent | CodeContent | ReferenceContent;
export interface TextContent {
text: string;
}
export interface UrlContent {
url: string;
}
export interface CodeContent {
expresssion: string;
output: string;
}
export interface ReferenceContent {
uid: string;
}
export interface MentionContent {
mention: string;
}
export type Content =
| TextContent
| UrlContent
| CodeContent
| ReferenceContent
| MentionContent;
export interface Post {
author: Patp;
@ -15,10 +31,9 @@ export interface Post {
index: string;
pending?: boolean;
signatures: string[];
'time-sent': number;
"time-sent": number;
}
export interface GraphNode {
children: Graph;
post: Post;
@ -27,5 +42,3 @@ export interface GraphNode {
export type Graph = BigIntOrderedMap<GraphNode>;
export type Graphs = { [rid: string]: Graph };

View File

@ -0,0 +1,63 @@
import _ from "lodash";
import { Post } from "./graph-update";
import { GroupUpdate } from "./group-update";
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import { Envelope } from './chat-update';
type GraphNotifDescription = "link" | "comment";
export interface GraphNotifIndex {
graph: string;
group: string;
description: GraphNotifDescription;
module: string;
}
export interface GroupNotifIndex {
group: string;
description: string;
}
export interface ChatNotifIndex {
chat: string;
mention: boolean;
}
export type NotifIndex =
| { graph: GraphNotifIndex }
| { group: GroupNotifIndex }
| { chat: ChatNotifIndex };
export type GraphNotificationContents = Post[];
export type GroupNotificationContents = GroupUpdate[];
export type ChatNotificationContents = Envelope[];
export type NotificationContents =
| { graph: GraphNotificationContents }
| { group: GroupNotificationContents }
| { chat: ChatNotificationContents };
interface Notification {
read: boolean;
time: number;
contents: NotificationContents;
}
export interface IndexedNotification {
index: NotifIndex;
notification: Notification;
}
export type Timebox = IndexedNotification[];
export type Notifications = BigIntOrderedMap<Timebox>;
export interface NotificationGraphConfig {
watchOnSelf: boolean;
mentions: boolean;
watching: string[];
}
export type GroupNotificationsConfig = string[];

View File

@ -6,6 +6,7 @@ export * from './contact-update';
export * from './global';
export * from './group-update';
export * from './graph-update';
export * from './hark-update';
export * from './invite-update';
export * from './launch-update';
export * from './local-update';

View File

@ -125,6 +125,9 @@ class App extends React.Component {
const theme = state.dark ? dark : light;
const { background } = state;
const notificationsCount = state.notificationsCount || 0;
const doNotDisturb = state.doNotDisturb || false;
return (
<ThemeProvider theme={theme}>
<Helmet>
@ -143,6 +146,8 @@ class App extends React.Component {
connection={this.state.connection}
subscription={this.subscription}
ship={this.ship}
doNotDisturb={doNotDisturb}
notificationsCount={notificationsCount}
/>
</ErrorBoundary>
<ErrorBoundary>

View File

@ -1,4 +1,4 @@
import React, { useRef, useCallback } from 'react';
import React, { useRef, useCallback, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Col } from '@tlon/indigo-react';
import _ from 'lodash';
@ -93,6 +93,15 @@ export function ChatResource(props: ChatResourceProps) {
station
]);
const scrollTo = new URLSearchParams(location.search).get('msg');
useEffect(() => {
const clear = () => {
props.history.replace(location.pathname);
};
setTimeout(clear, 10000);
return clear;
}, [station]);
return (
<Col {...bind} height="100%" overflow="hidden" position="relative">
{dragging && <SubmitDragger />}
@ -117,6 +126,7 @@ export function ChatResource(props: ChatResourceProps) {
hideNicknames={props.hideNicknames}
hideAvatars={props.hideAvatars}
location={props.location}
scrollTo={scrollTo ? parseInt(scrollTo, 10) : undefined}
/>
<ChatInput
ref={chatInput}

View File

@ -31,8 +31,8 @@ export const DayBreak = ({ when }) => (
interface ChatMessageProps {
measure(element): void;
msg: Envelope | IMessage;
previousMsg: Envelope | IMessage | undefined;
nextMsg: Envelope | IMessage | undefined;
previousMsg?: Envelope | IMessage;
nextMsg?: Envelope | IMessage;
isLastRead: boolean;
group: Group;
association: Association;
@ -48,6 +48,7 @@ interface ChatMessageProps {
unreadMarkerRef: React.RefObject<HTMLDivElement>;
history: any;
api: any;
highlighted?: boolean;
}
export default class ChatMessage extends Component<ChatMessageProps> {
@ -84,7 +85,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
isLastMessage,
unreadMarkerRef,
history,
api
api,
highlighted
} = this.props;
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
@ -115,7 +117,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
isPending,
history,
api,
scrollWindow
scrollWindow,
highlighted
};
const unreadContainerStyle = {
@ -124,6 +127,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
return (
<Box
bg={highlighted ? 'washedBlue' : 'white'}
width='100%'
display='flex'
flexWrap='wrap'
@ -165,6 +169,8 @@ interface MessageProps {
};
export class MessageWithSigil extends PureComponent<MessageProps> {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
render() {
const {
msg,
@ -176,8 +182,8 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
hideAvatars,
remoteContentPolicy,
measure,
history,
api,
history,
scrollWindow
} = this.props;
@ -185,8 +191,8 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
const contact = msg.author in contacts ? contacts[msg.author] : false;
const showNickname = !hideNicknames && contact && contact.nickname;
const name = showNickname ? contact.nickname : cite(msg.author);
const color = contact ? `#${uxToHex(contact.color)}` : '#000000';
const sigilClass = contact ? '' : 'mix-blend-diff';
const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF'
const sigilClass = contact ? '' : this.isDark ? 'mix-blend-diff' : 'mix-blend-darken';
let nameSpan = null;
@ -213,7 +219,7 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
scrollWindow={scrollWindow}
history={history}
api={api}
className="fl pr3 v-top bg-white bg-gray0-d pt1"
className="fl pr3 v-top pt1"
/>
<Box flexGrow='1' display='block' className="clamp-message">
<Box
@ -239,7 +245,7 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
<Text flexShrink='0' gray mono className="v-mid">{timestamp}</Text>
<Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
</Box>
<Box fontSize='14px'><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} /></Box>
<Box fontSize='14px'><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} /></Box>
</Box>
</>
);

View File

@ -43,6 +43,7 @@ type ChatWindowProps = RouteComponentProps<{
hideNicknames: boolean;
hideAvatars: boolean;
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
scrollTo?: number;
}
interface ChatWindowState {
@ -84,6 +85,10 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
window.addEventListener('focus', this.handleWindowFocus);
this.initialFetch();
setTimeout(() => {
if(this.props.scrollTo) {
this.scrollToUnread();
}
this.setState({ initialized: true });
}, this.INITIALIZATION_MAX_TIME);
}
@ -167,8 +172,9 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
}
scrollToUnread() {
const { mailboxSize, unreadCount } = this.props;
this.virtualList?.scrollToData(mailboxSize - unreadCount);
const { mailboxSize, unreadCount, scrollTo } = this.props;
const target = scrollTo || (mailboxSize - unreadCount);
this.virtualList?.scrollToData(target);
}
dismissUnread() {
@ -297,7 +303,8 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isLastMessage: boolean = Boolean(index === lastMessage)
const isLastRead: boolean = Boolean(!isLastMessage && index === this.state.lastRead);
const props = { measure, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
const highlighted = index === this.props.scrollTo;
const props = { measure, highlighted, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
return (
<ChatMessage
key={index}

View File

@ -87,6 +87,10 @@ h2 {
mix-blend-mode: difference;
}
.mix-blend-darken {
mix-blend-mode: darken;
}
.placeholder-inter::placeholder {
font-family: "Inter", sans-serif;
}

View File

@ -0,0 +1,99 @@
import React, { useCallback } from "react";
import _ from "lodash";
import { Link } from "react-router-dom";
import GlobalApi from "~/logic/api/global";
import {
Rolodex,
Associations,
ChatNotifIndex,
ChatNotificationContents,
Groups,
} from "~/types";
import { BigInteger } from "big-integer";
import { Box, Col } from "@tlon/indigo-react";
import { Header } from "./header";
import { pluralize } from "~/logic/lib/util";
import ChatMessage from "../chat/components/ChatMessage";
function describeNotification(mention: boolean, lent: number) {
const msg = pluralize("message", lent !== 1);
if (mention) {
return `mentioned you in ${msg} in`;
}
return `sent ${msg} in`;
}
export function ChatNotification(props: {
index: ChatNotifIndex;
contents: ChatNotificationContents;
archived: boolean;
read: boolean;
time: number;
timebox: BigInteger;
associations: Associations;
contacts: Rolodex;
groups: Groups;
api: GlobalApi;
}) {
const { index, contents, read, time, api, timebox } = props;
const authors = _.map(contents, "author");
const { chat, mention } = index;
const association = props.associations.chat[chat];
const groupPath = association["group-path"];
const appPath = association["app-path"];
const group = props.groups[groupPath];
const desc = describeNotification(mention, contents.length);
const groupContacts = props.contacts[groupPath];
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? "unread" : "read";
return api.hark[func](timebox, { chat: index });
}, [api, timebox, index, read]);
return (
<Col onClick={onClick} flexGrow="1" p="2">
<Header
chat
associations={props.associations}
read={read}
archived={props.archived}
time={time}
authors={authors}
moduleIcon="Chat"
channel={chat}
contacts={props.contacts}
group={groupPath}
description={desc}
/>
<Col pb="3" pl="5">
{_.map(_.take(contents, 5), (content, idx) => {
const to = `/~landscape${groupPath}/resource/chat${appPath}?msg=${content.number}`;
return (
<Link key={idx} to={to}>
<ChatMessage
measure={() => {}}
msg={content}
isLastRead={false}
group={group}
contacts={groupContacts}
/>
</Link>
);
})}
{contents.length > 5 && (
<Box ml="4" mt="3" mb="2" color="gray" fontSize="14px">
and {contents.length - 5} other message
{contents.length > 6 ? "s" : ""}
</Box>
)}
</Col>
</Col>
);
}

View File

@ -0,0 +1,231 @@
import React, { ReactNode, useCallback } from "react";
import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import { Link } from "react-router-dom";
import _ from "lodash";
import {
Post,
GraphNotifIndex,
GraphNotificationContents,
Associations,
Content,
Rolodex,
} from "~/types";
import { Header } from "./header";
import { cite, deSig, pluralize } from "~/logic/lib/util";
import { Sigil } from "~/logic/lib/sigil";
import RichText from "~/views/components/RichText";
import GlobalApi from "~/logic/api/global";
import ReactMarkdown from "react-markdown";
import { getSnippet } from "~/logic/lib/publish";
import styled from "styled-components";
function getGraphModuleIcon(module: string) {
if (module === "link") {
return "Links";
}
return _.capitalize(module);
}
const FilterBox = styled(Box)`
background: linear-gradient(
${(p) => p.theme.colors.scales.white10} 0%,
${(p) => p.theme.colors.scales.white60} 40%,
${(p) => p.theme.colors.scales.white100} 100%
);
`;
function describeNotification(description: string, plural: boolean) {
switch (description) {
case "link":
return `added ${pluralize("new link", plural)} to`;
case "comment":
return `left ${pluralize("comment", plural)} on`;
case "note":
return `posted ${pluralize("note", plural)} to`;
case "mention":
return "mentioned you on";
default:
return description;
}
}
const GraphUrl = ({ url, title }) => (
<Box borderRadius="1" p="2" bg="washedGray">
<Anchor target="_blank" color="gray" href={url}>
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{title}
</Anchor>
</Box>
);
const GraphNodeContent = ({ contents, mod, description, index }) => {
const idx = index.slice(1).split("/");
if (mod === "link") {
if (idx.length === 1) {
const [{ text }, { url }] = contents;
return <GraphUrl title={text} url={url} />;
} else if (idx.length === 2) {
const [{ text }] = contents;
return <RichText>{text}</RichText>;
}
return null;
}
if (mod === "publish") {
if (idx.length !== 3) {
return null;
} else if (idx[1] === "2") {
const [{ text }] = contents;
return <RichText>{text}</RichText>;
} else if (idx[1] === "1") {
const [{ text: header }, { text: body }] = contents;
const snippet = getSnippet(body);
return (
<Col>
<Box mb="2" fontWeight="500">
{header}
</Box>
<Box overflow="hidden" maxHeight="400px">
{snippet}
<FilterBox
width="100%"
zIndex="1"
height="calc(100% - 2em)"
bottom="0px"
position="absolute"
/>
</Box>
</Col>
);
}
}
return null;
};
function getNodeUrl(mod: string, group: string, graph: string, index: string) {
const graphUrl = `/~landscape${group}/resource/${mod}${graph}`;
const idx = index.slice(1).split("/");
if (mod === "publish") {
const [noteId] = idx;
return `${graphUrl}/note/${noteId}`;
} else if (mod === "link") {
const [linkId] = idx;
return `${graphUrl}/-${linkId}`;
}
return "";
}
const GraphNode = ({
contents,
author,
mod,
description,
time,
index,
graph,
group,
}) => {
author = deSig(author);
const img = (
<Sigil
ship={`~${author}`}
size={16}
icon
color={`#000000`}
classes="mix-blend-diff"
/>
);
const nodeUrl = getNodeUrl(mod, group, graph, index);
return (
<Link to={nodeUrl}>
<Row gapX="2" py="2">
<Col>{img}</Col>
<Col alignItems="flex-start">
<Row
mb="2"
height="16px"
alignItems="center"
p="1"
backgroundColor="white"
>
<Text mono title={author}>
{cite(author)}
</Text>
<Text ml="2" gray>
{moment(time).format("HH:mm")}
</Text>
</Row>
<Row p="1">
<GraphNodeContent
contents={contents}
mod={mod}
description={description}
index={index}
/>
</Row>
</Col>
</Row>
</Link>
);
};
export function GraphNotification(props: {
index: GraphNotifIndex;
contents: GraphNotificationContents;
archived: boolean;
read: boolean;
time: number;
timebox: BigInteger;
associations: Associations;
contacts: Rolodex;
api: GlobalApi;
}) {
const { contents, index, read, time, api, timebox } = props;
const authors = _.map(contents, "author");
const { graph, group } = index;
const icon = getGraphModuleIcon(index.module);
const desc = describeNotification(index.description, contents.length !== 1);
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? "unread" : "read";
return api.hark[func](timebox, { graph: index });
}, [api, timebox, index, read]);
return (
<Col onClick={onClick} p="2">
<Header
archived={props.archived}
time={time}
read={read}
authors={authors}
moduleIcon={icon}
channel={graph}
contacts={props.contacts}
group={group}
description={desc}
associations={props.associations}
/>
<Col pl="5">
{_.map(contents, (content, idx) => (
<GraphNode
author={content.author}
contents={content.contents}
mod={index.module}
time={content["time-sent"]}
description={index.description}
index={content.index}
graph={graph}
group={group}
/>
))}
</Col>
</Col>
);
}

View File

@ -0,0 +1,90 @@
import React, { ReactNode, useCallback } from "react";
import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import _ from "lodash";
import { NotificationProps } from "./types";
import {
Post,
GraphNotifIndex,
GraphNotificationContents,
Associations,
Content,
IndexedNotification,
GroupNotificationContents,
GroupNotifIndex,
GroupUpdate,
Rolodex,
} from "~/types";
import { Header } from "./header";
import { cite, deSig } from "~/logic/lib/util";
import { Sigil } from "~/logic/lib/sigil";
import RichText from "~/views/components/RichText";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
function describeNotification(description: string, plural: boolean) {
switch (description) {
case "add-members":
return "joined";
case "remove-members":
return "left";
default:
return description;
}
}
function getGroupUpdateParticipants(update: GroupUpdate) {
if ("addMembers" in update) {
return update.addMembers.ships;
}
if ("removeMembers" in update) {
return update.removeMembers.ships;
}
return [];
}
interface GroupNotificationProps {
index: GroupNotifIndex;
contents: GroupNotificationContents;
archived: boolean;
read: boolean;
time: number;
timebox: BigInteger;
associations: Associations;
contacts: Rolodex;
api: GlobalApi;
}
export function GroupNotification(props: GroupNotificationProps) {
const { contents, index, read, time, api, timebox, associations } = props;
const authors = _.flatten(_.map(contents, getGroupUpdateParticipants));
const { group } = index;
const desc = describeNotification(index.description, contents.length !== 1);
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? "unread" : "read";
return api.hark[func](timebox, { group: index });
}, [api, timebox, index, read]);
return (
<Col onClick={onClick} p="2">
<Header
archived={props.archived}
time={time}
read={read}
group={group}
contacts={props.contacts}
authors={authors}
description={desc}
associations={associations}
/>
</Col>
);
}

View File

@ -0,0 +1,107 @@
import React from "react";
import { Text as NormalText, Row, Icon } from "@tlon/indigo-react";
import f from "lodash/fp";
import _ from "lodash";
import moment from "moment";
import { PropFunc } from "~/types/util";
import { getContactDetails } from "~/logic/lib/util";
import { Associations, Contact, Contacts, Rolodex } from "~/types";
const Text = (props: PropFunc<typeof Text>) => (
<NormalText fontWeight="500" {...props} />
);
const Divider = (props: PropFunc<typeof Text>) => (
<Text lineHeight="tall" mx="1" fontWeight="bold" color="lightGray">
|
</Text>
);
function Author(props: { patp: string; contacts: Contacts; last?: boolean }) {
const contact: Contact | undefined = props.contacts?.[props.patp];
const showNickname = !!contact?.nickname;
const name = contact?.nickname || `~${props.patp}`;
return (
<Text mono={!showNickname}>
{name}
{!props.last && ", "}
</Text>
);
}
export function Header(props: {
authors: string[];
archived?: boolean;
channel?: string;
group: string;
contacts: Rolodex;
description: string;
moduleIcon?: string;
time: number;
read: boolean;
associations: Associations;
chat?: boolean;
}) {
const { description, channel, group, moduleIcon, read } = props;
const contacts = props.contacts[group] || {};
const authors = _.uniq(props.authors);
const authorDesc = f.flow(
f.take(3),
f.entries,
f.map(([idx, p]: [string, string]) => {
const lent = Math.min(3, authors.length);
const last = lent - 1 === parseInt(idx, 10);
return <Author key={idx} contacts={contacts} patp={p} last={last} />;
}),
(auths) => (
<React.Fragment>
{auths}
{authors.length > 3 &&
` and ${authors.length - 3} other${authors.length === 4 ? "" : "s"}`}
</React.Fragment>
)
)(authors);
const time = moment(props.time).format("HH:mm");
const groupTitle =
props.associations.contacts?.[props.group]?.metadata?.title || props.group;
const app = props.chat ? 'chat' : 'graph';
const channelTitle =
(channel && props.associations?.[app]?.[channel]?.metadata?.title) ||
channel;
return (
<Row p="2" flexWrap="wrap" gapX="1" alignItems="center">
{!props.archived && (
<Icon
display="block"
mr="1"
icon={read ? "Circle" : "Bullet"}
color="blue"
/>
)}
<Text mr="1" mono>
{authorDesc}
</Text>
<Text mr="1">{description}</Text>
{!!moduleIcon && <Icon icon={moduleIcon as any} />}
{!!channel && <Text fontWeight="500">{channelTitle}</Text>}
<Text mx="1" fontWeight="bold" color="lightGray">
|
</Text>
<Text fontWeight="500">{groupTitle}</Text>
<Text lineHeight="tall" mx="1" fontWeight="bold" color="lightGray">
|
</Text>
<Text fontWeight="regular" color="lightGray">
{time}
</Text>
</Row>
);
}

View File

@ -0,0 +1,165 @@
import React, { useEffect } from "react";
import f from "lodash/fp";
import _ from "lodash";
import { Icon, Col, Row, Box, Text, Anchor } from "@tlon/indigo-react";
import moment from "moment";
import { Notifications, Rolodex, Timebox, IndexedNotification } from "~/types";
import { MOMENT_CALENDAR_DATE, daToUnix } from "~/logic/lib/util";
import { BigInteger } from "big-integer";
import GlobalApi from "~/logic/api/global";
import { Notification } from "./notification";
import { Associations } from "~/types";
type DatedTimebox = [BigInteger, Timebox];
function filterNotification(associations: Associations, groups: string[]) {
if (groups.length === 0) {
return () => true;
}
return (n: IndexedNotification) => {
if ("graph" in n.index) {
const { group } = n.index.graph;
return groups.findIndex((g) => group === g) !== -1;
} else if ("group" in n.index) {
const { group } = n.index.group;
return groups.findIndex((g) => group === g) !== -1;
} else if ("chat" in n.index) {
const group = associations.chat[n.index.chat]?.["group-path"];
return groups.findIndex((g) => group === g) !== -1;
}
return true;
};
}
export default function Inbox(props: {
notifications: Notifications;
archive: Notifications;
showArchive?: boolean;
api: GlobalApi;
associations: Associations;
contacts: Rolodex;
filter: string[];
}) {
const { api, associations } = props;
useEffect(() => {
let seen = false;
setTimeout(() => {
seen = true;
}, 3000);
return () => {
if (seen) {
api.hark.seen();
}
};
}, []);
const [newNotifications, ...notifications] =
Array.from(props.showArchive ? props.archive : props.notifications) || [];
const notificationsByDay = f.flow(
f.map<DatedTimebox>(([date, nots]) => [
date,
nots.filter(filterNotification(associations, props.filter)),
]),
f.groupBy<DatedTimebox>(([date]) =>
moment(daToUnix(date)).format("DDMMYYYY")
),
f.values
)(notifications);
return (
<Col overflowY="auto" flexGrow="1">
{newNotifications && (
<DaySection
latest
timeboxes={[newNotifications]}
contacts={props.contacts}
archive={!!props.showArchive}
associations={props.associations}
graphConfig={props.notificationsGraphConfig}
groupConfig={props.notificationsGroupConfig}
chatConfig={props.notificationsChatConfig}
api={api}
/>
)}
{_.map(
notificationsByDay,
(timeboxes, idx) =>
timeboxes.length > 0 && (
<DaySection
key={idx}
timeboxes={timeboxes}
contacts={props.contacts}
archive={!!props.showArchive}
associations={props.associations}
api={api}
graphConfig={props.notificationsGraphConfig}
groupConfig={props.notificationsGroupConfig}
chatConfig={props.notificationsChatConfig}
/>
)
)}
</Col>
);
}
function sortTimeboxes([a]: DatedTimebox, [b]: DatedTimebox) {
return b.subtract(a);
}
function sortIndexedNotification(
{ notification: a }: IndexedNotification,
{ notification: b }: IndexedNotification
) {
return b.time - a.time;
}
function DaySection({
contacts,
archive,
timeboxes,
latest = false,
associations,
api,
groupConfig,
graphConfig,
chatConfig,
}) {
const calendar = latest
? MOMENT_CALENDAR_DATE
: { ...MOMENT_CALENDAR_DATE, sameDay: "[Earlier Today]" };
if (timeboxes.length === 0) {
return null;
}
return (
<>
<Box position="sticky" zIndex="3" top="-1px" bg="white">
<Box p="2" bg="scales.black05">
{moment(daToUnix(timeboxes[0][0])).calendar(null, calendar)}
</Box>
</Box>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}>
{(i !== 0 || j !== 0) && (
<Box flexShrink="0" height="4px" bg="scales.black05" />
)}
<Notification
graphConfig={graphConfig}
groupConfig={groupConfig}
chatConfig={chatConfig}
api={api}
associations={associations}
notification={not}
archived={archive}
contacts={contacts}
time={date}
/>
</React.Fragment>
))
)}
</>
);
}

View File

@ -0,0 +1,43 @@
import React from "react";
import { Box } from "@tlon/indigo-react";
import { MetadataBody, NotificationProps } from "./types";
import { Header } from "./header";
function getInvolvedUsers(body: MetadataBody) {
return [];
}
function getDescription(body: MetadataBody) {
const b = body.metadata;
if ("new" in b) {
return "created";
} else if ("changedTitle" in b) {
return "changed the title to";
} else if ("changedDescription" in b) {
return "changed the description to";
} else if ("changedColor" in b) {
return "changed the color to";
} else if ("deleted" in b) {
return "deleted";
} else {
throw new Error("bad metadata frond");
}
}
export function MetadataNotification(props: NotificationProps<"metadata">) {
const { unread } = props;
const description = getDescription(unread.unreads[0].body);
return (
<Box p="2">
<Header
authors={[]}
description={description}
group={unread.group}
channel={unread.channel}
date={unread.date}
/>
</Box>
);
}

View File

@ -0,0 +1,175 @@
import React, { ReactNode, useCallback, useMemo } from "react";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import _ from "lodash";
import {
GraphNotificationContents,
IndexedNotification,
GroupNotificationContents,
NotificationGraphConfig,
GroupNotificationsConfig,
NotifIndex,
Associations,
} from "~/types";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { GroupNotification } from "./group";
import { GraphNotification } from "./graph";
import { ChatNotification } from "./chat";
import { BigInteger } from "big-integer";
interface NotificationProps {
notification: IndexedNotification;
time: BigInteger;
associations: Associations;
api: GlobalApi;
archived: boolean;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
chatConfig: string[];
}
function getMuted(
idx: NotifIndex,
groups: GroupNotificationsConfig,
graphs: NotificationGraphConfig,
chat: string[]
) {
if ("graph" in idx) {
const { graph } = idx.graph;
return _.findIndex(graphs?.watching || [], (g) => g === graph) === -1;
}
if ("group" in idx) {
return _.findIndex(groups || [], (g) => g === idx.group.group) === -1;
}
if ("chat" in idx) {
return _.findIndex(chat || [], (c) => c === idx.chat) === -1;
}
return false;
}
function NotificationWrapper(props: {
api: GlobalApi;
time: BigInteger;
notif: IndexedNotification;
children: ReactNode;
archived: boolean;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
chatConfig: string[];
}) {
const { api, time, notif, children } = props;
const onArchive = useCallback(async () => {
return api.hark.archive(time, notif.index);
}, [time, notif]);
const isMuted = getMuted(
notif.index,
props.groupConfig,
props.graphConfig,
props.chatConfig
);
const onChangeMute = useCallback(async () => {
const func = isMuted ? "unmute" : "mute";
return api.hark[func](notif.index);
}, [notif, api, isMuted]);
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
return (
<Row alignItems="center" justifyContent="space-between">
{children}
<Row gapX="2" p="2" alignItems="center">
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute}>
{changeMuteDesc}
</StatelessAsyncAction>
{!props.archived && (
<StatelessAsyncAction name={time.toString()} onClick={onArchive}>
Archive
</StatelessAsyncAction>
)}
</Row>
</Row>
);
}
export function Notification(props: NotificationProps) {
const { notification, associations, archived } = props;
const { read, contents, time } = notification.notification;
const Wrapper = ({ children }) => (
<NotificationWrapper
archived={archived}
notif={notification}
time={props.time}
api={props.api}
graphConfig={props.graphConfig}
groupConfig={props.groupConfig}
chatConfig={props.chatConfig}
>
{children}
</NotificationWrapper>
);
if ("graph" in notification.index) {
const index = notification.index.graph;
const c: GraphNotificationContents = (contents as any).graph;
return (
<Wrapper>
<GraphNotification
api={props.api}
index={index}
contents={c}
contacts={props.contacts}
read={read}
archived={archived}
timebox={props.time}
time={time}
associations={associations}
/>
</Wrapper>
);
}
if ("group" in notification.index) {
const index = notification.index.group;
const c: GroupNotificationContents = (contents as any).group;
return (
<Wrapper>
<GroupNotification
api={props.api}
index={index}
contents={c}
contacts={props.contacts}
read={read}
timebox={props.time}
archived={archived}
time={time}
associations={associations}
/>
</Wrapper>
);
}
if ("chat" in notification.index) {
const index = notification.index.chat;
const c: ChatNotificationContents = (contents as any).chat;
return (
<Wrapper>
<ChatNotification
api={props.api}
index={index}
contents={c}
contacts={props.contacts}
read={read}
archived={archived}
groups={{}}
timebox={props.time}
time={time}
associations={associations}
/>
</Wrapper>
);
}
return null;
}

View File

@ -0,0 +1,141 @@
import React, { useCallback, useState } from "react";
import _ from 'lodash';
import { Box, Col, Text, Row } from "@tlon/indigo-react";
import { Link, Switch, Route } from "react-router-dom";
import { Body } from "~/views/components/Body";
import { PropFunc } from "~/types/util";
import Inbox from "./inbox";
import NotificationPreferences from "./preferences";
import { Dropdown } from "~/views/components/Dropdown";
import { Formik } from "formik";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import GroupSearch from "~/views/components/GroupSearch";
const baseUrl = "/~notifications";
const HeaderLink = (
props: PropFunc<typeof Text> & { view?: string; current: string }
) => {
const { current, view, ...textProps } = props;
const to = view ? `${baseUrl}/${view}` : baseUrl;
const active = view ? current === view : !current;
return (
<Link to={to}>
<Text px="2" {...textProps} gray={!active} />
</Link>
);
};
interface NotificationFilter {
groups: string[];
}
export default function NotificationsScreen(props: any) {
const relativePath = (p: string) => baseUrl + p;
const [filter, setFilter] = useState<NotificationFilter>({ groups: [] });
const onSubmit = async (values: { groups: string }) => {
setFilter({ groups: values.groups ? [values.groups] : [] });
};
const groupFilterDesc =
filter.groups.length === 0
? "All"
: filter.groups
.map((g) => props.associations?.contacts?.[g]?.metadata?.title)
.join(", ");
return (
<Switch>
<Route
path={[relativePath("/:view"), relativePath("")]}
render={(routeProps) => {
const { view } = routeProps.match.params;
return (
<Body>
<Col height="100%">
<Row
p="3"
alignItems="center"
height="48px"
justifyContent="space-between"
width="100%"
borderBottom="1"
borderBottomColor="washedGray"
>
<Box>Updates </Box>
<Row>
<Box>
<HeaderLink current={view} view="">
Inbox
</HeaderLink>
</Box>
<Box>
<HeaderLink current={view} view="archive">
Archive
</HeaderLink>
</Box>
<Box>
<HeaderLink current={view} view="preferences">
Preferences
</HeaderLink>
</Box>
</Row>
<Dropdown
alignX="right"
alignY="top"
options={
<Col
p="2"
backgroundColor="white"
border={1}
borderRadius={1}
borderColor="lightGray"
gapY="2"
>
<FormikOnBlur
initialValues={filter}
onSubmit={onSubmit}
>
<GroupSearch
id="groups"
label="Filter Groups"
caption="Only show notifications from this group"
associations={props.associations}
/>
</FormikOnBlur>
</Col>
}
>
<Box>
<Text mr="1" gray>
Filter:
</Text>
{groupFilterDesc}
</Box>
</Dropdown>
</Row>
{view === "archive" && (
<Inbox
{...props}
archive={props.archivedNotifications}
showArchive
filter={filter.groups}
/>
)}
{view === "preferences" && (
<NotificationPreferences
graphConfig={props.notificationsGraphConfig}
api={props.api}
dnd={props.doNotDisturb}
/>
)}
{!view && <Inbox {...props} filter={filter.groups} />}
</Col>
</Body>
);
}}
/>
</Switch>
);
}

View File

@ -0,0 +1,90 @@
import React, { useCallback } from "react";
import { Box, Col, ManagedCheckboxField as Checkbox } from "@tlon/indigo-react";
import { Formik, Form, FormikHelpers } from "formik";
import * as Yup from "yup";
import _ from "lodash";
import { AsyncButton } from "~/views/components/AsyncButton";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import { NotificationGraphConfig } from "~/types";
import GlobalApi from "~/logic/api/global";
interface FormSchema {
mentions: boolean;
dnd: boolean;
watchOnSelf: boolean;
watching: string[];
}
interface NotificationPreferencesProps {
graphConfig: NotificationGraphConfig;
dnd: boolean;
api: GlobalApi;
}
export default function NotificationPreferences(
props: NotificationPreferencesProps
) {
const { graphConfig, api, dnd } = props;
const initialValues: FormSchema = {
mentions: graphConfig.mentions,
watchOnSelf: graphConfig.watchOnSelf,
dnd,
watching: graphConfig.watching,
};
const onSubmit = useCallback(
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
console.log(values);
try {
let promises: Promise<any>[] = [];
if (values.mentions !== graphConfig.mentions) {
promises.push(api.hark.setMentions(values.mentions));
}
if (values.watchOnSelf !== graphConfig.watchOnSelf) {
promises.push(api.hark.setWatchOnSelf(values.watchOnSelf));
}
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
promises.push(api.hark.setDoNotDisturb(values.dnd))
}
await Promise.all(promises);
actions.setStatus({ success: null });
actions.resetForm({ values: initialValues });
} catch (e) {
console.error(e);
actions.setStatus({ error: e.message });
}
},
[api, graphConfig]
);
return (
<FormikOnBlur
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form>
<Col maxWidth="384px" p="3" gapY="4">
<Checkbox
label="Do not disturb"
id="dnd"
caption="You won't see the notification badge, but notifications will still appear in your inbox."
/>
<Checkbox
label="Watch for replies"
id="watchOnSelf"
caption="Automatically follow a post for notifications when it's yours"
/>
<Checkbox
label="Watch for mentions"
id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined"
/>
</Col>
</Form>
</FormikOnBlur>
);
}

View File

@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { Comment, NoteId } from '~/types/publish-update';
import React from 'react';
import { Contacts } from '~/types/contact-update';
import GlobalApi from '~/logic/api/global';
import { Box, Row } from '@tlon/indigo-react';
@ -7,8 +6,8 @@ import styled from 'styled-components';
import { Author } from '~/views/apps/publish/components/Author';
import { GraphNode, TextContent } from '~/types/graph-update';
import tokenizeMessage from '~/logic/lib/tokenizeMessage';
import RichText from '~/views/components/RichText';
import { LocalUpdateRemoteContentPolicy } from '~/types';
import { MentionText } from '~/views/components/MentionText';
const ClickBox = styled(Box)`
cursor: pointer;
@ -30,9 +29,7 @@ interface CommentItemProps {
export function CommentItem(props: CommentItemProps) {
const { ship, contacts, name, api, remoteContentPolicy } = props;
const commentData = props.comment?.post;
const comment = commentData.contents[0] as TextContent;
const content = tokenizeMessage(comment.text).flat().join(' ');
const comment = commentData.contents;
const disabled = props.pending || window.ship !== commentData.author;
@ -61,7 +58,11 @@ export function CommentItem(props: CommentItemProps) {
</Author>
</Row>
<Box mb={2}>
<RichText className="f9 white-d" remoteContentPolicy={remoteContentPolicy}>{content}</RichText>
<MentionText
contacts={contacts}
content={comment}
remoteContentPolicy={remoteContentPolicy}
/>
</Box>
</Box>
);

View File

@ -1,13 +1,14 @@
import React from 'react';
import { Col } from '@tlon/indigo-react';
import { CommentItem } from '~/views/components/CommentItem';
import CommentInput from '~/views/components/CommentInput';
import { CommentItem } from './CommentItem';
import CommentInput from './CommentInput';
import { Contacts } from '~/types/contact-update';
import GlobalApi from '~/logic/api/global';
import { FormikHelpers } from 'formik';
import { GraphNode } from '~/types/graph-update';
import { createPost } from '~/logic/api/graph';
import { LocalUpdateRemoteContentPolicy } from '~/types';
import { scanForMentions } from '~/logic/lib/graph';
interface CommentsProps {
comments: GraphNode;
@ -28,7 +29,8 @@ export function Comments(props: CommentsProps) {
actions: FormikHelpers<{ comment: string }>
) => {
try {
const post = createPost([{ text: comment }], comments?.post?.index);
const content = scanForMentions(comment);
const post = createPost(content, comments?.post?.index);
await api.graph.addPost(ship, name, post);
actions.resetForm();
actions.setStatus({ success: null });
@ -44,7 +46,7 @@ export function Comments(props: CommentsProps) {
{Array.from(comments.children).reverse().map(([idx, comment]) => (
<CommentItem
comment={comment}
key={idx}
key={idx.toString()}
contacts={props.contacts}
api={api}
name={name}

View File

@ -15,7 +15,7 @@ export function FormikOnBlur<
) {
const { values } = formikBag;
formikBag.submitForm().then(() => {
formikBag.resetForm({ values });
formikBag.resetForm({ values, touched: {} });
});
}
}, [

View File

@ -79,7 +79,7 @@ export function GroupSearch(props: InviteSearchProps) {
: Object.values(props.associations?.contacts || {});
}, [props.associations?.contacts]);
const [{ value }, meta, { setValue }] = useField(props.id);
const [{ value }, meta, { setValue, setTouched }] = useField(props.id);
const { title: groupTitle } =
props.associations.contacts?.[value]?.metadata || {};
@ -87,12 +87,14 @@ export function GroupSearch(props: InviteSearchProps) {
const onSelect = useCallback(
(a: Association) => {
setValue(a["group-path"]);
setTouched(true);
},
[setValue]
);
const onUnselect = useCallback(() => {
setValue(undefined);
setTouched(true);
}, [setValue]);
return (

View File

@ -0,0 +1,51 @@
import React from "react";
import _ from "lodash";
import { Text } from "@tlon/indigo-react";
import { Contacts, Content, LocalUpdateRemoteContentPolicy } from "~/types";
import RichText from "~/views/components/RichText";
import { cite } from "~/logic/lib/util";
interface MentionTextProps {
contacts: Contacts;
content: Content[];
remoteContentPolicy: LocalUpdateRemoteContentPolicy;
}
export function MentionText(props: MentionTextProps) {
const { content, contacts } = props;
return (
<>
{_.map(content, (c, idx) => {
if ("text" in c) {
return (
<RichText
inline
key={idx}
remoteContentPolicy={props.remoteContentPolicy}
>
{c.text}
</RichText>
);
} else if ("mention" in c) {
return (
<Mention key={idx} contacts={contacts || {}} ship={c.mention} />
);
}
return null;
})}
</>
);
}
function Mention(props: { ship: string; contacts: Contacts }) {
const { contacts, ship } = props;
const contact = contacts[ship];
const showNickname = !!contact?.nickname;
const name = showNickname ? contact?.nickname : cite(ship);
return (
<Text mx="2px" px="2px" bg="washedBlue" color="blue" mono={!showNickname}>
{name}
</Text>
);
}

View File

@ -30,7 +30,7 @@ const RichText = React.memo(({ remoteContentPolicy, ...props }) => (
return <BaseAnchor target='_blank' rel='noreferrer noopener' borderBottom='1px solid' {...props}>{props.children}</BaseAnchor>;
},
paragraph: (paraProps) => {
return <Text display='block' mb='2' {...props}>{paraProps.children}</Text>;
return <Text display={props.inline ? 'inline' : 'block'} mb='2' {...props}>{paraProps.children}</Text>;
}
}}
plugins={[[

View File

@ -28,6 +28,11 @@ const StatusBar = (props) => {
</Button>
<StatusBarItem mr={2} onClick={() => props.api.local.setOmnibox()}>
{ !props.doNotDisturb && props.notificationsCount > 0 &&
(<Box display="block" right="-8px" top="-8px" position="absolute" >
<Icon color="blue" icon="Bullet" />
</Box>
)}
<Icon icon='LeapArrow'/>
<Text ml={2} color='black'>
Leap
@ -42,7 +47,15 @@ const StatusBar = (props) => {
/>
</Row>
<Row justifyContent="flex-end" collapse>
{!props.doNotDisturb && (<StatusBarItem color="blue" px='12px' mr='2' onClick={() => props.history.push('/~notifications')}>
<Text
fontWeight={props.notificationsCount > 0 ? "500" : "400"}
fontSize='0'
color="blue"
>
{(props.notificationsCount > 99) ? "99+" : props.notificationsCount}
</Text>
</StatusBarItem>)}
<StatusBarItem px={'2'} flexShrink='0' onClick={() => props.history.push('/~profile')}>
<Sigil ship={props.ship} size={16} color='black' classes='mix-blend-diff' icon />
<Text ml={2} display={["none", "inline"]} fontFamily="mono">~{props.ship}</Text>

View File

@ -117,7 +117,7 @@ export class Omnibox extends Component {
const { props } = this;
this.setState({ results: this.initialResults(), query: '' }, () => {
props.api.local.setOmnibox();
if (defaultApps.includes(app.toLowerCase()) || app === 'profile' || app === 'Links' || app === 'home') {
if (defaultApps.includes(app.toLowerCase()) || app === 'profile' || app === 'Links' || app === 'home' || app === 'inbox') {
props.history.push(link);
} else {
window.location.href = link;

View File

@ -30,17 +30,20 @@ export class OmniboxResult extends Component {
const sigilFill = (this.state.hovered || (selected === link)) ? '#3a8ff7' : '#ffffff';
let graphic = <div />;
if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links') {
if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links' || icon === 'inbox') {
icon = (icon === 'inbox') ? 'Inbox' : icon;
icon = (icon === 'Link') ? 'Links' : icon;
graphic = <Icon display="inline-block" verticalAlign="middle" icon={icon} mr='2' size='16px' color={iconFill} />;
} else if (icon === 'logout') {
graphic = <Icon display="inline-block" verticalAlign="middle" icon='ArrowWest' mr='2' size='16px' color={iconFill} />;
} else if (icon === 'profile') {
graphic = <Sigil color={sigilFill} classes='dib 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') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Circle' mr='2' size='16px' color={iconFill} />;
} else if (icon === 'notifications') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='16px' color={iconFill} />;
} else {
graphic = <Icon icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
graphic = <Icon display='inline-block' icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
}
return graphic;

View File

@ -3,8 +3,10 @@ import { Link, useHistory } from "react-router-dom";
import { Icon, Row, Col, Button, Text, Box, Action } from "@tlon/indigo-react";
import { Dropdown } from "~/views/components/Dropdown";
import { Association } from "~/types";
import { Association, NotificationGraphConfig } from "~/types";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { appIsGraph } from "~/logic/lib/util";
const ChannelMenuItem = ({
icon,
@ -27,6 +29,8 @@ const ChannelMenuItem = ({
interface ChannelMenuProps {
association: Association;
api: GlobalApi;
graphNotificationConfig: NotificationGraphConfig;
chatNotificationConfig: string[];
}
export function ChannelMenu(props: ChannelMenuProps) {
@ -34,8 +38,9 @@ export function ChannelMenu(props: ChannelMenuProps) {
const history = useHistory();
const { metadata } = association;
const app = metadata.module || association["app-name"];
const workspace = history.location.pathname.startsWith('/~landscape/home')
? '/home' : association?.['group-path'];
const workspace = history.location.pathname.startsWith("/~landscape/home")
? "/home"
: association?.["group-path"];
const baseUrl = `/~landscape${workspace}/resource/${app}${association["app-path"]}`;
const appPath = association["app-path"];
@ -44,6 +49,22 @@ export function ChannelMenu(props: ChannelMenuProps) {
: appPath.split("/");
const isOurs = ship.slice(1) === window.ship;
const isMuted = appIsGraph(app)
? props.graphNotificationConfig.watching.findIndex((a) => a === appPath) ===
-1
: props.chatNotificationConfig.findIndex((a) => a === appPath) === -1;
const onChangeMute = async () => {
const func =
association["app-name"] === "chat"
? isMuted
? "listenChat"
: "ignoreChat"
: isMuted
? "listenGraph"
: "ignoreGraph";
await api.hark[func](appPath);
};
const onUnsubscribe = useCallback(async () => {
const app = metadata.module || association["app-name"];
switch (app) {
@ -83,15 +104,35 @@ export function ChannelMenu(props: ChannelMenuProps) {
return (
<Dropdown
options={
<Col backgroundColor="white" border={1} borderRadius={1} borderColor="lightGray">
<Col
backgroundColor="white"
border={1}
borderRadius={1}
borderColor="lightGray"
>
<ChannelMenuItem color="blue" icon="Inbox">
<StatelessAsyncAction
m="2"
bg="white"
name="notif"
onClick={onChangeMute}
>
{isMuted ? "Unmute" : "Mute"} this channel
</StatelessAsyncAction>
</ChannelMenuItem>
{isOurs ? (
<>
<ChannelMenuItem color="red" icon="TrashCan">
<Action m="2" backgroundColor='white' destructive onClick={onDelete}>
<Action
m="2"
backgroundColor="white"
destructive
onClick={onDelete}
>
Delete Channel
</Action>
</ChannelMenuItem>
<ChannelMenuItem bottom icon="Gear" color='black'>
<ChannelMenuItem bottom icon="Gear" color="black">
<Link to={`${baseUrl}/settings`}>
<Box fontSize={0} p="2">
Channel Settings

View File

@ -8,6 +8,7 @@ import DojoApp from '~/views/apps/dojo/app';
import Landscape from '~/views/landscape/index';
import Profile from '~/views/apps/profile/profile';
import ErrorComponent from '~/views/components/Error';
import Notifications from '~/views/apps/notifications/notifications';
export const Container = styled(Box)`
@ -61,6 +62,12 @@ export const Content = (props) => {
/>
)}
/>
<Route
path="/~notifications"
render={ p => (
<Notifications {...props} />
)}
/>
<Route
render={p => (
<ErrorComponent

View File

@ -1,176 +0,0 @@
import React, { useEffect } from "react";
import { AsyncButton } from "~/views/components/AsyncButton";
import * as Yup from "yup";
import {
Box,
ManagedTextInputField as Input,
ManagedCheckboxField as Checkbox,
Col,
Label,
Button,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import { FormError } from "~/views/components/FormError";
import { Group, GroupPolicy } from "~/types/group-update";
import { Enc } from "~/types/noun";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath, roleForShip } from "~/logic/lib/group";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import { ColorInput } from "~/views/components/ColorInput";
import { useHistory } from "react-router-dom";
import { uxToHex } from '~/logic/lib/util';
interface FormSchema {
title: string;
description: string;
color: string;
isPrivate: boolean;
}
const formSchema = Yup.object({
title: Yup.string().required("Group must have a name"),
description: Yup.string(),
color: Yup.string(),
isPrivate: Yup.boolean(),
});
interface GroupSettingsProps {
group: Group;
association: Association;
api: GlobalApi;
}
export function GroupSettings(props: GroupSettingsProps) {
const { group, association } = props;
const { metadata } = association;
const history = useHistory();
const currentPrivate = "invite" in props.group.policy;
const initialValues: FormSchema = {
title: metadata?.title,
description: metadata?.description,
color: metadata?.color,
isPrivate: currentPrivate,
};
const onSubmit = async (
values: FormSchema,
actions: FormikHelpers<FormSchema>
) => {
try {
const { title, description, color, isPrivate } = values;
const uxColor = uxToHex(color);
await props.api.metadata.update(props.association, {
title,
description,
color: uxColor
});
if (isPrivate !== currentPrivate) {
const resource = resourceFromPath(props.association["group-path"]);
const newPolicy: Enc<GroupPolicy> = isPrivate
? { invite: { pending: [] } }
: { open: { banRanks: [], banned: [] } };
const diff = { replace: newPolicy };
await props.api.groups.changePolicy(resource, diff);
}
actions.setStatus({ success: null });
} catch (e) {
console.log(e);
actions.setStatus({ error: e.message });
}
};
const onDelete = async () => {
const name = association['group-path'].split('/').pop();
if (prompt(`To confirm deleting this group, type ${name}`) === name) {
await props.api.contacts.delete(association["group-path"]);
history.push("/");
}
};
const disabled =
resourceFromPath(association["group-path"]).ship.slice(1) !== window.ship &&
roleForShip(group, window.ship) !== "admin";
return (
<Box height="100%" overflowY="auto">
<Formik
validationSchema={formSchema}
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form style={{ display: "contents" }}>
<Box
maxWidth="300px"
gridTemplateColumns="1fr"
gridAutoRows="auto"
display="grid"
gridRowGap={4}
my={3}
mx={4}
>
{!disabled ? (
<Col>
<Label>Delete Group</Label>
<Label gray mt="2">
Permanently delete this group. (All current members will no
longer see this group.)
</Label>
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
Delete this group
</StatelessAsyncButton>
</Col>
) : (
<Col>
<Label>Leave Group</Label>
<Label gray mt="2">
Leave this group. You can rejoin if it is an open group, or if
you are reinvited
</Label>
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
Leave this group
</StatelessAsyncButton>
</Col>
)}
<Box borderBottom={1} borderBottomColor="washedGray" />
<Input
id="title"
label="Group Name"
caption="The name for your group to be called by"
disabled={disabled}
/>
<Input
id="description"
label="Group Description"
caption="The description of your group"
disabled={disabled}
/>
<ColorInput
id="color"
label="Group color"
caption="A color to represent your group"
disabled={disabled}
/>
<Checkbox
id="isPrivate"
label="Private group"
caption="If enabled, users must be invited to join the group"
disabled={disabled}
/>
<AsyncButton
disabled={disabled}
primary
loadingText="Updating.."
border
>
Save
</AsyncButton>
<FormError message="Failed to update settings" />
</Box>
</Form>
</Formik>
</Box>
);
}

View File

@ -0,0 +1,134 @@
import React, { useEffect } from "react";
import { AsyncButton } from "~/views/components/AsyncButton";
import * as Yup from "yup";
import {
Box,
ManagedTextInputField as Input,
ManagedToggleSwitchField as Checkbox,
Col,
Label,
Button,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import { FormError } from "~/views/components/FormError";
import { Group, GroupPolicy } from "~/types/group-update";
import { Enc } from "~/types/noun";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath, roleForShip } from "~/logic/lib/group";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import { ColorInput } from "~/views/components/ColorInput";
import { useHistory } from "react-router-dom";
import { uxToHex } from "~/logic/lib/util";
interface FormSchema {
title: string;
description: string;
color: string;
isPrivate: boolean;
}
const formSchema = Yup.object({
title: Yup.string().required("Group must have a name"),
description: Yup.string(),
color: Yup.string(),
isPrivate: Yup.boolean(),
});
interface GroupAdminSettingsProps {
group: Group;
association: Association;
api: GlobalApi;
}
export function GroupAdminSettings(props: GroupAdminSettingsProps) {
const { group, association } = props;
const { metadata } = association;
const history = useHistory();
const currentPrivate = "invite" in props.group.policy;
const initialValues: FormSchema = {
title: metadata?.title,
description: metadata?.description,
color: metadata?.color,
isPrivate: currentPrivate,
};
const onSubmit = async (
values: FormSchema,
actions: FormikHelpers<FormSchema>
) => {
try {
const { title, description, color, isPrivate } = values;
const uxColor = uxToHex(color);
await props.api.metadata.update(props.association, {
title,
description,
color: uxColor,
});
if (isPrivate !== currentPrivate) {
const resource = resourceFromPath(props.association["group-path"]);
const newPolicy: Enc<GroupPolicy> = isPrivate
? { invite: { pending: [] } }
: { open: { banRanks: [], banned: [] } };
const diff = { replace: newPolicy };
await props.api.groups.changePolicy(resource, diff);
}
actions.setStatus({ success: null });
} catch (e) {
console.log(e);
actions.setStatus({ error: e.message });
}
};
const disabled =
resourceFromPath(association["group-path"]).ship.slice(1) !== window.ship &&
roleForShip(group, window.ship) !== "admin";
return (
<Formik
validationSchema={formSchema}
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form>
<Col gapY={4}>
<Input
id="title"
label="Group Name"
caption="The name for your group to be called by"
disabled={disabled}
/>
<Input
id="description"
label="Group Description"
caption="The description of your group"
disabled={disabled}
/>
<ColorInput
id="color"
label="Group color"
caption="A color to represent your group"
disabled={disabled}
/>
<Checkbox
id="isPrivate"
label="Private group"
caption="If enabled, users must be invited to join the group"
disabled={disabled}
/>
<AsyncButton
disabled={disabled}
primary
loadingText="Updating.."
border
>
Save
</AsyncButton>
<FormError message="Failed to update settings" />
</Col>
</Form>
</Formik>
);
}

View File

@ -0,0 +1,44 @@
import React, { useEffect } from "react";
import { AsyncButton } from "~/views/components/AsyncButton";
import * as Yup from "yup";
import {
Box,
ManagedTextInputField as Input,
ManagedToggleSwitchField as Checkbox,
Col,
Label,
Button,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import { FormError } from "~/views/components/FormError";
import { Group, GroupPolicy } from "~/types/group-update";
import { Enc } from "~/types/noun";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath, roleForShip } from "~/logic/lib/group";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import { ColorInput } from "~/views/components/ColorInput";
import { useHistory } from "react-router-dom";
import { uxToHex } from "~/logic/lib/util";
import { GroupAdminSettings } from "./Admin";
import { GroupPersonalSettings } from "./Personal";
import {GroupNotificationsConfig} from "~/types";
interface GroupSettingsProps {
group: Group;
association: Association;
api: GlobalApi;
notificationsGroupConfig: GroupNotificationsConfig;
}
export function GroupSettings(props: GroupSettingsProps) {
return (
<Box height="100%" overflowY="auto">
<Col maxWidth="384px" p="4" gapY="4">
<GroupPersonalSettings {...props} />
<Box borderBottom="1" borderBottomColor="washedGray" />
<GroupAdminSettings {...props} />
</Col>
</Box>
);
}

View File

@ -0,0 +1,97 @@
import React, { useCallback } from "react";
import { AsyncButton } from "~/views/components/AsyncButton";
import * as Yup from "yup";
import {
Box,
ManagedTextInputField as Input,
ManagedToggleSwitchField as Toggle,
Col,
Label,
Button,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import { FormError } from "~/views/components/FormError";
import { Group, GroupPolicy } from "~/types/group-update";
import { Enc } from "~/types/noun";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath, roleForShip } from "~/logic/lib/group";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import { ColorInput } from "~/views/components/ColorInput";
import { useHistory } from "react-router-dom";
import { uxToHex } from "~/logic/lib/util";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import {GroupNotificationsConfig} from "~/types";
function DeleteGroup(props: {
owner: boolean;
api: GlobalApi;
association: Association;
}) {
const history = useHistory();
const onDelete = async () => {
const name = props.association['group-path'].split('/').pop();
if (prompt(`To confirm deleting this group, type ${name}`) === name) {
await props.api.contacts.delete(props.association["group-path"]);
history.push("/");
}
};
const action = props.owner ? "Delete" : "Leave";
const description = props.owner
? "Permanently delete this group. (All current members will no longer see this group.)"
: "Leave this group. You can rejoin if it is an open group, or if you are reinvited";
return (
<Col>
<Label>{action} Group</Label>
<Label gray mt="2">
{description}
</Label>
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
{action} this group
</StatelessAsyncButton>
</Col>
);
}
interface FormSchema {
watching: boolean;
}
export function GroupPersonalSettings(props: {
api: GlobalApi;
association: Association;
notificationsGroupConfig: GroupNotificationsConfig;
}) {
const groupPath = props.association['group-path'];
const watching = props.notificationsGroupConfig.findIndex(g => g === groupPath) !== -1;
const initialValues: FormSchema = {
watching
};
const onSubmit = async (values: FormSchema) => {
if(values.watching === watching) {
return;
}
const func = values.watching ? 'listenGroup' : 'ignoreGroup';
await props.api.hark[func](groupPath);
};
return (
<Col gapY="4">
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Toggle
id="watching"
label="Notify me on group activity"
caption="Send me notifications when this group changes"
/>
</FormikOnBlur>
<DeleteGroup association={props.association} owner api={props.api} />
</Col>
);
}

View File

@ -68,6 +68,8 @@ export function GroupsPane(props: GroupsPaneProps) {
s3={props.s3}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
notificationsGroupConfig={props.notificationsGroupConfig}
{...routeProps}
baseUrl={baseUrl}
/>)}

View File

@ -7,10 +7,10 @@ import { Contacts } from "~/types/contact-update";
import { Group } from "~/types/group-update";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import {S3State} from "~/types";
import {GroupNotificationsConfig, S3State} from "~/types";
import { ContactCard } from "./ContactCard";
import { GroupSettings } from "./GroupSettings";
import { GroupSettings } from "./GroupSettings/GroupSettings";
import { Participants } from "./Participants";
@ -41,6 +41,7 @@ export function PopoverRoutes(
api: GlobalApi;
hideAvatars: boolean;
hideNicknames: boolean;
notificationsGroupConfig: GroupNotificationsConfig;
} & RouteComponentProps
) {
const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`;
@ -125,6 +126,7 @@ export function PopoverRoutes(
group={props.group}
association={props.association}
api={props.api}
notificationsGroupConfig={props.notificationsGroupConfig}
/>
)}
{view === "participants" && (

View File

@ -57,7 +57,13 @@ export function Resource(props: ResourceProps) {
<Route
path={relativePath("")}
render={(routeProps) => (
<ResourceSkeleton baseUrl={props.baseUrl} {...skelProps} atRoot>
<ResourceSkeleton
notificationsGraphConfig={props.notificationsGraphConfig}
notificationsChatConfig={props.notificationsChatConfig}
baseUrl={props.baseUrl}
{...skelProps}
atRoot
>
{app === "chat" ? (
<ChatResource {...props} />
) : app === "publish" ? (

View File

@ -6,13 +6,14 @@ import { Link } from "react-router-dom";
import { ChatResource } from "~/views/apps/chat/ChatResource";
import { PublishResource } from "~/views/apps/publish/PublishResource";
import RichText from '~/views/components/RichText';
import RichText from "~/views/components/RichText";
import { Association } from "~/types/metadata-update";
import GlobalApi from "~/logic/api/global";
import { RouteComponentProps, Route, Switch } from "react-router-dom";
import { ChannelSettings } from "./ChannelSettings";
import { ChannelMenu } from "./ChannelMenu";
import { NotificationGraphConfig } from "~/types";
const TruncatedBox = styled(Box)`
white-space: nowrap;
@ -22,6 +23,7 @@ const TruncatedBox = styled(Box)`
type ResourceSkeletonProps = {
association: Association;
notificationsGraphConfig: NotificationGraphConfig;
api: GlobalApi;
baseUrl: string;
children: ReactNode;
@ -33,13 +35,14 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
const { association, api, baseUrl, children, atRoot } = props;
const app = association?.metadata?.module || association["app-name"];
const appPath = association["app-path"];
const workspace = (baseUrl === '/~landscape/home') ? '/home' : association["group-path"];
const workspace =
baseUrl === "/~landscape/home" ? "/home" : association["group-path"];
const title = props.title || association?.metadata?.title;
const disableRemoteContent = {
audioShown: false,
imageShown: false,
oembedShown: false,
videoShown: false
videoShown: false,
};
return (
<Col width="100%" height="100%" overflowY="hidden">
@ -64,11 +67,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
</Box>
) : (
<Box
color="blue"
pr={2}
mr={2}
>
<Box color="blue" pr={2} mr={2}>
<Link to={`/~landscape${workspace}/resource/${app}${appPath}`}>
<Text color="blue">Go back to channel</Text>
</Link>
@ -78,22 +77,34 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
{atRoot && (
<>
<Box pr={1} mr={2}>
<Text display='inline-block' verticalAlign='middle'>{title}</Text>
<Text display="inline-block" verticalAlign="middle">
{title}
</Text>
</Box>
<TruncatedBox
display={["none", "block"]}
maxWidth="60%"
verticalAlign='middle'
verticalAlign="middle"
flexShrink={1}
title={association?.metadata?.description}
color="gray"
>
<RichText color='gray' remoteContentPolicy={disableRemoteContent} mb='0' display='inline-block'>
{association?.metadata?.description}
<RichText
color="gray"
remoteContentPolicy={disableRemoteContent}
mb="0"
display="inline-block"
>
{association?.metadata?.description}
</RichText>
</TruncatedBox>
<Box flexGrow={1} />
<ChannelMenu association={association} api={api} />
<ChannelMenu
graphNotificationConfig={props.notificationsGraphConfig}
chatNotificationConfig={props.notificationsChatConfig}
association={association}
api={api}
/>
</>
)}
</Box>

View File

@ -20,6 +20,7 @@ const scales = {
white80: "rgba(255,255,255,0.8)",
white90: "rgba(255,255,255,0.9)",
white100: "rgba(255,255,255,1)",
black05: "rgba(0,0,0,0.05)",
black10: "rgba(0,0,0,0.1)",
black20: "rgba(0,0,0,0.2)",
black30: "rgba(0,0,0,0.3)",