Merge branch 'lt/link-js' into lf/global-skeleton-links

This commit is contained in:
Liam Fitzgerald 2020-09-21 14:50:58 +10:00
commit 75b9968bc7
53 changed files with 2100 additions and 3716 deletions

View File

@ -0,0 +1,48 @@
/- *resource
/+ store=graph-store, graph, default-agent, verb, dbug, pull-hook
~% %graph-pull-hook-top ..is ~
|%
+$ card card:agent:gall
++ config
^- config:pull-hook
:* %graph-store
update:store
%graph-update
%graph-push-hook
==
--
::
%- agent:dbug
^- agent:gall
%- (agent:pull-hook config)
^- (pull-hook:pull-hook config)
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
dep ~(. (default:pull-hook this config) bowl)
::
++ on-init on-init:def
++ on-save !>(~)
++ on-load on-load:def
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
++ on-agent on-agent:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-pull-nack
|= [=resource =tang]
^- (quip card _this)
:_ this
=- [%pass /pull-nack %agent [our.bowl %graph-store] %poke %graph-update -]~
!> ^- update:store
[%0 now.bowl [%archive-graph resource]]
::
++ on-pull-kick
|= =resource
^- (unit path)
=/ maybe-time (peek-update-log:graph resource)
?~ maybe-time `/
`/(scot %da u.maybe-time)
--

View File

@ -0,0 +1,128 @@
/+ store=graph-store
/+ metadata
/+ res=resource
/+ graph
/+ group
/+ default-agent
/+ dbug
/+ push-hook
~% %graph-push-hook-top ..is ~
|%
+$ card card:agent:gall
++ config
^- config:push-hook
:* %graph-store
/updates
update:store
%graph-update
%graph-pull-hook
==
::
+$ agent (push-hook:push-hook config)
::
++ is-allowed
|= [=resource:res =bowl:gall requires-admin=?]
^- ?
=/ grp ~(. group bowl)
=/ met ~(. metadata bowl)
=/ group-paths (groups-from-resource:met [%graph (en-path:res resource)])
?~ group-paths %.n
?: requires-admin
(is-admin:grp src.bowl i.group-paths)
?| (is-member:grp src.bowl i.group-paths)
(is-admin:grp src.bowl i.group-paths)
==
--
::
%- agent:dbug
^- agent:gall
%- (agent:push-hook config)
^- agent
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
grp ~(. group bowl)
gra ~(. graph bowl)
::
++ on-init on-init:def
++ on-save !>(~)
++ on-load on-load:def
++ on-poke on-poke:def
++ on-agent on-agent:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
::
++ should-proxy-update
|= =vase
^- ?
=/ =update:store !<(update:store vase)
?- -.q.update
%add-graph (is-allowed resource.q.update bowl %.y)
%remove-graph (is-allowed resource.q.update bowl %.y)
%add-nodes (is-allowed resource.q.update bowl %.n)
%remove-nodes (is-allowed resource.q.update bowl %.y)
%add-signatures (is-allowed resource.uid.q.update bowl %.n)
%remove-signatures (is-allowed resource.uid.q.update bowl %.y)
%archive-graph (is-allowed resource.q.update bowl %.y)
%unarchive-graph %.n
%add-tag %.n
%remove-tag %.n
%keys %.n
%tags %.n
%tag-queries %.n
%run-updates (is-allowed resource.q.update bowl %.y)
==
::
++ resource-for-update
|= =vase
^- (unit resource:res)
=/ =update:store !<(update:store vase)
?- -.q.update
%add-graph `resource.q.update
%remove-graph `resource.q.update
%add-nodes `resource.q.update
%remove-nodes `resource.q.update
%add-signatures `resource.uid.q.update
%remove-signatures `resource.uid.q.update
%archive-graph `resource.q.update
%unarchive-graph ~
%add-tag ~
%remove-tag ~
%keys ~
%tags ~
%tag-queries ~
%run-updates `resource.q.update
==
::
++ initial-watch
|= [=path =resource:res]
^- vase
?> (is-allowed resource bowl %.n)
!> ^- update:store
?~ path
:: new subscribe
::
(get-graph:gra resource)
:: resubscribe
::
=/ =time (slav %da i.path)
=/ =update-log:store (get-update-log-subset:gra resource time)
[%0 now.bowl [%run-updates resource update-log]]
::
++ take-update
|= =vase
^- [(list card) agent]
=/ =update:store !<(update:store vase)
?+ -.q.update [~ this]
%remove-graph
:_ this
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
::
%archive-graph
:_ this
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
==
--

View File

@ -532,6 +532,15 @@
^- [index:store node:store]
[(snoc index atom) node]
==
::
[%x %update-log-subset @ @ @ @ ~]
=/ =ship (slav %p i.t.t.path)
=/ =term i.t.t.t.path
=/ start=(unit time) (slaw %da i.t.t.t.t.path)
=/ end=(unit time) (slaw %da i.t.t.t.t.t.path)
=/ update-log=(unit update-log:store) (~(get by update-logs) [ship term])
?~ update-log [~ ~]
``noun+!>((subset:orm-log u.update-log start end))
::
[%x %update-log @ @ ~]
=/ =ship (slav %p i.t.t.path)

View File

@ -2,7 +2,7 @@
/+ drum=hood-drum, helm=hood-helm, kiln=hood-kiln
|%
+$ state
$: %9
$: %10
drum=state:drum
helm=state:helm
kiln=state:kiln
@ -12,6 +12,7 @@
[ver=?(%1 %2 %3 %4 %5 %6) lac=(map @tas fin-any-state)]
[%7 drum=state:drum helm=state:helm kiln=state:kiln]
[%8 drum=state:drum helm=state:helm kiln=state:kiln]
[%9 drum=state:drum helm=state:helm kiln=state:kiln]
==
+$ any-state-tuple
$: drum=any-state:drum

View File

@ -1,646 +1,46 @@
:: link-listen-hook [landscape]:
:: link-listen-hook: no longer in use
::
:: get your friends' bookmarks
::
:: keeps track of a listening=(set app-path). users can manually add to and
:: remove from this set.
::
:: for all ships in groups associated with those resources, we subscribe to
:: their link's local-pages and annotations at the resource path (through
:: link-proxy-hook), and forward all entries into our link-store as
:: submissions and comments.
::
:: if a subscription to a target fails, we assume it's because their
:: metadata+groups definition hasn't been updated to include us yet.
:: we retry with exponential backoff, maxing out at one hour timeouts.
:: to expede this process, we prod other potential listeners when we add
:: them to our metadata+groups definition.
::
::
/- listen-hook=link-listen-hook, *metadata-store, *group, *link
/+ mdl=metadata, default-agent, verb, dbug, group-store, grpl=group, resource, store=link-store
/+ default-agent, verb, dbug
::
~% %link-listen-hook-top ..is ~
|%
+$ versioned-state
$% [%0 state-0]
[%1 state-1]
[%2 state-2]
[%3 state-3]
$% [%0 *]
[%1 *]
[%2 *]
[%3 *]
[%4 ~]
==
+$ state-3 state-1
+$ state-2 state-1
+$ state-1
$: listening=(set app-path)
state-0
==
+$ state-0
$: retry-timers=(map target @dr)
:: reasoning: the resources we're subscribed to,
:: and the groups that cause that.
::
:: we don't strictly need to track this in state, but doing so heavily
:: simplifies logic and reduces the amount of big scries we do.
:: this also gives us the option to check & restore subscriptions,
:: should we ever need that.
::
reasoning=(jug [ship app-path] group-path)
==
::
+$ what-target ?(%local-pages %annotations)
+$ target
$: what=what-target
who=ship
where=path
==
++ wire-to-target
|= =wire
^- target
?> ?=([what-target @ ^] wire)
[i.wire (slav %p i.t.wire) t.t.wire]
++ target-to-wire
|= target
^- wire
[what (scot %p who) where]
::
+$ card card:agent:gall
--
::
=| [%3 state-3]
=| [%4 ~]
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init
^- (quip card _this)
:_ this
~[watch-metadata:do watch-groups:do]
::
++ on-save !>(state)
++ on-load
|= =vase
^- (quip card _this)
=/ old=versioned-state
!<(versioned-state vase)
=| cards=(list card)
|-
=* upgrade-loop $
?- -.old
%3 [cards this(state old)]
::
%2
:_ this(state [%3 +.old])
%+ welp cards
:~ [%pass /groups %agent [our.bowl %group-store] %leave ~]
watch-groups:do
==
::
%1
:: the upgrade from 0 left out local-only collections.
:: here, we pull those back in.
::
=. listening.old
(~(run in ~(key by reasoning.old)) tail)
=/ resources=(list [=group-path =app-path])
%~ tap in
%. %link
%~ get ju
.^ (jug app-name [group-path app-path])
%gy
(scot %p our.bowl)
%metadata-store
(scot %da now.bowl)
/app-indices
==
|-
?~ resources
upgrade-loop(old [%2 +.old])
=, i.resources
=/ members=(set ship)
(members-from-path:grp:do group-path)
:: if we're the only group member, this got incorrectly ignored
:: during 0's upgrade logic. watch it now.
::
?. &(=(1 ~(wyt in members)) (~(has in members) our.bowl))
$(resources t.resources)
=^ more-cards state
(handle-listen-action:do %watch app-path)
$(resources t.resources, cards (weld more-cards cards))
::
%0
=/ listening=(set app-path)
(~(run in ~(key by reasoning.old)) tail)
$(old [%1 listening +.old])
==
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
=^ cards state
?+ wire ~|([dap.bowl %weird-agent-wire wire] !!)
[%metadata ~]
(take-metadata-sign:do sign)
::
[%groups ~]
(take-groups-sign:do sign)
::
[%links ?(%local-pages %annotations) @ ^]
(take-link-sign:do (wire-to-target t.wire) sign)
::
[%forward ^]
(take-forward-sign:do t.wire sign)
::
[%prod *]
?> ?=(%poke-ack -.sign)
?~ p.sign [~ state]
%- (slog leaf+"prod failed" u.p.sign)
[~ state]
==
[cards this]
::
++ on-poke
|= [=mark =vase]
?+ mark (on-poke:def mark vase)
%link-listen-poke
=/ =path !<(path vase)
:_ this
%+ weld
(take-retry:do %local-pages src.bowl path)
(take-retry:do %annotations src.bowl path)
::
%link-listen-action
?> (team:title [our src]:bowl)
=^ cards state
~| p.vase
(handle-listen-action:do !<(action:listen-hook vase))
[cards this]
==
::
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?+ sign-arvo (on-arvo:def wire sign-arvo)
[%g %done *]
?~ error.sign-arvo [~ this]
=/ =tank leaf+"{(trip dap.bowl)}'s message went wrong!"
%- (slog tank tang.u.error.sign-arvo)
[~ this]
::
[%b %wake *]
?> ?=([%retry @ @ ^] wire)
?^ error.sign-arvo
=/ =tank leaf+"wake on {(spud wire)} went wrong!"
%- (slog tank u.error.sign-arvo)
[~ this]
:_ this
(take-retry:do (wire-to-target t.wire))
==
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path ~
[%x %listening ~] ``noun+!>(listening)
[%x %listening ^] ``noun+!>((~(has in listening) t.t.path))
==
::
++ on-watch
|= =path
^- (quip card _this)
?. ?=([%listening ~] path) (on-watch:def path)
?> (team:title [our src]:bowl)
:_ this
[%give %fact ~ %link-listen-update !>([%listening listening])]~
::
++ on-leave on-leave:def
++ on-fail on-fail:def
--
::
::
|_ =bowl:gall
+* md ~(. mdl bowl)
++ grp ~(. grpl bowl)
+* this .
def ~(. (default-agent this %|) bowl)
::
:: user actions & updates
::
++ handle-listen-action
|= =action:listen-hook
^- (quip card _state)
::NOTE no-opping where appropriate happens further down the call stack.
:: we *could* no-op here, as %watch when we're already listening should
:: result in no-ops all the way down, but walking through everything
:: makes this a nice "resurrect if broken unexpectedly" option.
::
=* app-path path.action
=^ cards listening
^- (quip card _listening)
=/ had=? (~(has in listening) app-path)
?- -.action
%watch
:_ (~(put in listening) app-path)
?:(had ~ [(send-update action)]~)
::
%leave
:_ (~(del in listening) app-path)
?.(had ~ [(send-update action)]~)
==
=/ groups=(list group-path)
(groups-from-resource:md %link app-path)
|-
?~ groups [cards state]
=^ more-cards state
?- -.action
%watch (listen-to-group app-path i.groups)
%leave (leave-from-group app-path i.groups)
==
$(cards (weld cards more-cards), groups t.groups)
::
++ send-update
|= =update:listen-hook
++ on-init [~ this]
++ on-save !>(state)
++ on-load
|= =vase
^- (quip card _this)
:_ this
:- [%pass /groups %agent [our.bowl %group-store] %leave ~]
%+ turn ~(tap in ~(key by wex.bowl))
|= [=wire =ship =term]
^- card
[%give %fact ~[/listening] %link-listen-update !>(update)]
[%pass wire %agent [ship term] %leave ~]
::
:: metadata subscription
::
++ watch-metadata
^- card
[%pass /metadata %agent [our.bowl %metadata-store] %watch /app-name/link]
::
++ take-metadata-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /metadata] !!)
%kick [[watch-metadata]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to metadata store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?. ?=(%metadata-update mark)
~| [dap.bowl %unexpected-mark mark]
!!
%- handle-metadata-update
!<(metadata-update vase)
==
::
++ handle-metadata-update
|= upd=metadata-update
^- (quip card _state)
?+ -.upd [~ state]
%add
?> =(%link app-name.resource.upd)
:: auto-listen to collections in unmanaged groups only
::
=/ rid=resource
(de-path:resource group-path.upd)
=/ =group
(need (scry-group:grp rid))
?. hidden.group
[~ state]
=, resource.upd
=^ update listening
^- (quip card _listening)
?: (~(has in listening) app-path)
[~ listening]
:- [(send-update %watch app-path)]~
(~(put in listening) app-path)
=^ cards state
(listen-to-group app-path group-path.upd)
[(weld update cards) state]
::
%remove
?> =(%link app-name.resource.upd)
=? listening
?=(~ (groups-from-resource:md %link app-path.resource.upd))
(~(del in listening) app-path.resource.upd)
(leave-from-group app-path.resource.upd group-path.upd)
==
::
:: groups subscriptions
::
++ watch-groups
^- card
[%pass /groups %agent [our.bowl %group-store] %watch /groups]
::
++ take-groups-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /groups] !!)
%kick [[watch-groups]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to groups. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark ~|([dap.bowl %unexpected-mark mark] !!)
%group-initial [~ state] ::NOTE initial handled using metadata
%group-update (handle-group-update !<(update:group-store vase))
==
==
::
++ handle-group-update
|= upd=update:group-store
^- (quip card _state)
?. ?=(?(%add-members %initial-group %remove-members) -.upd)
[~ state]
=/ =path
(en-path:resource resource.upd)
=/ socs=(list app-path)
(app-paths-from-group:md %link path)
=/ whos=(list ship)
?- -.upd
%add-members ~(tap in ships.upd)
%remove-members ~(tap in ships.upd)
%initial-group ~(tap in members.group.upd)
==
=| cards=(list card)
|-
=* loop-socs $
?~ socs [cards state]
?. (~(has in listening) i.socs)
loop-socs(socs t.socs)
|-
=* loop-whos $
?~ whos loop-socs(socs t.socs)
=^ caz state
?. ?=(%remove-members -.upd)
(listen-to-peer i.socs path i.whos)
?: =(our.bowl i.whos)
(handle-listen-action %leave i.socs)
(leave-from-peer i.socs path i.whos)
loop-whos(whos t.whos, cards (weld cards caz))
::
:: link subscriptions
::
++ listen-to-group
|= [=app-path =group-path]
^- (quip card _state)
=/ peers=(list ship)
~| group-path
%~ tap in
(members-from-path:grp group-path)
=| cards=(list card)
|-
?~ peers [cards state]
=^ caz state
(listen-to-peer app-path group-path i.peers)
$(peers t.peers, cards (weld cards caz))
::
++ leave-from-group
|= [=app-path =group-path]
^- (quip card _state)
=/ peers=(list ship)
%~ tap in
(members-from-path:grp group-path)
=| cards=(list card)
|-
?~ peers [cards state]
=^ caz state
(leave-from-peer app-path group-path i.peers)
$(peers t.peers, cards (weld cards caz))
::
++ listen-to-peer
|= [=app-path =group-path who=ship]
^- (quip card _state)
?: =(our.bowl who)
[~ state]
:_ =- state(reasoning -)
(~(put ju reasoning) [who app-path] group-path)
:- (prod-other-listener who app-path)
?^ (~(get ju reasoning) [who app-path])
~
(start-link-subscriptions who app-path)
::
++ leave-from-peer
|= [=app-path =group-path who=ship]
^- (quip card _state)
?: =(our.bowl who)
[~ state]
=. reasoning (~(del ju reasoning) [who app-path] group-path)
::NOTE leaving is always safe, so we just do it unconditionally
(end-link-subscriptions who app-path)
::
++ start-link-subscriptions
|= [=ship =app-path]
^- (list card)
:~ (start-link-subscription %local-pages ship app-path)
(start-link-subscription %annotations ship app-path)
==
::
++ start-link-subscription
|= =target
^- card
:* %pass
[%links (target-to-wire target)]
%agent
[who.target %link-proxy-hook]
%watch
?- what.target
%local-pages [what where]:target
%annotations [what %$ where]:target
==
==
::
++ end-link-subscriptions
|= [who=ship where=path]
^- (quip card _state)
=. retry-timers (~(del by retry-timers) [%local-pages who where])
=. retry-timers (~(del by retry-timers) [%annotations who where])
:_ state
|^ ~[(end %local-pages) (end %annotations)]
::
++ end
|= what=what-target
:* %pass
[%links (target-to-wire what who where)]
%agent
[who %link-proxy-hook]
%leave
~
==
--
::
++ prod-other-listener
|= [who=ship where=path]
^- card
:* %pass
[%prod (scot %p who) where]
%agent
[who %link-listen-hook]
%poke
%link-listen-poke
!>(where)
==
::
++ take-link-sign
|= [=target =sign:agent:gall]
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /links target] !!)
%kick [[(start-link-subscription target)]~ state]
::
%watch-ack
?~ p.sign
=. retry-timers (~(del by retry-timers) target)
[~ state]
:: our subscription request got rejected,
:: most likely because our group definition is out of sync with theirs.
:: set timer for retry.
::
(start-retry target)
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark ~|([dap.bowl %unexpected-mark mark] !!)
%link-initial
%- handle-link-initial
[who.target where.target !<(initial:store vase)]
::
%link-update
%- handle-link-update
[who.target where.target !<(update:store vase)]
==
==
::
++ start-retry
|= =target
^- (quip card _state)
=/ timer=@dr
%+ min ~h1
%+ mul 2
(~(gut by retry-timers) target ~s15)
=. retry-timers
(~(put by retry-timers) target timer)
:_ state
:_ ~
:* %pass
[%retry (target-to-wire target)]
[%arvo %b %wait (add now.bowl timer)]
==
::
++ take-retry
|= =target
^- (list card)
:: relevant: whether :who is still associated with resource :where
::
=; relevant=?
?. relevant ~
[(start-link-subscription target)]~
?. (~(has in listening) where.target)
|
?: %- ~(has by wex.bowl)
[[%links (target-to-wire target)] who.target %link-proxy-hook]
|
%+ lien (groups-from-resource:md %link where.target)
|= =group-path
^- ?
%. who.target
~(has in (members-from-path:grp group-path))
::
++ do-link-action
|= [=wire =action:store]
^- card
:* %pass
wire
%agent
[our.bowl %link-store]
%poke
%link-action
!>(action)
==
::
++ handle-link-initial
|= [who=ship where=path =initial:store]
^- (quip card _state)
?> =(src.bowl who)
?+ -.initial ~|([dap.bowl %unexpected-initial -.initial] !!)
%local-pages
=/ =pages (~(got by pages.initial) where)
(handle-link-update who where [%local-pages where pages])
::
%annotations
=/ urls=(list [=url =notes])
~(tap by (~(got by notes.initial) where))
=| cards=(list card)
|- ^- (quip card _state)
?~ urls [cards state]
=^ caz state
^- (quip card _state)
=, i.urls
(handle-link-update who where [%annotations where url notes])
$(urls t.urls, cards (weld cards caz))
==
::
++ handle-link-update
|= [who=ship where=path =update:store]
^- (quip card _state)
?> =(src.bowl who)
:_ state
?+ -.update ~|([dap.bowl %unexpected-update -.update] !!)
%local-pages
%+ turn pages.update
|= =page
%+ do-link-action
[%forward %local-page (scot %p who) where]
[%hear where who page]
::
%annotations
%+ turn notes.update
|= =note
^- card
%+ do-link-action
`wire`[%forward %annotation (scot %p who) where]
`action:store`[%read where url.update `comment`[who note]]
==
::
++ take-forward-sign
|= [=wire =sign:agent:gall]
^- (quip card _state)
~| [%unexpected-sign on=[%forward wire] -.sign]
?> ?=(%poke-ack -.sign)
?~ p.sign [~ state]
=/ =tank
:- %leaf
;: weld
(trip dap.bowl)
" failed to save submission from "
(spud wire)
==
%- (slog tank u.p.sign)
[~ state]
::
++ scry-for
|* [=mold =app-name =path]
.^ mold
%gx
(scot %p our.bowl)
app-name
(scot %da now.bowl)
(snoc `^path`path %noun)
==
++ on-agent on-agent:def
++ on-poke on-poke:def
++ on-arvo on-arvo:def
++ on-peek on-peek:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--

View File

@ -1,339 +1,46 @@
:: link-proxy-hook [landscape]:
:: link-proxy-hook: no longer in use
::
:: make local pages available to foreign ships
::
:: this is a "proxy" style hook, relaying foreign subscriptions into local
:: stores if permission conditions are met.
:: the patterns herein should one day be generalized into a proxy-hook lib.
::
:: this uses metadata-store to discover resources and their associated
:: groups. it sets the permission condition to be that a ship must be in a
:: group associated with the resource it's subscribing to.
:: we check this on-watch, but also subscribe to metadata & groups so that
:: we can kick subscriptions if needed (eg ship removed from group).
::
:: we deduplicate incoming subscriptions on the same path, ensuring we have
:: exactly one local subscription per unique incoming subscription path.
:: this comes at the cost of assuming that the store's initial response is
:: whatever's returned by the scry at that path, but perhaps that should
:: become part of the stores standard anyway.
::
:: when adding support for new paths, the only things you'll likely want
:: to touch are +permitted, +initial-response, & +kick-proxies.
::
/- *link, *metadata-store, *group
/+ metadata, default-agent, verb, dbug, group-store, grpl=group,
resource, store=link-store
/+ default-agent, verb, dbug
~% %link-proxy-hook-top ..is ~
|%
+$ state-0
$: %0
::TODO we use this to detect "first sub started" and "last sub left",
:: but can't we use [wex sup]:bowl for that?
active=(map path (set ship))
==
+$ state-1
$: %1
active=(map path (set ship))
==
::
+$ versioned-state
$% state-0
state-1
$% [%0 *]
[%1 *]
[%2 ~]
==
::
+$ card card:agent:gall
--
::
=| state-1
=| [%2 ~]
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %&) bowl)
::
++ on-init
^- (quip card _this)
:_ this
~[watch-groups:do watch-metadata:do]
::
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ old
!<(versioned-state old-vase)
?- -.old
%1 [~ this(state old)]
::
%0
:_ this(state [%1 +.old])
:~ [%pass /groups %agent [our.bowl %group-store] %leave ~]
watch-groups:do
==
==
::
++ on-watch
|= =path
^- (quip card _this)
:: the local ship should just use link-store directly
::TODO do we want to allow this anyway, to avoid client-side target checks?
::
?< (team:title [our src]:bowl)
?> (permitted:do src.bowl path)
=^ cards state
(start-proxy:do src.bowl path)
[cards this]
::
++ on-leave
|= =path
^- (quip card _this)
=^ cards state
(stop-proxy:do src.bowl path)
[cards this]
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
?: ?=([%groups ~] wire)
=^ cards state
(take-groups-sign:do sign)
[cards this]
?: ?=([%proxy ^] wire)
=^ cards state
(handle-proxy-sign t.wire sign)
[cards this]
~| [dap.bowl %weird-wire wire]
!!
::
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--
::
|_ =bowl:gall
+* md ~(. metadata bowl)
grp ~(. grpl bowl)
+* this .
def ~(. (default-agent this %&) bowl)
::
:: permissions
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ paths
%+ turn ~(val by sup.bowl)
|=([=ship =path] path)
:_ this
:- [%pass /groups %agent [our.bowl %group-store] %leave ~]
?~ paths ~
[%give %kick paths ~]~
::
++ permitted
|= [who=ship =path]
^- ?
:: we only expose /local-pages and /annotations,
:: to ships in the groups associated with the resource.
:: (no url-specific annotations subscriptions, either.)
::
=/ target=(unit ^path)
?: ?=([%local-pages ^] path)
`t.path
?: ?=([%annotations ~ ^] path)
`t.t.path
~
?~ target |
%+ lien (groups-from-resource:md %link u.target)
|= =group-path
^- ?
(~(has in (members-from-path:grp group-path)) who)
::
++ kick-revoked-permissions
|= [=path who=(list ship)]
^- (list card)
%+ murn who
|= =ship
^- (unit card)
:: no need to remove to ourselves
::
?: =(our.bowl ship) ~
?: (permitted ship path) ~
`(kick-proxies ship path)
::
:: metadata subscription
::
++ watch-metadata
^- card
[%pass /metadata %agent [our.bowl %metadata-store] %watch /app-name/link]
::
++ take-metadata-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /metadata] !!)
%kick [[watch-metadata]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to metadata store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?. ?=(%metadata-update mark)
~| [dap.bowl %unexpected-mark mark]
!!
%- handle-metadata-update
!<(metadata-update vase)
==
::
++ handle-metadata-update
|= upd=metadata-update
^- (quip card _state)
:_ state
?. ?=(%remove -.upd) ~
?> =(%link app-name.resource.upd)
:: if a group is no longer associated with a resource,
:: we need to re-check permissions for everyone in that group.
::
%+ kick-revoked-permissions
app-path.resource.upd
%~ tap in
(members-from-path:grp group-path.upd)
::
:: groups subscription
::TODO largely copied from link-listen-hook. maybe make a store-listener lib?
::
++ watch-groups
^- card
[%pass /groups %agent [our.bowl %group-store] %watch /groups]
::
++ take-groups-sign
|= =sign:agent:gall
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack /groups] !!)
%kick [[watch-groups]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to group store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark ~|([dap.bowl %unexpected-mark mark] !!)
%group-initial [~ state]
%group-update (handle-group-update !<(update:group-store vase))
==
==
::
++ handle-group-update
|= upd=update:group-store
^- (quip card _state)
:_ state
?. ?=(%remove-members -.upd) ~
:: if someone was removed from a group, find all link resources associated
:: with that group, then kick their subscriptions if they're no longer
::
%- zing
%+ turn (app-paths-from-group:md %link (en-path:resource resource.upd))
|= =app-path
^- (list card)
%+ kick-revoked-permissions
app-path
~(tap in ships.upd)
::
:: proxy subscriptions
::
++ kick-proxies
|= [who=ship =path]
^- card
=- [%give %kick - `who]
:~ [%local-pages path]
[%annotations %$ path]
==
::
++ handle-proxy-sign
|= [=wire =sign:agent:gall]
^- (quip card _state)
?- -.sign
%poke-ack ~|([dap.bowl %unexpected-poke-ack wire] !!)
%fact [[%give %fact ~[wire] cage.sign]~ state]
%kick [[(proxy-pass-link-store wire %watch wire)]~ state]
::
%watch-ack
?~ p.sign [~ state]
=/ =tank
:- %leaf
"{(trip dap.bowl)} failed subscribe to link-store. very wrong!"
%- (slog tank u.p.sign)
[~ state]
==
::
++ proxy-pass-link-store
|= [=path =task:agent:gall]
^- card
:* %pass
[%proxy path]
%agent
[our.bowl %link-store]
task
==
::
++ initial-response
|= =path
^- card
=; =initial:store
[%give %fact ~ %link-initial !>(initial)]
?+ path !!
[%local-pages ^]
[%local-pages (scry-for (map ^path pages) %link-store path)]
::
[%annotations %$ ^]
[%annotations (scry-for (per-path-url notes) %link-store path)]
==
::
++ start-proxy
|= [who=ship =path]
^- (quip card _state)
:_ state(active (~(put ju active) path who))
:_ ~
:: if we already have a local subscription open,
::
?. =(~ (~(get ju active) path))
:: gather the initial response ourselves, and send that.
::
(initial-response path)
:: else, open a local subscription,
:: sending outward its initial response when we hear it.
::
(proxy-pass-link-store path %watch path)
::
++ stop-proxy
|= [who=ship =path]
^- (quip card _state)
=. active (~(del ju active) path who)
:_ state
:: if there are still subscriptions remaining, do nothing.
::
?. =(~ (~(get ju active) path)) ~
:: else, close the local subscription.
::
[(proxy-pass-link-store path %leave ~)]~
::
:: helpers
::
++ scry-for
|* [=mold =app-name =path]
.^ mold
%gx
(scot %p our.bowl)
app-name
(scot %da now.bowl)
(snoc `^path`path %noun)
==
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-agent on-agent:def
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

View File

@ -52,10 +52,12 @@
:: ?
:: /seen/wood-url/some-path have we seen this here
::
/- *link
/+ store=link-store, default-agent, verb, dbug
/- *link, gra=graph-store, *resource
/+ store=link-store, graph-store, default-agent, verb, dbug
::
|%
+$ state-any $%(state-1 state-0)
+$ state-1 [%1 ~]
+$ state-0
$: %0
by-group=(map path links)
@ -78,414 +80,107 @@
+$ card card:agent:gall
--
::
=| state-0
=| state-1
=* state -
::
%- agent:dbug
%+ verb |
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
[~ this(state !<(state-0 old))]
::
++ on-poke
|= [=mark =vase]
^- (quip card _this)
?> (team:title [our src]:bowl) ::TODO /lib/store
=^ cards state
?+ mark (on-poke:def mark vase)
::TODO move json conversion into mark once mark performance improves
%json (do-action:do (action:dejs:store !<(json vase)))
%link-action (do-action:do !<(action:store vase))
==
[cards this]
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path (on-peek:def path)
[%y ?(%local-pages %submissions) ~]
``noun+!>(~(key by by-group))
::
[%x %local-pages *]
``noun+!>((get-local-pages:do t.t.path))
::
[%x %submissions *]
``noun+!>((get-submissions:do t.t.path))
::
[%y ?(%annotations %discussions) *]
=/ [spath=^path surl=url]
(break-discussion-path:store t.t.path)
=- ``noun+!>(-)
::
?: =(~ surl)
:: no url, provide urls that have comments
::
^- (set url)
?~ spath
:: no path, find urls accross all paths
::
%- ~(rep by discussions)
|= [[* discussions=(map url discussion)] urls=(set url)]
%- ~(uni in urls)
~(key by discussions)
:: specified path, find urls for that specific path
::
%~ key by
(~(gut by discussions) spath *(map url *))
:: specified url and path, nothing to list here
::
?^ spath !!
:: no path, find paths with comments for this url
::
^- (set ^path)
%- ~(rep by discussions)
|= [[=^path urls=(map url discussion)] paths=(set ^path)]
?. (~(has by urls) surl) paths
(~(put in paths) path)
::
[%x %annotations *]
``noun+!>((get-annotations:do t.t.path))
::
[%x %discussions *]
``noun+!>((get-discussions:do t.t.path))
::
[%x %seen @ ^]
``noun+!>((is-seen:do t.t.path))
::
[%x %unseen ~]
``noun+!>(get-all-unseen:do)
::
[%x %unseen ^]
``noun+!>((get-unseen:do t.t.path))
==
::
++ on-watch
|= =path
^- (quip card _this)
?> (team:title [our src]:bowl) ::TODO /lib/store
:_ this
|^ ?+ path (on-watch:def path)
[%local-pages *]
%+ give %link-initial
^- initial:store
[%local-pages (get-local-pages:do t.path)]
::
[%submissions *]
%+ give %link-initial
^- initial:store
[%submissions (get-submissions:do t.path)]
::
[%annotations *]
%+ give %link-initial
^- initial:store
[%annotations (get-annotations:do t.path)]
::
[%discussions *]
%+ give %link-initial
^- initial:store
[%discussions (get-discussions:do t.path)]
::
[%seen ~]
~
==
::
++ give
|* [=mark =noun]
^- (list card)
[%give %fact ~ mark !>(noun)]~
::
++ give-single
|* [=mark =noun]
^- card
[%give %fact ~ mark !>(noun)]
--
::
++ on-leave on-leave:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--
::
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
:: writing
::
++ do-action
|= =action:store
^- (quip card _state)
?- -.action
%save (save-page +.action)
%note (note-note +.action)
%seen (seen-submission +.action)
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= old=vase
^- (quip card _this)
=/ s !<(state-any old)
?: ?=(%1 -.s)
[~ this(state s)]
::
%hear (hear-submission +.action)
%read (read-comment +.action)
==
:: +save-page: save a page ourselves
::
++ save-page
|= [=path title=@t =url]
^- (quip card _state)
?< |(=(~ path) =(~ title) =(~ url))
:: add page to group ours
::
=/ =links (~(gut by by-group) path *links)
=/ =page [title url now.bowl]
=. ours.links [page ours.links]
=. by-group (~(put by by-group) path links)
:: do generic submission logic
::
=^ submission-cards state
(hear-submission path [our.bowl page])
:: mark page as seen (because we submitted it ourselves)
::
=^ seen-cards state
(seen-submission path `url)
:: send updates to subscribers
::
:_ state
:_ (weld submission-cards seen-cards)
:+ %give %fact
:+ :~ /local-pages
[%local-pages path]
==
%link-update
!>([%local-pages path [page]~])
:: +note-note: save a note for a url
::
++ note-note
|= [=path =url udon=@t]
^- (quip card _state)
?< |(=(~ path) =(~ url) =(~ udon))
:: add note to discussion ours
::
=/ urls (~(gut by discussions) path *(map ^url discussion))
=/ =discussion (~(gut by urls) url *discussion)
=/ =note [now.bowl udon]
=. ours.discussion [note ours.discussion]
=. urls (~(put by urls) url discussion)
=. discussions (~(put by discussions) path urls)
:: do generic comment logic
::
=^ cards state
(read-comment path url [our.bowl note])
:: send updates to subscribers
::
:_ state
:_ this(state *state-1)
=/ orm orm:graph-store
|^ ^- (list card)
%- zing
%+ turn ~(tap by by-group.s)
|= [=path =links]
^- (list card)
:_ cards
:+ %give %fact
:+ :~ /annotations
[%annotations %$ path]
[%annotations (build-discussion-path:store url)]
[%annotations (build-discussion-path:store path url)]
?. ?=([@ @ *] path)
(on-bad-path path links)
=/ =resource [(slav %p i.path) i.t.path]
:_ [(archive-graph resource)]~
%+ add-graph resource
^- graph:gra
%+ gas:orm ~
=/ comments (~(gut by discussions.s) path *(map url discussion))
%+ turn submissions.links
|= sub=submission
^- [atom node:gra]
:- time.sub
=/ contents ~[text+title.sub url+url.sub]
=/ parent-hash `@ux`(sham ~ ship.sub time.sub contents)
:- ^- post:gra
:* author=ship.sub
index=~[time.sub]
time-sent=time.sub
contents
hash=`parent-hash
signatures=~
==
%link-update
!>([%annotations path url [note]~])
:: +seen-submission: mark url as seen/read
::
:: if no url specified, all under path are marked as read
::
++ seen-submission
|= [=path murl=(unit url)]
^- (quip card _state)
=/ =links (~(gut by by-group) path *links)
:: new: urls we want to, but haven't yet, marked as seen
^- internal-graph:gra
=/ dis (~(get by comments) url.sub)
?~ dis
[%empty ~]
:- %graph
^- graph:gra
%+ gas:orm ~
%+ turn comments.u.dis
|= [=ship =time udon=@t]
^- [atom node:gra]
:- time
:_ `internal-graph:gra`[%empty ~]
=/ contents ~[text+udon]
:* author=ship
index=~[time.sub time]
time-sent=time
contents
hash=``@ux`(sham `parent-hash ship time contents)
signatures=~
==
::
=/ new=(set url)
%. seen.links
%~ dif in
^- (set url)
?^ murl (sy ~[u.murl])
%- ~(gas in *(set url))
%+ turn submissions.links
|=(submission url)
?: =(~ new) [~ state]
=. seen.links (~(uni in seen.links) new)
:_ state(by-group (~(put by by-group) path links))
[%give %fact ~[/seen] %link-update !>([%observation path new])]~
:: +hear-submission: record page someone else saved
::
++ hear-submission
|= [=path =submission]
^- (quip card _state)
?< =(~ path)
:: add link to group submissions
++ on-bad-path
|= [=path =links]
^- (list card)
~| discarding-malformed-links+[path links]
~
::
=/ =links (~(gut by by-group) path *links)
=^ added submissions.links
?: ?=(^ (find ~[submission] submissions.links))
[| submissions.links]
:- &
(submissions:merge:store submissions.links ~[submission])
=. by-group (~(put by by-group) path links)
:: add submission to global sites
++ add-graph
|= [=resource =graph:gra]
^- card
%- poke-graph-store
[%0 now.bowl %add-graph resource graph `%graph-validator-link]
::
=/ =site (site-from-url:store url.submission)
=. by-site (~(add ja by-site) site [path submission])
:: send updates to subscribers
++ archive-graph
|= =resource
^- card
%- poke-graph-store
[%0 now.bowl %archive-graph resource]
::
:_ state
?. added ~
:_ ~
:+ %give %fact
:+ :~ /submissions
[%submissions path]
==
%link-update
!>([%submissions path [submission]~])
:: +read-comment: record a comment someone else made
::
++ read-comment
|= [=path =url =comment]
^- (quip card _state)
:: add comment to url's discussion
::
=/ urls (~(gut by discussions) path *(map ^url discussion))
=/ =discussion (~(gut by urls) url *discussion)
=^ added comments.discussion
?: ?=(^ (find ~[comment] comments.discussion))
[| comments.discussion]
:- &
(comments:merge:store comments.discussion ~[comment])
=. urls (~(put by urls) url discussion)
=. discussions (~(put by discussions) path urls)
:: send updates to subscribers
::
:_ state
?. added ~
:_ ~
:+ %give %fact
:+ :~ /discussions
[%discussions '' path]
[%discussions (build-discussion-path:store url)]
[%discussions (build-discussion-path:store path url)]
==
%link-update
!>([%discussions path url [comment]~])
::
:: reading
::
++ get-local-pages
|= =path
^- (map ^path pages)
?~ path
:: all paths
::
%- ~(run by by-group)
|=(links ours)
:: specific path
::
%+ ~(put by *(map ^path pages)) path
ours:(~(gut by by-group) path *links)
::
++ get-submissions
|= =path
^- (map ^path submissions)
?~ path
:: all paths
::
%- ~(run by by-group)
|=(links submissions)
:: specific path
::
%+ ~(put by *(map ^path submissions)) path
submissions:(~(gut by by-group) path *links)
::
++ get-all-unseen
^- (jug path url)
%- ~(rut by by-group)
|= [=path *]
(get-unseen path)
::
++ get-unseen
|= =path
^- (set url)
=/ =links
(~(gut by by-group) path *links)
%- ~(gas in *(set url))
%+ murn submissions.links
|= submission
?: (~(has in seen.links) url) ~
(some url)
::
++ is-seen
|= =path
^- ?
=/ [=^path =url]
(break-discussion-path:store path)
%. url
%~ has in
seen:(~(gut by by-group) path *links)
::
::
++ get-annotations
|= =path
^- (per-path-url notes)
=/ args=[=^path =url]
(break-discussion-path:store path)
|^ ?~ path
:: all paths
::
(~(run by discussions) get-ours)
:: specific path
::
%+ ~(put by *(per-path-url notes)) path.args
%- get-ours
%+ ~(gut by discussions) path.args
*(map url discussion)
::
++ get-ours
|= m=(map url discussion)
^- (map url notes)
?: =(~ url.args)
:: all urls
::
%- ~(run by m)
|=(discussion ours)
:: specific url
::
%+ ~(put by *(map url notes)) url.args
ours:(~(gut by m) url.args *discussion)
++ poke-graph-store
|= =update:gra
^- card
:* %pass /migrate-link %agent [our.bowl %graph-store]
%poke %graph-update !>(update)
==
--
::
++ get-discussions
|= =path
^- (per-path-url comments)
=/ args=[=^path =url]
(break-discussion-path:store path)
|^ ?~ path
:: all paths
::
(~(run by discussions) get-comments)
:: specific path
::
%+ ~(put by *(per-path-url comments)) path.args
%- get-comments
%+ ~(gut by discussions) path.args
*(map url discussion)
::
++ get-comments
|= m=(map url discussion)
^- (map url comments)
?: =(~ url.args)
:: all urls
::
%- ~(run by m)
|=(discussion comments)
:: specific url
::
%+ ~(put by *(map url comments)) url.args
comments:(~(gut by m) url.args *discussion)
--
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
--

View File

@ -1,628 +1,39 @@
:: link-view [landscape]:
::
::frontend endpoints
::
:: endpoints, mapping onto link-store's paths. p is for page as in pagination.
:: only the /0/submissions endpoint provides updates.
:: as with link-store, urls are expected to use +wood encoding.
::
:: /json/0/submissions initial + updates for all
:: /json/[p]/submissions/[collection] page for one collection
:: /json/[p]/discussions/[wood-url]/[collection] page for url in collection
:: /json/[n]/submission/[wood-url]/[collection] nth matching submission
:: /json/seen mark-as-read updates
::
/- *link, view=link-view
/- *invite-store, group-store
/- listen-hook=link-listen-hook
/- group-hook, permission-hook, permission-group-hook
/- metadata-hook, contact-view
/- pull-hook, *group
/+ store=link-store, metadata, *server, default-agent, verb, dbug, grpl=group
/+ group-store, resource
:: link-view: no longer in use
/+ default-agent, verb, dbug
~% %link-view-top ..is ~
::
::
|%
+$ versioned-state
$% state-0
state-1
==
+$ state-0
$: %0
~
==
::
+$ state-1
$: %1
~
$% [%0 ~]
[%1 ~]
[%2 ~]
==
::
+$ card card:agent:gall
--
::
=| state-1
=| [%2 ~]
=* state -
::
%+ verb |
%- agent:dbug
^- agent:gall
=<
|_ =bowl:gall
+* this .
do ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init
^- (quip card _this)
:_ this
:~ [%pass /submissions %agent [our.bowl %link-store] %watch /submissions]
[%pass /discussions %agent [our.bowl %link-store] %watch /discussions]
[%pass /seen %agent [our.bowl %link-store] %watch /seen]
::
=+ [%invite-action !>([%create /link])]
[%pass /invitatory/create %agent [our.bowl %invite-store] %poke -]
::
=+ /invitatory/link
[%pass - %agent [our.bowl %invite-store] %watch -]
:* %pass /srv %agent [our.bowl %file-server]
%poke %file-server-action
!>([%serve-dir /'~link' /app/landscape %.n %.y])
==
==
::
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
=/ old !<(versioned-state old-vase)
?- -.old
%1 [~ this]
%0
:_ this(state [%1 ~])
:- [%pass /connect %arvo %e %disconnect [~ /'~link']]
:~ :* %pass /srv %agent [our.bowl %file-server]
%poke %file-server-action
!>([%serve-dir /'~link' /app/landscape %.n %.y])
== ==
==
::
++ on-poke
|= [=mark =vase]
^- (quip card _this)
?> (team:title our.bowl src.bowl)
:_ this
?+ mark (on-poke:def mark vase)
%link-action
[(handle-action:do !<(action:store vase)) ~]
::
%link-view-action
(handle-view-action:do !<(action:view vase))
==
::
++ on-watch
|= =path
^- (quip card _this)
?: ?=([%json %seen ~] path)
[~ this]
?: ?=([%tile ~] path)
:_ this
~[give-tile-data:do]
?. ?=([%json @ @ *] path)
(on-watch:def path)
=/ p=@ud (slav %ud i.t.path)
?+ t.t.path (on-watch:def path)
[%submissions ~]
:_ this
(give-initial-submissions:do p ~)
::
[%submissions ^]
:_ this
(give-initial-submissions:do p t.t.t.path)
::
[%submission @ ^]
:_ this
(give-specific-submission:do p (break-discussion-path:store t.t.t.path))
::
[%discussions @ ^]
:_ this
(give-initial-discussions:do p (break-discussion-path:store t.t.t.path))
==
::
++ on-agent
|= [=wire =sign:agent:gall]
^- (quip card _this)
?+ -.sign (on-agent:def wire sign)
%poke-ack
?. ?=([%join-group @ @ @ @ ~] wire)
(on-agent:def wire sign)
?^ p.sign
(on-agent:def wire sign)
=/ rid=resource
(de-path:resource t.t.wire)
=/ host=ship
(slav %p i.t.wire)
:_ this
(joined-group:do host rid)
::
%kick
:_ this
=/ app=term
?: ?=([%invites *] wire)
%invite-store
%link-store
[%pass wire %agent [our.bowl app] %watch wire]~
::
%fact
=* mark p.cage.sign
=* vase q.cage.sign
?+ mark (on-agent:def wire sign)
%invite-update [(handle-invite-update:do !<(invite-update vase)) this]
%link-initial [~ this]
::
%link-update
:_ this
:- (send-update:do !<(update:store vase))
?: =(/discussions wire) ~
~[give-tile-data:do]
==
==
::
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?. ?=([%e %bound *] sign-arvo)
(on-arvo:def wire sign-arvo)
~? !accepted.sign-arvo
[dap.bowl "bind rejected!" binding.sign-arvo]
[~ this]
::
++ on-peek on-peek:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--
::
~% %link-view-logic ..card ~
|_ =bowl:gall
+* md ~(. metadata bowl)
grp ~(. grpl bowl)
+* this .
def ~(. (default-agent this %|) bowl)
::
++ page-size 25
++ get-paginated
|* [page=(unit @ud) list=(list)]
^- [total=@ud pages=@ud page=_list]
=/ l=@ud (lent list)
:+ l
%+ add (div l page-size)
(min 1 (mod l page-size))
?~ page list
%+ swag
[(mul u.page page-size) page-size]
list
++ on-init [~ this]
++ on-save !>(state)
++ on-load
|= old-vase=vase
^- (quip card _this)
:_ this(state [%2 ~])
[%pass /connect %arvo %e %disconnect [~ /'~link']]~
::
++ page-to-json
=, enjs:format
|* $: page-number=@ud
[total-items=@ud total-pages=@ud page=(list)]
item-to-json=$-(* json)
==
^- json
%- pairs
:~ 'totalItems'^(numb total-items)
'totalPages'^(numb total-pages)
'pageNumber'^(numb page-number)
'page'^a+(turn page item-to-json)
==
++ do-poke
|= [app=term =mark =vase]
^- card
[%pass /create/[app]/[mark] %agent [our.bowl app] %poke mark vase]
::
++ joined-group
|= [host=ship rid=resource]
^- (list card)
=/ =path
(en-path:resource rid)
:~
:: sync the group
::
%^ do-poke %group-pull-hook
%pull-hook-action
!> ^- action:pull-hook
[%add host rid]
::
:: sync the metadata
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%add-synced host path]
::
:: sync the collection
::
%^ do-poke %link-listen-hook
%link-listen-action
!> ^- action:listen-hook
[%watch ~[name.rid]]
==
::
++ handle-invite-update
|= upd=invite-update
^- (list card)
?. ?=(%accepted -.upd) ~
?. =(/link path.upd) ~
=/ rid=resource
(de-path:resource path.invite.upd)
:~ :: add self
:* %pass
[%join-group (scot %p ship.invite.upd) path.invite.upd]
%agent [entity.rid %group-push-hook]
%poke %group-update
!> ^- action:group-store
[%add-members rid (sy our.bowl ~)]
== ==
::
++ handle-action
|= =action:store
^- card
[%pass /action %agent [our.bowl %link-store] %poke %link-action !>(action)]
::
++ handle-view-action
|= act=action:view
^- (list card)
?- -.act
%create (handle-create +.act)
%delete (handle-delete +.act)
%invite (handle-invite +.act)
==
::
++ handle-create
|= [=path title=@t description=@t members=create-members:view real-group=?]
^- (list card)
=/ group-path=^path
?- -.members
%group path.members
::
%ships
[%ship (scot %p our.bowl) path]
==
=; group-setup=(list card)
%+ weld group-setup
:~ :: add collection to metadata-store
::
%^ do-poke %metadata-hook
%metadata-action
!> ^- metadata-action:md
:^ %add group-path
[%link path]
%* . *metadata:md
title title
description description
date-created now.bowl
creator our.bowl
==
::
:: expose the metadata
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%add-owned group-path]
::
:: watch the collection ourselves
::
%^ do-poke %link-listen-hook
%link-listen-action
!> ^- action:listen-hook
[%watch path]
==
?: ?=(%group -.members) ~
:: if the group is "real", make contact-view do the heavy lifting
=/ rid=resource
(de-path:resource group-path)
?: real-group
:- %^ do-poke %contact-view
%contact-view-action
!> ^- contact-view-action:contact-view
[%groupify rid title description]
%+ turn ~(tap in ships.members)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-hook
group-path
ship
title
==
:: for "unmanaged" groups, do it ourselves
::
=/ =policy
[%invite ships.members]
:* :: create the new group
::
%^ do-poke %group-store
%group-action
!> ^- action:group-store
[%add-group rid policy %.y]
::
:: send invites
::
%+ turn ~(tap in ships.members)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-hook
group-path
ship
title
==
==
::
++ handle-delete
|= =path
^- (list card)
=/ groups=(list ^path)
(groups-from-resource:md [%link path])
%- zing
%+ turn groups
|= =group=^path
=/ rid=resource
(de-path:resource group-path)
%+ snoc
^- (list card)
:: if it's a real group, we can't/shouldn't unsync it. this leaves us with
:: no way to stop propagation of collection deletion.
::
?. ?=([%'~' ^] group-path) ~
:: if it's an unmanaged group, we just stop syncing the group & metadata,
:: and clean up the group (after un-hooking it, to not push deletion).
::
:~ %^ do-poke %group-hook
%group-hook-action
!> ^- action:group-hook
[%remove rid]
::
%^ do-poke %metadata-hook
%metadata-hook-action
!> ^- metadata-hook-action:metadata-hook
[%remove group-path]
::
%^ do-poke %group-store
%group-action
!> ^- action:group-store
[%remove-group rid ~]
==
:: remove collection from metadata-store
::
%^ do-poke %metadata-store
%metadata-action
!> ^- metadata-action:md
[%remove group-path [%link path]]
::
++ handle-invite
|= [=path ships=(set ship)]
^- (list card)
%- zing
%+ turn (groups-from-resource:md %link path)
|= =group=^path
^- (list card)
=/ rid=resource
(de-path:resource group-path)
=/ =group
(need (scry-group:grp rid))
%- zing
:~
?. ?=(%invite -.policy.group)
~
:~ %^ do-poke %group-store
%group-action
!> ^- action:group-store
[%change-policy rid %invite %add-invites ships]
==
::
%+ turn ~(tap in ships)
|= =ship
^- card
%^ do-poke %invite-hook
%invite-action
!> ^- invite-action
:^ %invite /link
(sham group-path eny.bowl)
:* our.bowl
%group-pull-hook
group-path
ship
(rsh 3 1 (spat path))
==
==
:: +give-tile-data: total unread count as json object
::
::NOTE the full recalc of totals here probably isn't the end of the world.
:: but in case it is, well, here it is.
::
++ give-tile-data
^- card
=; =json
[%give %fact ~[/tile] %json !>(json)]
%+ frond:enjs:format 'unseen'
%- numb:enjs:format
%- %~ rep in
(scry-for (jug path url) /unseen)
|= [[=path unseen=(set url)] total=@ud]
%+ add total
~(wyt in unseen)
::
:: +give-initial-submissions: page of submissions on path
::
:: for the / path, give page for every path
::
:: result is in the shape of: {
:: "/some/path": {
:: totalItems: 1,
:: totalPages: 1,
:: pageNumber: 0,
:: page: [
:: { commentCount: 1, ...restOfTheSubmission }
:: ]
:: },
:: "/maybe/more": { etc }
:: }
::
++ give-initial-submissions
~/ %link-view-initial-submissions
|= [p=@ud =requested=path]
^- (list card)
:_ :: only keep the base case alive (for updates), kick all others
::
?: &(=(0 p) ?=(~ requested-path)) ~
[%give %kick ~ ~]~
=; =json
[%give %fact ~ %json !>(json)]
%+ frond:enjs:format 'link-update'
%+ frond:enjs:format 'initial-submissions'
%- pairs:enjs:format
%+ turn
%~ tap by
%+ scry-for (map path submissions)
[%submissions requested-path]
|= [=path =submissions]
^- [@t json]
:- (spat path)
=; =json
:: add unseen count
::
?> ?=(%o -.json)
:- %o
%+ ~(put by p.json) 'unseenCount'
%- numb:enjs:format
%~ wyt in
%+ scry-for (set url)
[%unseen path]
?: &(=(0 p) ?=(~ requested-path))
:: for a broad-scope initial result, only give total counts
::
=, enjs:format
%- pairs
=+ l=(lent submissions)
:~ 'totalItems'^(numb l)
'totalPages'^(numb (div l page-size))
==
%^ page-to-json p
%+ get-paginated `p
submissions
|= =submission
^- json
=/ =json (submission:enjs:store submission)
?> ?=([%o *] json)
:: add in seen status
::
=. p.json
%+ ~(put by p.json) 'seen'
:- %b
%+ scry-for ?
[%seen (build-discussion-path:store path url.submission)]
:: add in comment count
::
=; comment-count=@ud
:- %o
%+ ~(put by p.json) 'commentCount'
(numb:enjs:format comment-count)
%- lent
~| [path url.submission]
^- comments
=- (~(got by (~(got by -) path)) url.submission)
%+ scry-for (per-path-url comments)
:- %discussions
(build-discussion-path:store path url.submission)
::
++ give-specific-submission
|= [n=@ud =path =url]
:_ [%give %kick ~ ~]~
=; =json
[%give %fact ~ %json !>(json)]
%+ frond:enjs:format 'link-update'
%+ frond:enjs:format 'submission'
^- json
=; sub=(unit submission)
?~ sub ~
(submission:enjs:store u.sub)
=/ =submissions
=- (~(got by -) path)
%+ scry-for (map ^path submissions)
[%submissions path]
|-
?~ submissions ~
=* sub i.submissions
?. =(url.sub url)
$(submissions t.submissions)
?: =(0 n) `sub
$(n (dec n), submissions t.submissions)
::
++ give-initial-discussions
|= [p=@ud =path =url]
^- (list card)
:_ ?: =(0 p) ~
[%give %kick ~ ~]~
=; =json
[%give %fact ~ %json !>(json)]
%+ frond:enjs:format 'link-update'
%+ frond:enjs:format 'initial-discussions'
%^ page-to-json p
%+ get-paginated `p
=- (~(got by (~(got by -) path)) url)
%+ scry-for (per-path-url comments)
[%discussions (build-discussion-path:store path url)]
comment:enjs:store
::
++ send-update
|= =update:store
^- card
?+ -.update ~|([dap.bowl %unexpected-update -.update] !!)
%submissions
%+ give-json
%+ frond:enjs:format 'link-update'
(update:enjs:store update)
:~ /json/0/submissions
(weld /json/0/submissions path.update)
==
::
%discussions
%+ give-json
%+ frond:enjs:format 'link-update'
(update:enjs:store update)
:_ ~
%+ weld /json/0/discussions
(build-discussion-path:store [path url]:update)
::
%observation
%+ give-json
%+ frond:enjs:format 'link-update'
(update:enjs:store update)
~[/json/seen]
==
::
++ give-json
|= [=json paths=(list path)]
^- card
[%give %fact paths %json !>(json)]
::
++ scry-for
|* [=mold =path]
.^ mold
%gx
(scot %p our.bowl)
%link-store
(scot %da now.bowl)
(snoc `^path`path %noun)
==
++ on-poke on-poke:def
++ on-watch on-watch:def
++ on-agent on-agent:def
++ on-arvo on-arvo:def
++ on-peek on-peek:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--

View File

@ -1,5 +1,5 @@
/- spider
/+ libstrand=strand, default-agent, verb
/+ libstrand=strand, default-agent, verb, server
=, strand=strand:libstrand
|%
+$ card card:agent:gall
@ -17,15 +17,25 @@
$: starting=(map yarn [=trying =vase])
running=trie
tid=(map tid yarn)
serving=(map tid [@ta =mark])
==
::
+$ clean-slate-any
$^ clean-slate-ket
$% clean-slate-sig
clean-slate-1
clean-slate
==
::
+$ clean-slate
$: %2
starting=(map yarn [=trying =vase])
running=(list yarn)
tid=(map tid yarn)
serving=(map tid [@ta =mark])
==
::
+$ clean-slate-1
$: %1
starting=(map yarn [=trying =vase])
running=(list yarn)
@ -133,7 +143,10 @@
sc ~(. spider-core bowl)
def ~(. (default-agent this %|) bowl)
::
++ on-init on-init:def
++ on-init
^- (quip card _this)
:_ this
~[bind-eyre:sc]
++ on-save clean-state:sc
++ on-load
|^
@ -141,7 +154,9 @@
=+ !<(any=clean-slate-any old-state)
=? any ?=(^ -.any) (old-to-1 any)
=? any ?=(~ -.any) (old-to-1 any)
?> ?=(%1 -.any)
=^ upgrade-cards any
(old-to-2 any)
?> ?=(%2 -.any)
::
=. tid.state tid.any
=/ yarns=(list yarn)
@ -154,12 +169,26 @@
(handle-stop-thread:sc (yarn-to-tid i.yarns) |)
=^ cards-2 this
$(yarns t.yarns)
[(weld cards-1 cards-2) this]
[:(weld upgrade-cards cards-1 cards-2) this]
::
++ old-to-1
|= old=clean-slate-ket
^- clean-slate
^- clean-slate-1
1+old(starting (~(run by starting.old) |=([* v=vase] none+v)))
::
++ old-to-2
|= old=clean-slate-any
^- (quip card clean-slate)
?> ?=(?(%1 %2) -.old)
?: ?=(%2 -.old)
`old
:- ~[bind-eyre:sc]
:* %2
starting.old
running.old
tid.old
~
==
--
::
++ on-poke
@ -172,6 +201,9 @@
%spider-input (on-poke-input:sc !<(input vase))
%spider-start (handle-start-thread:sc !<(start-args vase))
%spider-stop (handle-stop-thread:sc !<([tid ?] vase))
::
%handle-http-request
(handle-http-request:sc !<([@ta =inbound-request:eyre] vase))
==
[cards this]
::
@ -182,6 +214,7 @@
?+ path (on-watch:def path)
[%thread @ *] (on-watch:sc t.path)
[%thread-result @ ~] (on-watch-result:sc i.t.path)
[%http-response *] `state
==
[cards this]
::
@ -216,6 +249,7 @@
?+ wire (on-arvo:def wire sign-arvo)
[%thread @ *] (handle-sign:sc i.t.wire t.t.wire sign-arvo)
[%build @ ~] (handle-build:sc i.t.wire sign-arvo)
[%bind ~] `state
==
[cards this]
:: On unexpected failure, kill all outstanding strands
@ -228,6 +262,41 @@
--
::
|_ =bowl:gall
::
++ bind-eyre
^- card
[%pass /bind %arvo %e %connect [~ /spider] %spider]
::
++ handle-http-request
|= [eyre-id=@ta =inbound-request:eyre]
^- (quip card _state)
?> authenticated.inbound-request
=/ url
(parse-request-line:server url.request.inbound-request)
?> ?=([%spider @t @t @t ~] site.url)
=* input-mark i.t.site.url
=* thread i.t.t.site.url
=* output-mark i.t.t.t.site.url
=/ =tid
(scot %uv (sham eny.bowl))
=. serving.state
(~(put by serving.state) tid [eyre-id output-mark])
=+ .^
=tube:clay
%cc
/(scot %p our.bowl)/[q.byk.bowl]/(scot %da now.bowl)/json/[input-mark]
==
?> ?=(^ body.request.inbound-request)
=/ body=json
(need (de-json:html q.u.body.request.inbound-request))
=/ input=vase
(tube !>(body))
=/ =start-args
[~ `tid thread input]
=^ cards state
(handle-start-thread start-args)
[cards state]
::
++ on-poke-input
|= input
=/ yarn (~(got by tid.state) tid)
@ -394,6 +463,25 @@
:~ [%give %fact ~[/thread-result/[tid]] %thread-fail !>([term tang])]
[%give %kick ~[/thread-result/[tid]] ~]
==
++ thread-http-fail
|= [=tid =term =tang]
^- (quip card ^state)
=- (fall - `state)
%+ bind
(~(get by serving.state) tid)
|= [eyre-id=@ta output=mark]
:_ state(serving (~(del by serving.state) tid))
%+ give-simple-payload:app:server eyre-id
^- simple-payload:http
:_ ~ :_ ~
?. ?=(http-error:spider term)
((slog tang) 500)
?- term
%bad-request 400
%forbidden 403
%nonexistent 404
%offline 504
==
::
++ thread-fail
|= [=yarn =term =tang]
@ -402,7 +490,24 @@
=/ =tid (yarn-to-tid yarn)
=/ fail-cards (thread-say-fail tid term tang)
=^ cards state (thread-clean yarn)
[(weld fail-cards cards) state]
=^ http-cards state (thread-http-fail tid term tang)
[:(weld fail-cards cards http-cards) state]
::
++ thread-http-response
|= [=tid =vase]
^- (quip card ^state)
=- (fall - `state)
%+ bind
(~(get by serving.state) tid)
|= [eyre-id=@ta output=mark]
=+ .^
=tube:clay
%cc
/(scot %p our.bowl)/[q.byk.bowl]/(scot %da now.bowl)/[output]/json
==
:_ state(serving (~(del by serving.state) tid))
%+ give-simple-payload:app:server eyre-id
(json-response:gen:server !<(json (tube vase)))
::
++ thread-done
|= [=yarn =vase]
@ -413,8 +518,10 @@
:~ [%give %fact ~[/thread-result/[tid]] %thread-done vase]
[%give %kick ~[/thread-result/[tid]] ~]
==
=^ http-cards state
(thread-http-response tid vase)
=^ cards state (thread-clean yarn)
[(weld done-cards cards) state]
[:(weld done-cards cards http-cards) state]
::
++ thread-clean
|= =yarn
@ -474,5 +581,5 @@
::
++ clean-state
!> ^- clean-slate
1+state(running (turn (tap-yarn running.state) head))
2+state(running (turn (tap-yarn running.state) head))
--

View File

@ -0,0 +1,61 @@
/- sur=graph-view
/+ resource, group-store
^?
=< [sur .]
=, sur
|%
++ dejs
=, dejs:format
|%
++ action
|^
^- $-(json ^action)
%- of
:~ create+create
delete+delete
join+join
leave+leave
groupify+groupify
::invite+invite
==
::
++ create
%- ou
:~ resource+(un dejs:resource)
title+(un so)
description+(un so)
mark+(uf ~ (mu so))
associated+(un associated)
==
::
++ leave
%- ot
:~ resource+dejs:resource
==
::
++ delete
%- ot
:~ resource+dejs:resource
==
::
++ join
%- ot
:~ resource+dejs:resource
ship+(su ;~(pfix sig fed:ag))
==
::
++ groupify
%- ou
:~ resource+(un dejs:resource)
to+(uf ~ (mu dejs:resource))
==
++ invite !!
::
++ associated
%- of
:~ group+dejs:resource
policy+policy:dejs:group-store
==
--
--
--

View File

@ -13,12 +13,24 @@
::
++ get-graph
|= res=resource
^- marked-graph:store
%+ scry-for marked-graph:store
^- update:store
%+ scry-for update:store
/graph/(scot %p entity.res)/[name.res]
::
++ peek-log
++ get-update-log
|= rid=resource
^- update-log:store
%+ scry-for update-log:store
/update-log/(scot %p entity.rid)/[name.rid]
::
++ peek-update-log
|= res=resource
^- (unit time)
(scry-for (unit time) /peek-update-log/(scot %p entity.res)/[name.res])
::
++ get-update-log-subset
|= [res=resource start=@da]
^- update-log:store
%+ scry-for update-log:store
/update-log-subset/(scot %p entity.res)/[name.res]/(scot %da start)/'~'
--

View File

@ -48,6 +48,13 @@
^- ?
=- (~(has in -) ship)
(members-from-path group-path)
::
++ is-admin
|= [=ship =group-path]
^- ?
=/ tags tags:(fall (scry-group-path group-path) *group)
=/ admins=(set ^ship) (~(gut by tags) %admin ~)
(~(has in admins) ship)
:: +role-for-ship: get role for user
::
:: Returns ~ if no such group exists or user is not
@ -77,6 +84,7 @@
?: (~(has in members.group) ship)
[~ ~]
~
::
++ can-join-from-path
|= [=path =ship]
%+ scry-for

View File

@ -105,6 +105,8 @@
%file-server
%glob
%graph-store
%graph-pull-hook
%graph-push-hook
==
::
++ deft-fish :: default connects
@ -207,7 +209,7 @@
==
::
++ on-load
|= [hood-version=?(%1 %2 %3 %4 %5 %6 %7 %8 %9) old=any-state]
|= [hood-version=?(%1 %2 %3 %4 %5 %6 %7 %8 %9 %10) old=any-state]
=< se-abet =< se-view
=. sat old
=. dev (~(gut by bin) ost *source)
@ -236,6 +238,9 @@
(se-born | %home %group-pull-hook)
=? ..on-load (lte hood-version %9)
(se-born | %home %graph-store)
=? ..on-load (lte hood-version %10)
=> (se-born | %home %graph-push-hook)
(se-born | %home %graph-pull-hook)
..on-load
::
++ reap-phat :: ack connect

View File

@ -241,6 +241,16 @@
;< our=@p bind:m get-our
(watch wire [our term] path)
::
++ scry
|* [=mold =path]
=/ m (strand ,mold)
^- form:m
?> ?=(^ path)
?> ?=(^ t.path)
;< =bowl:spider bind:m get-bowl
%- pure:m
.^(mold i.path (scot %p our.bowl) i.t.path (scot %da now.bowl) t.t.path)
::
++ leave
|= [=wire =dock]
=/ m (strand ,~)
@ -285,6 +295,20 @@
[%pass /wait/(scot %da until) %arvo %b %wait until]
(send-raw-card card)
::
++ map-err
|* computation-result=mold
=/ m (strand ,computation-result)
|= [f=$-([term tang] [term tang]) computation=form:m]
^- form:m
|= tin=strand-input:strand
=* loop $
=/ c-res (computation tin)
?: ?=(%cont -.next.c-res)
c-res(self.next ..loop(computation self.next.c-res))
?. ?=(%fail -.next.c-res)
c-res
c-res(err.next (f err.next.c-res))
::
++ set-timeout
|* computation-result=mold
=/ m (strand ,computation-result)
@ -478,6 +502,17 @@
`[%skip ~]
`[%done +>.sign-arvo.u.in.tin]
==
:: +check-online: require that peer respond before timeout
::
++ check-online
|= [who=ship lag=@dr]
=/ m (strand ,~)
^- form:m
%+ (map-err ,~) |=(* [%offline *tang])
%+ (set-timeout ,~) lag
;< ~ bind:m
(poke [who %hood] %helm-hi !>(~))
(pure:m ~)
::
:: Queue on skip, try next on fail %ignore
::

View File

@ -1,7 +1,9 @@
/+ *graph-store
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json (update:enjs upd)
--
::

View File

@ -0,0 +1,27 @@
/- *post
|_ i=indexed-post
++ grow
|%
++ noun i
--
++ grab
|%
++ noun
|= p=*
=/ ip ;;(indexed-post p)
?+ index.p.ip ~|(index+index.p.ip !!)
:: top-level link post; title and url
::
[@ ~]
?> ?=([[%text @] [%url @] ~] contents.p.ip)
ip
::
:: comment on link post; comment text
::
[@ @ ~]
?> ?=([[%text @] ~] contents.p.ip)
ip
==
--
++ grad %noun
--

View File

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

View File

@ -0,0 +1,45 @@
/- *group, store=graph-store
/+ resource
^?
|%
:: $associated: A group to associate, or a policy if it is unmanaged
::
+$ associated
$% [%group rid=resource]
[%policy =policy]
==
::
:: $error: An error from a graph-view poke
::
:: %offline: Ship is offline
:: %bad-perms: Not permitted
:: %unknown: Anything not described above
::
+$ error
?(%offline %bad-perms %unknown)
:: $action: A semantic action on graphs
::
:: %create: Create a graph and associated metadata
:: %delete: Delete a graph
:: %join: Join a graph
:: %invite: Invite users to a graph
:: %groupify: Make graph into managed group
::
+$ action
$%
$: %create
rid=resource
title=@t
description=@t
mark=(unit mark)
=associated
==
[%delete rid=resource]
[%leave rid=resource]
[%join rid=resource =ship]
::[%invite rid=resource ships=(set ship)]
[%groupify rid=resource to=(unit resource)]
[%forward rid=resource =update:store]
==
--

View File

@ -5,4 +5,10 @@
+$ input [=tid =cage]
+$ tid tid:strand
+$ bowl bowl:strand
+$ http-error
$? %bad-request :: 400
%forbidden :: 403
%nonexistent :: 404
%offline :: 504
==
--

View File

@ -0,0 +1,60 @@
/- spider, graph=graph-store, *metadata-store, *group, group-store
/+ strandio, resource, graph-view
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ handle-group
|= [rid=resource =associated:graph-view]
=/ m (strand ,resource)
?: ?=(%group -.associated)
(pure:m rid.associated)
=/ =action:group-store
[%add-group rid policy.associated %&]
;< ~ bind:m (poke-our %group-store %group-action !>(action))
;< ~ bind:m
(poke-our %group-push-hook %push-hook-action !>([%add rid]))
(pure:m rid)
--
::
=, strand=strand:spider
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%create -.action)
;< =bowl:spider bind:m get-bowl:strandio
:: Add graph to graph-store
::
?. =(our.bowl entity.rid.action)
(strand-fail:strandio %bad-request ~)
=/ =update:graph
[%0 now.bowl %add-graph rid.action *graph:graph mark.action]
;< ~ bind:m
(poke-our %graph-store graph-update+!>(update))
;< ~ bind:m
(poke-our %graph-push-hook %push-hook-action !>([%add rid.action]))
:: Add group, if graph is unmanaged
::
;< group=resource bind:m
(handle-group rid.action associated.action)
=/ group-path=path
(en-path:resource group)
:: Setup metadata
::
=/ =metadata
%* . *metadata
title title.action
description description.action
date-created now.bowl
creator our.bowl
==
=/ act=metadata-action
[%add group-path graph+(en-path:resource rid.action) metadata]
;< ~ bind:m (poke-our %metadata-hook %metadata-action !>(act))
;< ~ bind:m
(poke-our %metadata-hook %metadata-hook-action !>([%add-owned group-path]))
(pure:m !>(~))

View File

@ -0,0 +1,70 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ scry-metadata
|= rid=resource
=/ m (strand ,(unit resource))
;< paxs=(unit (set path)) bind:m
%+ scry:strandio ,(unit (set path))
;: weld
/gx/metadata-store/resource/graph
(en-path:resource rid)
/noun
==
?~ paxs (pure:m ~)
?~ u.paxs (pure:m ~)
(pure:m `(de-path:resource n.u.paxs))
::
++ scry-group
|= rid=resource
=/ m (strand ,group)
;< ugroup=(unit group) bind:m
%+ scry:strandio ,(unit group)
;: weld
/gx/group-store/groups
(en-path:resource rid)
/noun
==
(pure:m (need ugroup))
::
++ delete-graph
|= rid=resource
=/ m (strand ,~)
^- form:m
;< =bowl:spider bind:m get-bowl:strandio
;< ~ bind:m
(poke-our %graph-store %graph-update !>([%0 now.bowl %remove-graph rid]))
;< ~ bind:m
(poke-our %graph-push-hook %push-hook-action !>([%remove rid]))
(pure:m ~)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%delete -.action)
;< =bowl:spider bind:m get-bowl:strandio
?. =(our.bowl entity.rid.action)
(strand-fail:strandio %bad-request ~)
;< ugroup-rid=(unit resource) bind:m
(scry-metadata rid.action)
?~ ugroup-rid !!
;< =group bind:m
(scry-group u.ugroup-rid)
?. hidden.group
;< ~ bind:m
(delete-graph rid.action)
(pure:m !>(~))
;< ~ bind:m
(poke-our %group-store %group-action !>([%remove-group rid.action ~]))
;< ~ bind:m
(poke-our %group-push-hook %push-hook-action !>([%remove rid.action]))
;< ~ bind:m (delete-graph rid.action)
(pure:m !>(~))

View File

@ -0,0 +1,74 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group, *metadata-store
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ check-live
|= who=ship
=/ m (strand ,~)
^- form:m
%+ (set-timeout:strandio ,~) ~s20
;< ~ bind:m
(poke [who %hood] %helm-hi !>(~))
(pure:m ~)
::
++ scry-group
|= rid=resource
=/ m (strand ,group)
^- form:m
;< ugroup=(unit group) bind:m
%+ scry:strandio (unit group)
%+ weld /gx/group-store/groups
(snoc (en-path:resource rid) %noun)
?> ?=(^ ugroup)
(pure:m u.ugroup)
::
++ scry-metadatum
|= rid=resource
=/ m (strand ,metadata)
^- form:m
=/ enc-path=@t
(scot %t (spat (en-path:resource rid)))
;< umeta=(unit metadata) bind:m
%+ scry:strandio (unit metadata)
%+ weld /gx/metadata-store/metadata
/[enc-path]/graph/[enc-path]/noun
?> ?=(^ umeta)
(pure:m u.umeta)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%groupify -.action)
;< =group bind:m (scry-group rid.action)
?. hidden.group
(strand-fail:strandio %bad-request ~)
;< =metadata bind:m
(scry-metadatum rid.action)
?~ to.action
;< ~ bind:m
%+ poke-our %contact-view
contact-view-action+!>([%groupify rid.action title.metadata description.metadata])
(pure:m !>(~))
;< new=^group bind:m (scry-group u.to.action)
?< hidden.new
=/ new-path
(en-path:resource u.to.action)
=/ app-path
(en-path:resource rid.action)
=/ add-md=metadata-action
[%add new-path graph+app-path metadata]
;< ~ bind:m
(poke-our %metadata-store metadata-action+!>(add-md))
;< ~ bind:m
%+ poke-our %metadata-store
metadata-action+!>([%remove app-path graph+app-path])
;< ~ bind:m
(poke-our %group-store %group-update !>([%remove-group rid.action]))
(pure:m !>(~))

View File

@ -0,0 +1,61 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ fail strand-fail:strand
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ scry-metadata
|= rid=resource
=/ m (strand ,(unit resource))
^- form:m
;< pax=(unit (set path)) bind:m
%+ scry:strandio ,(unit (set path))
;: weld
/gx/metadata-store/resource/graph
(en-path:resource rid)
/noun
==
%- pure:m
?~ pax ~
?~ u.pax ~
`(de-path:resource n.u.pax)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<(=action:graph-view arg)
?> ?=(%join -.action)
;< =bowl:spider bind:m get-bowl:strandio
?: =(our.bowl entity.rid.action)
(fail %bad-request ~)
;< group=(unit resource) bind:m (scry-metadata rid.action)
?^ group
:: We have group, graph is managed
;< ~ bind:m
%+ poke-our %graph-pull-hook
pull-hook-action+!>([%add ship.action rid.action])
(pure:m !>(~))
:: Else, add group then join
;< ~ bind:m
%+ (map-err:strandio ,~) |=(* [%forbidden ~])
%+ poke
[ship.action %group-push-hook]
group-update+!>([%add-members rid.action (sy our.bowl ~)])
::
;< ~ bind:m
%+ poke-our %group-pull-hook
pull-hook-action+!>([%add ship.action rid.action])
::
;< ~ bind:m
%+ poke-our %metadata-hook
metadata-hook-action+!>([%add-synced ship.action rid.action])
::
;< ~ bind:m
%+ poke-our %graph-pull-hook
pull-hook-action+!>([%add ship.action rid.action])
(pure:m !>(~))

View File

@ -0,0 +1,67 @@
/- spider, graph-view, graph=graph-store, *metadata-store, *group
/+ strandio, resource
=>
|%
++ strand strand:spider
++ poke poke:strandio
++ poke-our poke-our:strandio
::
++ scry-metadata
|= rid=resource
=/ m (strand ,resource)
^- form:m
;< pax=(unit (set path)) bind:m
%+ scry:strandio ,(unit (set path))
;: weld
/gx/metadata-store/resource/graph
(en-path:resource rid)
/noun
==
?> ?=(^ pax)
?> ?=(^ u.pax)
(pure:m (de-path:resource n.u.pax))
::
++ scry-group
|= rid=resource
=/ m (strand ,group)
^- form:m
;< ugroup=(unit group) bind:m
%+ scry:strandio ,(unit group)
;: weld
/gx/group-store/resource/graph
(en-path:resource rid)
/noun
==
(pure:m (need ugroup))
::
++ delete-graph
|= rid=resource
=/ m (strand ,~)
^- form:m
;< ~ bind:m
(poke-our %graph-pull-hook %pull-hook-action !>([%remove rid]))
;< ~ bind:m
(poke-our %graph-store %graph-update !>([%remove-graph rid]))
(pure:m ~)
--
::
^- thread:spider
|= arg=vase
=/ m (strand ,vase)
^- form:m
=+ !<([=action:graph-view ~] arg)
?> ?=(%leave -.action)
;< =bowl:spider bind:m get-bowl:strandio
?: =(our.bowl entity.rid.action)
(strand-fail:strandio %bad-request ~)
;< group-rid=resource bind:m (scry-metadata rid.action)
;< g=group bind:m (scry-group group-rid)
?. hidden.g
;< ~ bind:m (delete-graph rid.action)
(pure:m !>(~))
;< ~ bind:m
(poke-our %group-push-hook %pull-hook-action !>([%remove rid.action]))
;< ~ bind:m
(poke-our %group-store %group-action !>([%remove-group rid.action ~]))
;< ~ bind:m (delete-graph rid.action)
(pure:m !>(~))

View File

@ -9,7 +9,6 @@ export default class BaseApi<S extends object = {}> {
unsubscribe(id: number) {
this.channel.unsubscribe(id);
}
subscribe(path: Path, method, ship = this.ship, app: string, success, fail, quit) {
@ -37,19 +36,20 @@ export default class BaseApi<S extends object = {}> {
);
}
action(appl: string, mark: string, data: any): Promise<any> {
action(
appl: string,
mark: string,
data: any,
ship = (window as any).ship
): Promise<any> {
return new Promise((resolve, reject) => {
this.channel.poke(
(window as any).ship,
ship,
appl,
mark,
data,
(json) => {
resolve(json);
},
(err) => {
reject(err);
}
(json) => { resolve(json); },
(err) => { reject(err); }
);
});
}
@ -57,4 +57,14 @@ export default class BaseApi<S extends object = {}> {
scry<T>(app: string, path: Path): Promise<T> {
return fetch(`/~/scry/${app}${path}.json`).then(r => r.json() as Promise<T>);
}
async spider<T>(inputMark: string, outputMark: string, threadName: string, body: any): Promise<T> {
const res = await fetch(`/spider/${inputMark}/${threadName}/${outputMark}.json`, {
method: 'POST',
body: JSON.stringify(body)
});
return res.json();
}
}

View File

@ -1,7 +1,10 @@
import BaseApi from './base';
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} from '~/types';
import { deSig } from '~/logic/lib/util';
export const createPost = (contents: Object[], parentIndex: string = '') => {
return {
@ -20,8 +23,93 @@ export default class GraphApi extends BaseApi<StoreState> {
return this.action('graph-store', 'graph-update', action)
}
private viewAction(threadName: string, action: any) {
return this.spider('graph-view-action', 'json', threadName, action);
}
private hookAction(ship: Patp, action: any): Promise<any> {
return this.action('graph-push-hook', 'graph-update', action, deSig(ship));
}
createManagedGraph(
name: string,
title: string,
description: string,
group: Path
) {
const associated = { group: resourceFromPath(group) };
const resource = makeResource(`~${window.ship}`, name);
return this.viewAction('graph-create', {
"create": {
resource,
title,
description,
associated
}
});
}
createUnmanagedGraph(
name: string,
title: string,
description: string,
policy: Enc<GroupPolicy>
) {
const resource = makeResource(`~${window.ship}`, name);
return this.viewAction('graph-create', {
"create": {
resource,
title,
description,
associated: { policy }
}
});
}
joinGraph(ship: Patp, name: string) {
const resource = makeResource(ship, name);
return this.viewAction('graph-join', {
join: {
resource,
ship,
}
});
}
deleteGraph(name: string) {
const resource = makeResource(`~${window.ship}`, name);
return this.viewAction('graph-delete', {
"delete": {
resource
}
});
}
leaveGraph(ship: Patp, name: string) {
const resource = makeResource(ship, name);
return this.viewAction('graph-leave', {
"leave": {
resource
}
});
}
groupifyGraph(ship: Patp, name: string, toPath?: string) {
const resource = makeResource(ship, name);
const to = toPath && resourceFromPath(toPath);
return this.viewAction('graph-groupify', {
groupify: {
resource,
to
}
});
}
addGraph(ship: Patp, name: string, graph: any, mark: any) {
this.storeAction({
return this.storeAction({
'add-graph': {
resource: { ship, name },
graph,
@ -30,31 +118,24 @@ export default class GraphApi extends BaseApi<StoreState> {
});
}
removeGraph(ship: Patp, name: string) {
this.storeAction({
'remove-graph': {
resource: { ship, name }
}
});
}
addPost(ship: Patp, name: string, post: Object) {
addPost(ship: Patp, name: string, post: Post) {
let nodes = {};
const resource = { ship, name };
nodes[post.index] = {
post,
children: { empty: null }
};
this.storeAction({
return this.hookAction(ship, {
'add-nodes': {
resource: { ship, name },
resource,
nodes
}
});
}
addNodes(ship: Patp, name: string, nodes: Object) {
this.storeAction({
this.hookAction(ship, {
'add-nodes': {
resource: { ship, name },
nodes
@ -63,7 +144,7 @@ export default class GraphApi extends BaseApi<StoreState> {
}
removeNodes(ship: Patp, name: string, indices: string[]) {
this.storeAction({
return this.hookAction(ship, {
'remove-nodes': {
resource: { ship, name },
indices
@ -107,7 +188,7 @@ export default class GraphApi extends BaseApi<StoreState> {
});
}
getGraphSubset(ship: string, resource: string, start: string, end: start) {
getGraphSubset(ship: string, resource: string, start: string, end: string) {
this.scry<any>(
'graph-store',
`/graph-subset/${ship}/${resource}/${end}/${start}`

View File

@ -13,3 +13,8 @@ export function resourceFromPath(path: Path): Resource {
const [, , ship, name] = path.split('/');
return { ship, name }
}
export function makeResource(ship: string, name:string) {
return { ship, name };
}

View File

@ -140,10 +140,21 @@ const addNodes = (json, state) => {
};
const removeNodes = (json, state) => {
const _remove = (graph, index) => {
if (index.length === 1) {
graph.delete(index[0]);
} else {
const child = graph.get(index[0]);
_remove(child.children, index.slice(1));
graph.set(index[0], child);
}
};
const data = _.get(json, 'remove-nodes', false);
if (data) {
console.log(data);
if (!(data.resource in state.graphs)) { return; }
const { ship, name } = data.resource;
const res = `${ship}/${name}`;
if (!(res in state.graphs)) { return; }
data.indices.forEach((index) => {
console.log(index);
@ -151,13 +162,7 @@ const removeNodes = (json, state) => {
let indexArr = index.split('/').slice(1).map((ind) => {
return parseInt(ind, 10);
});
if (indexArr.length === 1) {
state.graphs[data.resource].delete(indexArr[0]);
} else {
// TODO: recursive
}
_remove(state.graphs[res], indexArr);
});
}
};

View File

@ -66,7 +66,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
associations: {
chat: {},
contacts: {},
link: {},
graph: {},
publish: {}
},
groups: {},

View File

@ -12,6 +12,7 @@ import { LaunchState, WeatherState } from '~/types/launch-update';
import { LinkComments, LinkCollections, LinkSeen } from '~/types/link-update';
import { ConnectionStatus } from '~/types/connection';
import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from '~/types/local-update';
import {Graphs} from '~/types/graph-update';
export interface StoreState {
// local state
@ -36,7 +37,7 @@ export interface StoreState {
groupKeys: Set<Path>;
permissions: Permissions;
s3: S3State;
graphs: Object;
graphs: Graphs;
graphKeys: Set<String>;

View File

@ -0,0 +1,30 @@
import {Patp} from "./noun";
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 Post {
author: Patp;
contents: Content[];
hash?: string;
index: string;
pending?: boolean;
signatures: string[];
'time-sent': number;
}
export interface GraphNode {
children: Graph;
post: Post;
}
export type Graph = Map<number, GraphNode>;
export type Graphs = { [rid: string]: Graph };

View File

@ -5,6 +5,7 @@ export * from './connection';
export * from './contact-update';
export * from './global';
export * from './group-update';
export * from './graph-update';
export * from './invite-update';
export * from './launch-update';
export * from './link-listen-update';

View File

@ -10,26 +10,20 @@ import { Skeleton } from './components/skeleton';
import { NewScreen } from './components/new';
import { SettingsScreen } from './components/settings';
import { MessageScreen } from './components/lib/message-screen';
import { Links } from './components/links-list';
import { LinkDetail } from './components/link';
import { LinkList } from './components/link-list';
import { LinkDetail } from './components/link-detail';
import {
makeRoutePath,
amOwnerOfGroup,
base64urlDecode
} from '~/logic/lib/util';
export class LinksApp extends Component {
constructor(props) {
super(props);
}
export class LinksApp extends Component {
componentDidMount() {
// preload spinner asset
new Image().src = '/~landscape/img/Spinner.png';
this.props.api.links.getPage('', 0);
this.props.subscription.startApp('link');
this.props.subscription.startApp('graph');
if (!this.props.sidebarShown) {
this.props.api.local.sidebarToggle();
@ -37,7 +31,6 @@ export class LinksApp extends Component {
}
componentWillUnmount() {
this.props.subscription.stopApp('link');
this.props.subscription.stopApp('graph');
}
@ -45,26 +38,20 @@ export class LinksApp extends Component {
const { props } = this;
const contacts = props.contacts ? props.contacts : {};
const groups = props.groups ? props.groups : {};
const associations = props.associations ? props.associations : { link: {}, contacts: {} };
const links = props.links ? props.links : {};
const comments = props.linkComments ? props.linkComments : {};
const seen = props.linksSeen ? props.linksSeen : {};
const totalUnseen = _.reduce(
links,
(acc, collection) => acc + collection.unseenCount,
0
);
const associations =
props.associations ? props.associations : { graph: {}, contacts: {} };
const graphKeys = props.graphKeys || new Set([]);
const graphs = props.graphs || {};
const invites = props.invites ?
props.invites : {};
const listening = props.linkListening;
const { api, sidebarShown, hideAvatars, hideNicknames, s3, remoteContentPolicy } = this.props;
return (
<>
<Helmet defer={false}>
<title>{totalUnseen > 0 ? `(${totalUnseen}) ` : ''}OS1 - Links</title>
<title>OS1 - Links</title>
</Helmet>
<Switch>
<Route exact path="/~link"
@ -77,10 +64,8 @@ export class LinksApp extends Component {
groups={groups}
rightPanelHide={true}
sidebarShown={sidebarShown}
links={links}
listening={listening}
api={api}
>
graphKeys={graphKeys}>
<MessageScreen text="Select or create a collection to begin." />
</Skeleton>
);
@ -94,29 +79,28 @@ export class LinksApp extends Component {
invites={invites}
groups={groups}
sidebarShown={sidebarShown}
links={links}
listening={listening}
api={api}
>
graphKeys={graphKeys}>
<NewScreen
api={api}
graphKeys={graphKeys}
associations={associations}
groups={groups}
contacts={contacts}
api={api}
{...props}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~link/join/:resource"
<Route exact path="/~link/join/:ship/:name"
render={ (props) => {
const resourcePath = '/' + props.match.params.resource;
const resource =
`${props.match.params.ship}/${props.match.params.name}`;
const autoJoin = () => {
try {
api.links.joinCollection(resourcePath);
props.history.push(makeRoutePath(resourcePath));
// TODO: graph join
props.history.push(`/~link/${resource}`);
} catch(err) {
setTimeout(autoJoin, 2000);
}
@ -124,51 +108,15 @@ export class LinksApp extends Component {
autoJoin();
}}
/>
<Route exact path="/~link/(popout)?/:resource/members"
render={(props) => {
const popout = props.match.url.includes('/popout/');
const resourcePath = '/' + props.match.params.resource;
const resource = associations.link[resourcePath] || { metadata: {} };
const contactDetails = contacts[resource['group-path']] || {};
const group = groups[resource['group-path']] || new Set([]);
const amOwner = amOwnerOfGroup(resource['group-path']);
return (
<Skeleton
associations={associations}
invites={invites}
groups={groups}
selected={resourcePath}
sidebarShown={sidebarShown}
links={links}
listening={listening}
api={api}
>
<MemberScreen
sidebarShown={sidebarShown}
resource={resource}
contacts={contacts}
contactDetails={contactDetails}
groupPath={resource['group-path']}
group={group}
groups={groups}
associations={associations}
amOwner={amOwner}
resourcePath={resourcePath}
popout={popout}
api={api}
{...props}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~link/(popout)?/:resource/settings"
<Route exact path="/~link/(popout)?/:ship/:name/settings"
render={ (props) => {
const resourcePath =
`${props.match.params.ship}/${props.match.params.name}`;
const popout = props.match.url.includes('/popout/');
const resourcePath = '/' + props.match.params.resource;
const resource = associations.link[resourcePath] || false;
const metPath = `/ship/~${resourcePath}`;
const resource =
associations.graph[metPath] ?
associations.graph[metPath] : { metadata: {} };
const contactDetails = contacts[resource['group-path']] || {};
const group = groups[resource['group-path']] || new Set([]);
@ -182,149 +130,125 @@ export class LinksApp extends Component {
selected={resourcePath}
sidebarShown={sidebarShown}
popout={popout}
links={links}
listening={listening}
api={api}
>
graphKeys={graphKeys}
api={api}>
<SettingsScreen
sidebarShown={sidebarShown}
resource={resource}
contacts={contacts}
contactDetails={contactDetails}
groupPath={resource['group-path']}
graphResource={graphKeys.has(resourcePath)}
group={group}
amOwner={amOwner}
resourcePath={resourcePath}
popout={popout}
api={api}
{...props} />
</Skeleton>
);
}}
/>
<Route exact path="/~link/(popout)?/:ship/:name"
render={ (props) => {
const resourcePath =
`${props.match.params.ship}/${props.match.params.name}`;
const resource =
associations.graph[resourcePath] ?
associations.graph[resourcePath] : { metadata: {} };
const contactDetails = contacts[resource['group-path']] || {};
const popout = props.match.url.includes('/popout/');
const graph = graphs[resourcePath] || null;
if (!graph) {
api.graph.getGraph(
`~${props.match.params.ship}`,
props.match.params.name
);
}
return (
<Skeleton
associations={associations}
invites={invites}
groups={groups}
selected={resourcePath}
sidebarShown={sidebarShown}
sidebarHideMobile={true}
popout={popout}
api={api}
graphKeys={graphKeys}>
<LinkList
{...props}
api={api}
graph={graph}
popout={popout}
metadata={resource.metadata}
contacts={contactDetails}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
sidebarShown={sidebarShown}
ship={props.match.params.ship}
name={props.match.params.name}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~link/(popout)?/:resource/:page?"
render={ (props) => {
const resourcePath = '/' + props.match.params.resource;
const resource = associations.link[resourcePath] || { metadata: {} };
<Route exact path="/~link/(popout)?/:ship/:name/:index"
render={ (props) => {
const resourcePath =
`${props.match.params.ship}/${props.match.params.name}`;
const resource =
associations.graph[resourcePath] ?
associations.graph[resourcePath] : { metadata: {} };
const popout = props.match.url.includes('/popout/');
const contactDetails = contacts[resource['group-path']] || {};
const amOwner = amOwnerOfGroup(resource['group-path']);
const indexArr = props.match.params.index.split('-');
const graph = graphs[resourcePath] || null;
const contactDetails = contacts[resource['group-path']] || {};
if (indexArr.length <= 1) {
return <div>Malformed URL</div>;
}
const page = props.match.params.page || 0;
const index = parseInt(indexArr[1], 10);
const node = !!graph ? graph.get(index) : null;
const popout = props.match.url.includes('/popout/');
const channelLinks = links[resourcePath]
? links[resourcePath]
: { local: {} };
const channelComments = comments[resourcePath]
? comments[resourcePath]
: {};
const channelSeen = seen[resourcePath]
? seen[resourcePath]
: {};
return (
<Skeleton
associations={associations}
invites={invites}
groups={groups}
selected={resourcePath}
sidebarShown={sidebarShown}
sidebarHideMobile={true}
popout={popout}
links={links}
listening={listening}
api={api}
>
<Links
{...props}
contacts={contactDetails}
links={channelLinks}
comments={channelComments}
seen={channelSeen}
page={page}
resourcePath={resourcePath}
resource={resource}
amOwner={amOwner}
popout={popout}
sidebarShown={sidebarShown}
api={api}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
s3={s3}
/>
</Skeleton>
if (!graph) {
api.graph.getGraph(
`~${props.match.params.ship}`,
props.match.params.name
);
}}
/>
<Route exact path="/~link/(popout)?/:resource/:page/:index/:encodedUrl/:commentpage?"
render={ (props) => {
const resourcePath = '/' + props.match.params.resource;
const resource = associations.link[resourcePath] || { metadata: {} };
const amOwner = amOwnerOfGroup(resource['group-path']);
const popout = props.match.url.includes('/popout/');
const contactDetails = contacts[resource['group-path']] || {};
const index = props.match.params.index || 0;
const page = props.match.params.page || 0;
const url = base64urlDecode(props.match.params.encodedUrl);
const data = links[resourcePath]
? links[resourcePath][page]
? links[resourcePath][page][index]
: {}
: {};
const coms = !comments[resourcePath]
? undefined
: comments[resourcePath][url];
const commentPage = props.match.params.commentpage || 0;
return (
<Skeleton
associations={associations}
invites={invites}
groups={groups}
selected={resourcePath}
sidebarShown={sidebarShown}
sidebarHideMobile={true}
popout={popout}
links={links}
listening={listening}
api={api}
>
<LinkDetail
}
return (
<Skeleton
associations={associations}
invites={invites}
groups={groups}
selected={resourcePath}
sidebarShown={sidebarShown}
sidebarHideMobile={true}
popout={popout}
graphKeys={graphKeys}
api={api}>
<LinkDetail
{...props}
node={node}
ship={props.match.params.ship}
name={props.match.params.name}
resource={resource}
page={page}
url={url}
linkIndex={index}
contacts={contactDetails}
resourcePath={resourcePath}
groupPath={resource['group-path']}
amOwner={amOwner}
popout={popout}
sidebarShown={sidebarShown}
data={data}
comments={coms}
commentPage={commentPage}
api={api}
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
remoteContentPolicy={remoteContentPolicy}
/>
</Skeleton>
);
}}
/>
remoteContentPolicy={remoteContentPolicy} />
</Skeleton>
);
}}
/>
</Switch>
</>
);

View File

@ -1,8 +1,8 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { makeRoutePath } from '~/logic/lib/util';
export class ChannelsItem extends Component {
export class ChannelItem extends Component {
render() {
const { props } = this;
@ -15,7 +15,7 @@ export class ChannelsItem extends Component {
: null;
return (
<Link to={makeRoutePath(props.link)}>
<Link to={`/~link/${props.link}`}>
<div className={'w-100 v-mid f9 ph5 z1 pv1 relative ' + selectedClass}>
<p className="f9 dib">{props.name}</p>
<p className="f9 dib fr">

View File

@ -6,120 +6,105 @@ import SidebarInvite from '~/views/components/SidebarInvite';
import { Welcome } from './welcome';
import { alphabetiseAssociations } from '~/logic/lib/util';
export class ChannelsSidebar extends Component {
// drawer to the left
render() {
const { props } = this;
const sidebarInvites = Object.keys(props.invites)
.map((uid) => {
return (
<SidebarInvite
key={uid}
invite={props.invites[uid]}
onAccept={() => props.api.invite.accept('/link', uid)}
onDecline={() => props.api.invite.decline('/link', uid)}
/>
);
});
const associations = props.associations.contacts ? alphabetiseAssociations(props.associations.contacts) : {};
const groupedChannels = {};
[...props.listening].map((path) => {
const groupPath = props.associations.link[path] ?
props.associations.link[path]['group-path'] : '';
if (groupPath in associations) {
if (groupedChannels[groupPath]) {
const array = groupedChannels[groupPath];
array.push(path);
groupedChannels[groupPath] = array;
} else {
groupedChannels[groupPath] = [path];
}
} else {
if (groupedChannels['/~/']) {
const array = groupedChannels['/~/'];
array.push(path);
groupedChannels['/~/'] = array;
} else {
groupedChannels['/~/'] = [path];
};
}
});
let i = -1;
const groupedItems = Object.keys(associations)
.map((each) => {
const channels = groupedChannels[each];
if (!channels || channels.length === 0)
return;
i++;
if (groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) {
i++;
}
export const ChannelSidebar = (props) => {
const sidebarInvites = Object.keys(props.invites)
.map((uid) => {
return (
<GroupItem
key={i}
index={i}
association={associations[each]}
linkMetadata={props.associations['link']}
channels={channels}
selected={props.selected}
links={props.links}
<SidebarInvite
key={uid}
invite={props.invites[uid]}
onAccept={() => props.api.invite.accept('/link', uid)}
onDecline={() => props.api.invite.decline('/link', uid)}
/>
);
});
if (groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) {
groupedItems.unshift(
<GroupItem
key={'/~/'}
index={0}
association={'/~/'}
linkMetadata={props.associations['link']}
channels={groupedChannels['/~/']}
selected={props.selected}
links={props.links}
/>
);
}
const activeClasses = (props.active === 'collections') ? ' ' : 'dn-s ';
const associations = props.associations.contacts ?
alphabetiseAssociations(props.associations.contacts) : {};
let hiddenClasses = true;
const graphAssoc = props.associations.graph || {};
const groupedChannels = {};
[...props.graphKeys].map((gKey) => {
const path = `/ship/~${gKey.split('/')[0]}/${gKey.split('/')[1]}`;
const groupPath = graphAssoc[path] ? graphAssoc[path]['group-path'] : '';
if (groupPath in associations) {
// managed
if (groupedChannels[groupPath]) {
const array = groupedChannels[groupPath];
array.push(path);
groupedChannels[groupPath] = array;
} else {
groupedChannels[groupPath] = [path];
}
if (props.popout) {
hiddenClasses = false;
} else {
hiddenClasses = props.sidebarShown;
// unmanaged
if (groupedChannels['/~/']) {
const array = groupedChannels['/~/'];
array.push(path);
groupedChannels['/~/'] = array;
} else {
groupedChannels['/~/'] = [path];
}
}
});
const groupedItems = Object.keys(associations).map((each, i) => {
const channels = groupedChannels[each];
if (!channels || channels.length === 0) { return; }
return (
<div className={`bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100
flex-shrink-0 mw5-m mw5-l mw5-xl pt3 pt0-m pt0-l pt0-xl
relative ` + activeClasses + ((hiddenClasses)
? 'flex-basis-100-s flex-basis-30-ns'
: 'dn')}
>
<div className="overflow-y-scroll h-100">
<div className="w-100 bg-transparent">
<Link
className="dib f9 pointer green2 gray4-d pa4"
to={'/~link/new'}
>
New Collection
</Link>
</div>
<Welcome associations={props.associations} />
{sidebarInvites}
{groupedItems}
</div>
</div>
<GroupItem
key={i + 1}
unmanaged={false}
association={associations[each]}
metadata={graphAssoc}
channels={channels}
selected={props.selected}
/>
);
});
if (groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) {
groupedItems.push(
<GroupItem
key={0}
unmanaged={true}
association={'/~/'}
metadata={graphAssoc}
channels={groupedChannels['/~/']}
selected={props.selected}
/>
);
}
}
const activeClasses = (props.active === 'collections') ? ' ' : 'dn-s ';
const hiddenClasses = !!props.popout ? false : props.sidebarShown;
return (
<div className={
`bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100` +
`flex-shrink-0 mw5-m mw5-l mw5-xl pt3 pt0-m pt0-l pt0-xl relative ` +
activeClasses +
((hiddenClasses) ? 'flex-basis-100-s flex-basis-30-ns' : 'dn')
}>
<div className="overflow-y-scroll h-100">
<div className="w-100 bg-transparent">
<Link
className="dib f9 pointer green2 gray4-d pa4"
to={'/~link/new'}>
New Collection
</Link>
</div>
<Welcome associations={props.associations} />
{sidebarInvites}
{groupedItems}
</div>
</div>
);
};

View File

@ -5,66 +5,41 @@ import moment from 'moment';
import { Box, Text, Row } from '@tlon/indigo-react';
import RichText from '~/views/components/RichText';
export class CommentItem extends Component {
constructor(props) {
super(props);
this.state = {
timeSinceComment: this.getTimeSinceComment()
};
}
export const CommentItem = (props) => {
const content = props.post.contents[0].text;
const timeSent =
moment.unix(props.post['time-sent'] / 1000).format('hh:mm a');
componentDidMount() {
this.updateTimeSinceNewestMessageInterval = setInterval( () => {
this.setState({ timeSinceComment: this.getTimeSinceComment() });
}, 60000);
}
componentWillUnmount() {
if (this.updateTimeSinceNewestMessageInterval) {
clearInterval(this.updateTimeSinceNewestMessageInterval);
this.updateTimeSinceNewestMessageInterval = null;
}
}
getTimeSinceComment() {
return this.props.time ?
moment.unix(this.props.time / 1000).from(moment.utc())
: '';
}
render() {
const props = this.props;
const member = props.member || false;
const showAvatar = props.avatar && !props.hideAvatars;
const showNickname = props.nickname && !props.hideNicknames;
const img = showAvatar
? <img src={props.avatar} height={36} width={36} className="dib" />
: <Sigil
ship={'~' + props.ship}
const showAvatar = props.avatar && !props.hideAvatars;
const showNickname = props.nickname && !props.hideNicknames;
const img = showAvatar
? <img src={props.avatar} height={36} width={36} className="dib" />
: <Sigil
ship={`~${props.post.author}`}
size={36}
color={'#' + props.color}
classes={(member ? 'mix-blend-diff' : '')}
/>;
color={`#${props.color}`}
classes={(!!props.member ? 'mix-blend-diff' : '')}
/>;
return (
<Box width="100%" py={3} opacity={props.pending ? '0.6' : '1'}>
<Row backgroundColor='white'>
{img}
<Row fontSize={0} alignItems="center" ml={2}>
<Text mono={!props.hasNickname} title={props.ship}>
{showNickname ? props.nickname : cite(props.ship)}
</Text>
<Text gray ml={2}>
{this.state.timeSinceComment}
</Text>
</Row>
return (
<Box width="100%" py={3} opacity={props.pending ? '0.6' : '1'}>
<Row backgroundColor='white'>
{img}
<Row fontSize={0} alignItems="center" ml={2}>
<Text mono={!props.hasNickname} title={props.post.author}>
{showNickname ? props.nickname : cite(props.post.author)}
</Text>
<Text gray ml={2}>{timeSent}</Text>
</Row>
<Text display="block" py={3} fontSize={1}><RichText remoteContentPolicy={props.remoteContentPolicy}>{props.content}</RichText></Text>
</Box>
);
}
</Row>
<Row>
<Text display="block" py={3} fontSize={1}>
<RichText remoteContentPolicy={props.remoteContentPolicy}>
{content}
</RichText>
</Text>
</Row>
</Box>
);
}
export default CommentItem;

View File

@ -0,0 +1,83 @@
import React, { Component } from 'react';
import { Spinner } from '~/views/components/Spinner';
import { createPost } from '~/logic/api/graph';
export class CommentSubmit extends Component {
constructor(props) {
super(props);
this.state = {
comment: '',
commentFocus: false,
disabled: false
};
}
onClickPost() {
const parentIndex = this.props.parentIndex || '';
let post = createPost([
{ text: this.state.comment },
], parentIndex);
this.setState({ disabled: true }, () => {
this.props.api.graph.addPost(
`~${this.props.ship}`,
this.props.name,
post
).then((r) => {
this.setState({
disabled: false,
comment: ''
});
});
});
}
setComment(event) {
this.setState({ comment: event.target.value });
}
render() {
const { state, props } = this;
const focus = (state.commentFocus)
? 'b--black b--white-d'
: 'b--gray4 b--gray2-d';
const activeClasses = state.comment
? 'black white-d pointer'
: 'gray2 b--gray2';
return (
<div className={'w-100 relative ba br1 mt6 mb6 ' + focus}>
<textarea
className="w-100 bg-gray0-d white-d f8 pa2 pr8"
style={{ resize: 'none', height: 75 }}
placeholder="Leave a comment on this link"
onChange={this.setComment.bind(this)}
onKeyDown={(e) => {
if (
(e.getModifierState('Control') || e.metaKey) &&
e.key === 'Enter'
) {
this.onClickPost();
}
}}
onFocus={() => this.setState({ commentFocus: true })}
onBlur={() => this.setState({ commentFocus: false })}
value={state.comment}
/>
<button
className={
'f8 bg-gray0-d ml2 absolute ' + activeClasses
}
disabled={state.disabled}
onClick={this.onClickPost.bind(this)}
style={{ bottom: 12, right: 8 }}>
Post
</button>
</div>
);
}
}

View File

@ -1,39 +0,0 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { makeRoutePath } from '~/logic/lib/util';
export class CommentsPagination extends Component {
render() {
const props = this.props;
const prevPage = (Number(props.commentPage) - 1);
const nextPage = (Number(props.commentPage) + 1);
const prevDisplay = ((Number(props.commentPage) > 0))
? 'dib'
: 'dn';
const nextDisplay = ((Number(props.commentPage) + 1) < Number(props.total))
? 'dib'
: 'dn';
return (
<div className="w-100 relative pt4 pb6">
<Link
className={'pb6 absolute inter f8 left-0 ' + prevDisplay}
to={makeRoutePath(props.resourcePath, props.popout, props.linkPage, props.url, props.linkIndex, prevPage)}
>
&#60;- Previous Page
</Link>
<Link
className={'pb6 absolute inter f8 right-0 ' + nextDisplay}
to={makeRoutePath(props.resourcePath, props.popout, props.linkPage, props.url, props.linkIndex, nextPage)}
>
Next Page -&gt;
</Link>
</div>
);
}
}
export default CommentsPagination;

View File

@ -1,93 +1,40 @@
import React, { Component } from 'react';
import React from 'react';
import { CommentItem } from './comment-item';
import { CommentsPagination } from './comments-pagination';
import { getContactDetails } from '~/logic/lib/util';
export class Comments extends Component {
constructor(props) {
super(props);
this.state = {};
}
componentDidMount() {
const page = this.props.commentPage;
if (!this.props.comments ||
!this.props.comments[page] ||
this.props.comments.local[page]
) {
this.setState({ requested: this.props.commentPage });
this.props.api.links.getCommentsPage(
this.props.resourcePath,
this.props.url,
this.props.commentPage);
}
}
render() {
const props = this.props;
export const Comments = (props) => {
const {
hideNicknames,
hideAvatars
} = props;
const page = props.commentPage;
const contacts = props.contacts ? props.contacts : {};
const commentsObj = props.comments
? props.comments
: {};
return (
<div>
{ Array.from(props.comments.values()).map((comment) => {
const { nickname, color, member, avatar } =
getContactDetails(contacts[comment.post.author]);
const commentsPage = commentsObj[page]
? commentsObj[page]
: {};
const nameClass = nickname && !hideNicknames ? 'inter' : 'mono';
const total = props.comments
? props.comments.totalPages
: 1;
const { hideNicknames, hideAvatars, remoteContentPolicy } = props;
const commentsList = Object.keys(commentsPage)
.map((entry) => {
const commentObj = commentsPage[entry];
const { ship, time, udon } = commentObj;
const contacts = props.contacts
? props.contacts
: {};
const { nickname, color, member, avatar } = getContactDetails(contacts[ship]);
const nameClass = nickname && !hideNicknames ? 'inter' : 'mono';
return(
<CommentItem
key={time}
ship={ship}
time={time}
content={udon}
nickname={nickname}
hasNickname={Boolean(nickname)}
color={color}
avatar={avatar}
member={member}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
remoteContentPolicy={remoteContentPolicy}
/>
);
});
return (
<div>
{commentsList}
<CommentsPagination
key={props.resourcePath + props.commentPage}
resourcePath={props.resourcePath}
popout={props.popout}
linkPage={props.linkPage}
linkIndex={props.linkIndex}
url={props.url}
commentPage={props.commentPage}
total={total}
/>
</div>
);
}
return (
<CommentItem
key={comment.post.index}
post={comment.post}
nickname={nickname}
hasNickname={Boolean(nickname)}
color={color}
avatar={avatar}
member={member}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
/>
);
})
}
</div>
);
}
export default Comments;

View File

@ -1,46 +1,43 @@
import React, { Component } from 'react';
import { ChannelsItem } from './channels-item';
import { ChannelItem } from './channel-item';
import { deSig } from '~/logic/lib/util';
export class GroupItem extends Component {
render() {
const { props } = this;
const association = props.association ? props.association : {};
let title = association['app-path'] ? association['app-path'] : 'Unmanaged Collections';
export const GroupItem = (props) => {
const association = props.association ? props.association : {};
if (association.metadata && association.metadata.title) {
title = association.metadata.title !== ''
? association.metadata.title : title;
}
let title =
association['app-path'] ? association['app-path'] : 'Unmanaged Collections';
const channels = props.channels ? props.channels : [];
const first = (props.index === 0) ? 'pt1' : 'pt6';
const channelItems = channels.map((each, i) => {
const meta = props.linkMetadata[each];
if (!meta)
return null;
const selected = (props.selected === each);
const unseenCount = props.links[each]
? props.links[each].unseenCount
: 0;
return (
<ChannelsItem
key={each}
link={each}
selected={selected}
unseenCount={unseenCount}
name={meta.metadata.title}
/>
);
});
return (
<div className={first}>
<p className="f9 ph4 pb2 gray3">{title}</p>
{channelItems}
</div>
);
if (association.metadata && association.metadata.title) {
title = association.metadata.title !== ''
? association.metadata.title : title;
}
}
export default GroupItem;
const channels = props.channels ? props.channels : [];
const unmanaged = props.unmanaged ? 'pt6' : 'pt1';
const channelItems = channels.map((each, i) => {
const meta = props.metadata[each];
if (!meta) { return null; }
const link = `${deSig(each.split('/')[2])}/${each.split('/')[3]}`;
const selected = (props.selected === each);
return (
<ChannelItem
key={each}
link={link}
selected={selected}
name={meta.metadata.title}
/>
);
});
return (
<div className={unmanaged}>
<p className="f9 ph4 pb2 gray3">{title}</p>
{channelItems}
</div>
);
};

View File

@ -1,106 +0,0 @@
import React, { Component } from 'react';
import { cite } from '~/logic/lib/util';
import moment from 'moment';
import RemoteContent from "~/views/components/RemoteContent";
export class LinkPreview extends Component {
constructor(props) {
super(props);
this.state = {
timeSinceLinkPost: this.getTimeSinceLinkPost(),
embed: ''
};
}
componentDidUpdate(prevProps) {
if (prevProps !== this.props) {
if (this.state.timeSinceLinkPost === '') {
this.setState({
timeSinceLinkPost: this.getTimeSinceLinkPost()
});
}
}
}
componentDidMount() {
this.updateTimeSinceNewestMessageInterval = setInterval(() => {
this.setState({
timeSinceLinkPost: this.getTimeSinceLinkPost()
});
}, 60000);
}
componentWillUnmount() {
if (this.updateTimeSinceNewestMessageInterval) {
clearInterval(this.updateTimeSinceNewestMessageInterval);
this.updateTimeSinceNewestMessageInterval = null;
}
}
getTimeSinceLinkPost() {
const time = this.props.time;
return time
? moment.unix(time / 1000).from(moment.utc())
: '';
}
render() {
const { props } = this;
const embed = (
<RemoteContent
unfold={true}
renderUrl={false}
url={props.url}
remoteContentPolicy={props.remoteContentPolicy}
className="mw-100"
/>
);
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);
let hostname = URLparser.exec(props.url);
if (hostname) {
hostname = hostname[4];
}
const showNickname = props.nickname && !props.hideNicknames;
const nameClass = showNickname ? 'inter' : 'mono';
return (
<div className="pb6 w-100">
<div
className={'w-100 tc'}
>
{embed}
</div>
<div className="flex flex-column ml2 pt6 flex-auto">
<a href={props.url} className="w-100 flex" target="_blank" rel="noopener noreferrer">
<p className="f8 truncate">
{props.title}
</p>
<span className="gray2 ml2 f8 dib v-btm flex-shrink-0">{hostname} </span>
</a>
<div className="w-100 pt1">
<span className={'f9 pr2 white-d dib ' + nameClass}
title={props.ship}
>
{showNickname ? props.nickname : cite(props.ship)}
</span>
<span className="f9 inter gray2 pr3 dib">
{this.state.timeSinceLinkPost}
</span>
<span className="f9 inter gray2 dib">{props.comments}</span>
</div>
</div>
</div>
);
}
}
export default LinkPreview;

View File

@ -1,115 +1,55 @@
import React, { Component } from 'react';
import moment from 'moment';
import { Sigil } from '~/logic/lib/sigil';
import { Link } from 'react-router-dom';
import { makeRoutePath, cite } from '~/logic/lib/util';
import { cite } from '~/logic/lib/util';
export class LinkItem extends Component {
constructor(props) {
super(props);
this.state = {
timeSinceLinkPost: this.getTimeSinceLinkPost()
};
this.markPostAsSeen = this.markPostAsSeen.bind(this);
}
export const LinkItem = (props) => {
const {
node,
nickname,
resource,
hideAvatars,
hideNicknames
} = props;
componentDidMount() {
this.updateTimeSinceNewestMessageInterval = setInterval( () => {
this.setState({ timeSinceLinkPost: this.getTimeSinceLinkPost() });
}, 60000);
}
const author = node.post.author;
const index = node.post.index.split('/').join('-');
const size = node.children ? node.children.size : 0;
const contents = node.post.contents;
componentWillUnmount() {
if (this.updateTimeSinceNewestMessageInterval) {
clearInterval(this.updateTimeSinceNewestMessageInterval);
this.updateTimeSinceNewestMessageInterval = null;
}
}
const showAvatar = props.avatar && !hideAvatars;
const showNickname = nickname && !hideNicknames;
getTimeSinceLinkPost() {
return this.props.timestamp ?
moment.unix(this.props.timestamp / 1000).from(moment.utc())
: '';
}
const mono = showNickname ? 'inter white-d' : 'mono white-d';
markPostAsSeen() {
this.props.api.links.seenLink(this.props.resourcePath, this.props.url);
}
const img = showAvatar
? <img src={props.avatar} height={38} width={38} className="dib" />
: <Sigil ship={`~${author}`} size={38} color={'#' + props.color} />;
render() {
const props = this.props;
const URLparser = new RegExp(/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/);
let hostname = URLparser.exec(props.url);
const seenState = props.seen
? 'gray2'
: 'green2 pointer';
if (hostname) {
hostname = hostname[4];
}
const comments = props.comments + ' comment' + ((props.comments === 1) ? '' : 's');
const member = this.props.member || false;
const showAvatar = props.avatar && !props.hideAvatars;
const showNickname = props.nickname && !props.hideNicknames;
const mono = showNickname ? 'inter white-d' : 'mono white-d';
const img = showAvatar
? <img src={this.props.avatar} height={38} width={38} className="dib" />
: <Sigil
ship={'~' + props.ship}
size={38}
color={'#' + props.color}
classes={(member ? 'mix-blend-diff' : '')}
/>;
return (
<div className="w-100 pv3 flex bg-white bg-gray0-d lh-solid">
return (
<div className="w-100 pv3 flex bg-white bg-gray0-d lh-solid">
{img}
<div className="flex flex-column ml2 flex-auto">
<a href={props.url}
className="w-100 flex"
target="_blank"
rel="noopener noreferrer"
onClick={this.markPostAsSeen}
>
<p className="f8 truncate">{props.title}
</p>
<span className="gray2 dib v-btm ml2 f8 flex-shrink-0">{hostname} </span>
</a>
<div className="w-100">
<span className={'f9 pr2 dib ' + mono}
title={props.ship}
>
{showNickname
? props.nickname
: cite(props.ship)}
</span>
<span
className={seenState + ' f9 inter pr3 dib'}
onClick={this.markPostAsSeen}
>
{this.state.timeSinceLinkPost}
<div className="flex flex-column ml2 flex-auto">
<a href={contents[1].url}
className="w-100 flex"
target="_blank"
rel="noopener noreferrer">
<p className="f8 truncate">{props.title}</p>
<span className="gray2 dib v-btm ml2 f8 flex-shrink-0">
{contents[0].text}
</span>
<Link to=
{makeRoutePath(props.resourcePath, props.popout, props.page, props.url, props.linkIndex)}
onClick={this.markPostAsSeen}
>
<span className="f9 inter gray2 dib">
{comments}
</span>
</Link>
</div>
</a>
<div className="w-100">
<span className={'f9 pr2 pl2 dib ' + mono} title={author}>
{ showNickname ? nickname : cite(author) }
</span>
<Link to={`/~link/${resource}/${index}`}>
<span className="f9 inter gray2 dib">{size} comments</span>
</Link>
</div>
</div>
);
}
</div>
);
}
export default LinkItem;

View File

@ -0,0 +1,42 @@
import React from 'react';
import { cite } from '~/logic/lib/util';
import moment from 'moment';
export const LinkPreview = (props) => {
const showNickname = props.nickname && !props.hideNicknames;
const nameClass = showNickname ? 'inter' : 'mono';
const author = props.post.author;
const timeSent =
moment.unix(props.post['time-sent'] / 1000).format('hh:mm a');
const title = props.post.contents[0].text;
const url = props.post.contents[1].url;
return (
<div className="pb6 w-100">
<div className="flex flex-column ml2 pt6 flex-auto">
<a href={url}
className="w-100 flex"
target="_blank"
rel="noopener noreferrer">
<p className="f8 truncate">{title}</p>
<span className="gray2 ml2 f8 dib v-btm flex-shrink-0">
{url}
</span>
</a>
<div className="w-100 pt1">
<span className={'f9 pr2 white-d dib ' + nameClass} title={author}>
{showNickname ? props.nickname : cite(`~${author}`)}
</span>
<span className="f9 inter gray2 pr3 dib">{timeSent}</span>
<span className="f9 inter gray2 dib">
{props.commentNumber} comments
</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,126 @@
import React, { Component } from 'react';
import { Spinner } from '~/views/components/Spinner';
import { createPost } from '~/logic/api/graph';
export class LinkSubmit extends Component {
constructor() {
super();
this.state = {
linkValue: '',
linkTitle: '',
submitFocus: false,
disabled: false
};
this.setLinkValue = this.setLinkValue.bind(this);
this.setLinkTitle = this.setLinkTitle.bind(this);
}
onClickPost() {
const link = this.state.linkValue;
const title = this.state.linkTitle
? this.state.linkTitle
: this.state.linkValue;
const parentIndex = this.props.parentIndex || '';
let post = createPost([
{ text: title },
{ url: link }
], parentIndex);
this.setState({ disabled: true }, () => {
this.props.api.graph.addPost(
`~${this.props.ship}`,
this.props.name,
post
).then((r) => {
this.setState({
disabled: false,
linkValue: '',
linkTitle: '',
});
});
});
}
setLinkValue(event) {
this.setState({ linkValue: event.target.value });
}
setLinkTitle(event) {
this.setState({ linkTitle: event.target.value });
}
render() {
const activeClasses = (!this.state.disabled) ? 'green2 pointer' : 'gray2';
const focus = (this.state.submitFocus)
? 'b--black b--white-d'
: 'b--gray4 b--gray2-d';
return (
<div className={'relative ba br1 w-100 mb6 ' + focus}>
<textarea
className="pl2 bg-gray0-d white-d w-100 f8"
style={{
resize: 'none',
height: 40,
paddingTop: 10
}}
placeholder="Paste link here"
onChange={this.setLinkValue}
onBlur={() => this.setState({ submitFocus: false })}
onFocus={() => this.setState({ submitFocus: true })}
spellCheck="false"
rows={1}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkValue}
/>
<textarea
className="pl2 bg-gray0-d white-d w-100 f8"
style={{
resize: 'none',
height: 40,
paddingTop: 16
}}
placeholder="Enter title"
onChange={this.setLinkTitle}
onBlur={() => this.setState({ submitFocus: false })}
onFocus={() => this.setState({ submitFocus: true })}
spellCheck="false"
rows={1}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkTitle}
/>
<button
className={
'absolute bg-gray0-d f8 ml2 flex-shrink-0 ' + activeClasses
}
disabled={this.state.disabled}
onClick={this.onClickPost.bind(this)}
style={{
bottom: 12,
right: 8
}}
>
Post
</button>
<Spinner
awaiting={this.state.disabled}
classes="mt3 absolute right-0"
text="Posting to collection..." />
</div>
);
}
}

View File

@ -1,255 +0,0 @@
import React, { Component } from 'react';
import { hasProvider } from 'oembed-parser';
import { S3Upload, SubmitDragger } from '~/views/components/s3-upload';
import { Spinner } from '~/views/components/Spinner';
import { Icon } from "@tlon/indigo-react";
import GlobalApi from '~/logic/api/global';
import { S3State } from '~/types';
interface LinkSubmitProps {
api: GlobalApi;
resourcePath: string;
s3: S3State;
}
interface LinkSubmitState {
linkValue: string;
linkTitle: string;
linkValid: boolean;
submitFocus: boolean;
urlFocus: boolean;
disabled: boolean;
dragover: boolean;
}
export class LinkSubmit extends Component<LinkSubmitProps, LinkSubmitState> {
private s3Uploader: React.RefObject<S3Upload>;
constructor(props) {
super(props);
this.state = {
linkValue: '',
linkTitle: '',
linkValid: false,
submitFocus: false,
urlFocus: false,
disabled: false,
dragover: false
};
this.setLinkValue = this.setLinkValue.bind(this);
this.setLinkTitle = this.setLinkTitle.bind(this);
this.onDragEnter = this.onDragEnter.bind(this);
this.onDrop = this.onDrop.bind(this);
this.onPaste = this.onPaste.bind(this);
this.uploadFiles = this.uploadFiles.bind(this);
this.s3Uploader = React.createRef();
}
onClickPost() {
const link = this.state.linkValue;
const title = this.state.linkTitle
? this.state.linkTitle
: this.state.linkValue;
this.setState({ disabled: true });
this.props.api.links.postLink(this.props.resourcePath, link, title).then((r) => {
this.setState({
disabled: false,
linkValue: '',
linkTitle: '',
linkValid: false
});
});
}
setLinkValid(linkValue) {
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);;
let linkValid = URLparser.test(linkValue);
if (!linkValid) {
linkValid = URLparser.test(`http://${linkValue}`);
if (linkValid) {
linkValue = `http://${linkValue}`;
}
}
this.setState({ linkValid, linkValue });
if (linkValid) {
if (hasProvider(linkValue)) {
fetch(`https://noembed.com/embed?url=${linkValue}`)
.then(response => response.json())
.then((result) => {
if (result.title) {
this.setState({ linkTitle: result.title });
}
}).catch((error) => {/*noop*/});
} else {
this.setState({
linkTitle: decodeURIComponent(linkValue
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
.replace('_', ' ')
.replace(/\d{4}\.\d{1,2}\.\d{2}\.\.\d{2}\.\d{2}\.\d{2}-/, '')
)
})
}
}
}
setLinkValue(event) {
this.setState({ linkValue: event.target.value });
this.setLinkValid(event.target.value);
}
setLinkTitle(event) {
this.setState({ linkTitle: event.target.value });
}
uploadSuccess(url) {
this.setState({ linkValue: url });
this.setLinkValid(url);
}
uploadError(error) {
// no-op for now
}
readyToUpload(): boolean {
return Boolean(this.s3Uploader.current && this.s3Uploader.current.inputRef.current);
}
onDragEnter() {
if (!this.readyToUpload()) {
return;
}
this.setState({ dragover: true });
}
onDrop(event: DragEvent) {
this.setState({ dragover: false });
if (!event.dataTransfer || !event.dataTransfer.files.length) {
return;
}
event.preventDefault();
this.uploadFiles(event.dataTransfer.files);
}
onPaste(event: ClipboardEvent) {
if (!event.clipboardData || !event.clipboardData.files.length) {
return;
}
event.preventDefault();
event.stopPropagation();
this.uploadFiles(event.clipboardData.files);
}
uploadFiles(files: FileList) {
if (!this.readyToUpload()) {
return;
}
this.s3Uploader.current.inputRef.current.files = files;
const fire = document.createEvent("HTMLEvents");
fire.initEvent("change", true, true);
this.s3Uploader.current?.inputRef.current?.dispatchEvent(fire);
}
render() {
const activeClasses = (this.state.linkValid && !this.state.disabled)
? 'green2 pointer' : 'gray2';
const focus = (this.state.submitFocus)
? 'b--black b--white-d'
: 'b--gray4 b--gray2-d';
return (
<div
className={`relative ba br1 w-100 mb6 ${focus}`}
onDragEnter={this.onDragEnter.bind(this)}
onDragOver={e => {e.preventDefault();this.setState({ dragover: true})}}
onDragLeave={() => this.setState({ dragover: false })}
onDrop={this.onDrop}
>
{this.state.dragover ? <SubmitDragger /> : null}
<div className="relative">
{(this.state.linkValue || this.state.urlFocus || this.state.disabled) ? null : <span className="gray2 absolute pl2 pt3 pb2 f8" style={{pointerEvents: 'none'}}>
Drop or <span className="pointer green2" style={{pointerEvents: 'all'}} onClick={(event) => {
if (!this.readyToUpload()) {
return;
}
this.s3Uploader.current.inputRef.current.click();
}}>upload</span> a file, or paste a link here
</span>}
{!this.state.disabled ? <S3Upload
ref={this.s3Uploader}
configuration={this.props.s3.configuration}
credentials={this.props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
className="dn absolute pt3 pb2 pl2 w-100"
></S3Upload> : null}
<input
type="url"
className="pl2 w-100 f8 pt3 pb2 white-d bg-transparent"
onChange={this.setLinkValue}
onBlur={() => this.setState({ submitFocus: false, urlFocus: false })}
onFocus={() => this.setState({ submitFocus: true, urlFocus: true })}
spellCheck="false"
onPaste={this.onPaste}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkValue}
/>
</div>
<input
type="text"
className="pl2 bg-transparent w-100 f8 white-d"
style={{
resize: 'none',
height: 40
}}
placeholder="Provide a title"
onChange={this.setLinkTitle}
onBlur={() => this.setState({ submitFocus: false })}
onFocus={() => this.setState({ submitFocus: true })}
spellCheck="false"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickPost();
}
}}
value={this.state.linkTitle}
/>
{!this.state.disabled ? <button
className={
'bg-transparent f8 flex-shrink-0 pr2 pl2 pt2 pb3 ' + activeClasses
}
disabled={!this.state.linkValid || this.state.disabled}
onClick={this.onClickPost.bind(this)}
style={{
bottom: 12,
right: 8
}}
>
Post link
</button> : null}
<Spinner awaiting={this.state.disabled} classes="nowrap flex items-center pr2 pl2 pt2 pb4" style={{flex: '1 1 14rem'}} text="Posting to collection..." />
</div>
) ;
}
}
export default LinkSubmit;

View File

@ -0,0 +1,78 @@
import React, { Component } from 'react';
import { TabBar } from '~/views/components/chat-link-tabbar';
import { LinkPreview } from './lib/link-preview';
import { LinkSubmit } from './lib/link-submit';
import { CommentSubmit } from './lib/comment-submit';
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
import { Link } from 'react-router-dom';
import { Comments } from './lib/comments';
import { getContactDetails } from '~/logic/lib/util';
export const LinkDetail = (props) => {
if (!props.node) {
// TODO: something
return (
<div>
Not found
</div>
);
}
const { nickname } = getContactDetails(props.contacts[ship]);
const our = getContactDetails(props.contacts[window.ship]);
const resourcePath = `${props.ship}/${props.name}`;
const title = props.resource.metadata.title || resourcePath;
return (
<div className="h-100 w-100 overflow-hidden flex flex-column">
<div
className={
'pl4 pt2 flex relative overflow-x-scroll ' +
'overflow-x-auto-l overflow-x-auto-xl flex-shrink-0 ' +
'bb bn-m bn-l bn-xl b--gray4'
}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={props.api}
/>
<Link className="dib f9 fw4 pt2 gray2 lh-solid" to="/~link">
{`<- ${title}`}
</Link>
<TabBar
location={props.location}
popout={props.popout}
popoutHref={`/~link/popout/${resourcePath}/${props.match.params.index}`}
settings={`/~link/${resourcePath}/settings`}
/>
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<LinkPreview
resourcePath={resourcePath}
post={props.node.post}
nickname={nickname}
hideNicknames={props.hideNicknames}
commentNumber={props.node.children.size} />
<div className="flex">
<CommentSubmit
name={props.name}
ship={props.ship}
api={props.api}
parentIndex={props.node.post.index} />
</div>
<Comments
comments={props.node.children}
resourcePath={resourcePath}
contacts={props.contacts}
popout={props.popout}
api={props.api}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import React, { Component } from 'react';
import { TabBar } from '~/views/components/chat-link-tabbar';
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
import { Link } from 'react-router-dom';
import { LinkItem } from './lib/link-item';
import { LinkSubmit } from './lib/link-submit';
import { getContactDetails } from '~/logic/lib/util';
export const LinkList = (props) => {
const resource = `${props.ship}/${props.name}`;
const title = props.metadata.title || resource;
if (!props.graph) {
return (
<div>Not found</div>
);
}
return (
<div className="h-100 w-100 overflow-hidden flex flex-column">
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}>
<Link to="/~link">{'⟵ All Channels'}</Link>
</div>
<div className={
'pl4 pt2 flex relative overflow-x-scroll' +
'overflow-x-auto-l overflow-x-auto-xl flex-shrink-0' +
'bb b--gray4 b--gray1-d bg-gray0-d'
}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={props.api} />
<h2 className='white-d dib f9 fw4 lh-solid v-top pt2'>{title}</h2>
<TabBar
location={props.location}
popout={props.popout}
popoutHref={`/~link/popout/${resource}`}
settings={`/~link/${resource}/settings`}
/>
</div>
<div className="w-100 mt6 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="flex">
<LinkSubmit
name={props.name}
ship={props.ship}
api={props.api} />
</div>
{ Array.from(props.graph.values()).map((node) => {
return (
<LinkItem
resource={resource}
node={node}
nickname={props.metadata.nickname}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
/>
);
})
}
</div>
</div>
</div>
);
}

View File

@ -1,234 +0,0 @@
import React, { Component } from 'react';
import { TabBar } from '~/views/components/chat-link-tabbar';
import { LinkPreview } from './lib/link-detail-preview';
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
import { Link } from 'react-router-dom';
import { Comments } from './lib/comments';
import { Spinner } from '~/views/components/Spinner';
import { LoadingScreen } from './loading';
import { makeRoutePath, getContactDetails } from '~/logic/lib/util';
import CommentItem from './lib/comment-item';
export class LinkDetail extends Component {
constructor(props) {
super(props);
this.state = {
comment: '',
data: props.data,
commentFocus: false,
pending: new Set(),
disabled: false
};
this.setComment = this.setComment.bind(this);
}
updateData(submission) {
this.setState({
data: submission
});
}
componentDidMount() {
this.componentDidUpdate();
}
componentDidUpdate(prevProps) {
// if we have no preloaded data, and we aren't expecting it, get it
if ((!this.state.data.title) && (this.props.api)) {
this.props.api?.links.getSubmission(
this.props.resourcePath, this.props.url, this.updateData.bind(this)
);
}
if (prevProps) {
if (this.props.url !== prevProps.url) {
this.updateData(this.props.data);
}
if (prevProps.comments && prevProps.comments['0'] &&
this.props.comments && this.props.comments['0']) {
const prevFirstComment = prevProps.comments['0'][0];
const thisFirstComment = this.props.comments['0'][0];
if ((prevFirstComment && prevFirstComment.udon) &&
(thisFirstComment && thisFirstComment.udon)) {
if (this.state.pending.has(thisFirstComment.udon)) {
const pending = this.state.pending;
pending.delete(thisFirstComment.udon);
this.setState({
pending: pending
});
}
}
}
}
}
onClickPost() {
const url = this.props.url || '';
const pending = this.state.pending;
pending.add(this.state.comment);
this.setState({ pending: pending, disabled: true });
this.props.api.links.postComment(
this.props.resourcePath,
url,
this.state.comment
).then(() => {
this.setState({ comment: '', disabled: false });
});
}
setComment(event) {
this.setState({ comment: event.target.value });
}
render() {
const props = this.props;
const data = this.state.data || props.data;
if (!data.ship) {
return <LoadingScreen />;
}
const ship = data.ship || 'zod';
const title = data.title || '';
const url = data.url || '';
const commentCount = props.comments
? props.comments.totalItems
: data.commentCount || 0;
const comments = commentCount + ' comment' + (commentCount === 1 ? '' : 's');
const { nickname } = getContactDetails(props.contacts[ship]);
const activeClasses = this.state.comment
? 'black white-d pointer'
: 'gray2 b--gray2';
const focus = (this.state.commentFocus)
? 'b--black b--white-d'
: 'b--gray4 b--gray2-d';
const our = getContactDetails(props.contacts[window.ship]);
const pendingArray = Array.from(this.state.pending).map((com, i) => {
return(
<CommentItem
key={i}
color={our.color}
nickname={our.nickname}
ship={window.ship}
pending={true}
content={com}
member={our.member}
remoteContentPolicy={props.remoteContentPolicy}
time={new Date().getTime()}
/>
);
});
return (
<div className="h-100 w-100 overflow-hidden flex flex-column">
<div
className={'pl4 pt2 flex relative overflow-x-scroll ' +
'overflow-x-auto-l overflow-x-auto-xl flex-shrink-0 ' +
'bb bn-m bn-l bn-xl b--gray4'}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={this.props.api}
/>
<Link
className="dib f9 fw4 pt2 gray2 lh-solid"
to={makeRoutePath(props.resourcePath, props.popout, props.page)}
>
{`<- ${props.resource.metadata.title}`}
</Link>
<TabBar
location={props.location}
popout={props.popout}
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
/>
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<LinkPreview
title={title}
url={url}
comments={comments}
nickname={nickname}
ship={ship}
resourcePath={props.resourcePath}
page={props.page}
linkIndex={props.linkIndex}
time={this.state.data.time}
hideNicknames={props.hideNicknames}
remoteContentPolicy={props.remoteContentPolicy}
/>
<div className="relative">
<div className={'relative ba br1 mt6 mb6 ' + focus}>
<textarea
className="w-100 bg-gray0-d white-d f8 pa2 pr8"
style={{
resize: 'none',
height: 75
}}
placeholder="Leave a comment on this link"
onChange={this.setComment}
onKeyDown={(e) => {
if (
(e.getModifierState('Control') || e.metaKey) &&
e.key === 'Enter'
) {
this.onClickPost();
}
}}
onFocus={() => this.setState({ commentFocus: true })}
onBlur={() => this.setState({ commentFocus: false })}
value={this.state.comment}
/>
<button
className={
'f8 bg-gray0-d ml2 absolute ' + activeClasses
}
disabled={!this.state.comment || this.state.disabled}
onClick={this.onClickPost.bind(this)}
style={{
bottom: 12,
right: 8
}}
>
Post
</button>
</div>
<Spinner awaiting={this.state.disabled} classes="absolute pt5 right-0" text="Posting comment..." />
{pendingArray}
</div>
<Comments
resourcePath={props.resourcePath}
key={props.resourcePath + props.commentPage}
comments={props.comments}
commentPage={props.commentPage}
contacts={props.contacts}
popout={props.popout}
url={props.url}
linkPage={props.page}
linkIndex={props.linkIndex}
api={props.api}
hideAvatars={props.hideAvatars}
hideNicknames={props.hideNicknames}
remoteContentPolicy={props.remoteContentPolicy}
/>
</div>
</div>
</div>
);
}
}
export default LinkDetail;

View File

@ -1,162 +0,0 @@
import React, { Component } from 'react';
import { LoadingScreen } from './loading';
import { MessageScreen } from './lib/message-screen';
import { TabBar } from '~/views/components/chat-link-tabbar';
import { SidebarSwitcher } from '~/views/components/SidebarSwitch';
import { Link } from 'react-router-dom';
import { LinkItem } from './lib/link-item';
import { LinkSubmit } from './lib/link-submit';
import { Pagination } from './lib/pagination';
import { makeRoutePath, getContactDetails } from '~/logic/lib/util';
export class Links extends Component {
constructor(props) {
super(props);
}
componentDidMount() {
this.componentDidUpdate();
}
componentDidUpdate(prevProps) {
const linkPage = this.props.page;
// if we just navigated to this particular page,
// and don't have links for it yet,
// or the links we have might not be complete,
// request the links for that page.
if ( ((!prevProps || // first load?
linkPage !== prevProps.page || // already waiting on response?
this.props.resourcePath !== prevProps.resourcePath // new page?
) ||
(prevProps.api !== this.props.api)) // api prop instantiated?
&&
!this.props.links[linkPage] || // don't have info?
this.props.links.local[linkPage] // waiting on post confirmation?
) {
this.props.api?.links.getPage(this.props.resourcePath, this.props.page);
}
}
render() {
const props = this.props;
if (!props.resource.metadata.title) {
return <LoadingScreen />;
}
const linkPage = props.page;
const links = props.links[linkPage]
? props.links[linkPage]
: {};
const currentPage = props.page
? Number(props.page)
: 0;
const totalPages = props.links
? Number(props.links.totalPages)
: 1;
let LinkList = (<LoadingScreen />);
if (props.links && props.links.totalItems === 0) {
LinkList = (
<MessageScreen text="Start by posting a link to this collection." />
);
} else if (Object.keys(links).length > 0) {
LinkList = Object.keys(links)
.map((linkIndex) => {
const linksObj = props.links[linkPage];
const { title, url, time, ship } = linksObj[linkIndex];
const seen = props.seen[url];
const commentCount = props.comments[url]
? props.comments[url].totalItems
: linksObj[linkIndex].commentCount || 0;
const { nickname, color, member, avatar } = getContactDetails(props.contacts[ship]);
return (
<LinkItem
key={time}
title={title}
page={props.page}
linkIndex={linkIndex}
url={url}
timestamp={time}
seen={seen}
nickname={nickname}
ship={ship}
color={color}
avatar={avatar}
member={member}
comments={commentCount}
resourcePath={props.resourcePath}
popout={props.popout}
api={props.api}
hideNicknames={props.hideNicknames}
hideAvatars={props.hideAvatars}
/>
);
});
}
return (
<div
className="h-100 w-100 overflow-hidden flex flex-column"
>
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}
>
<Link to="/~link">{'⟵ All Channels'}</Link>
</div>
<div
className={`pl4 pt2 flex relative overflow-x-scroll
overflow-x-auto-l overflow-x-auto-xl flex-shrink-0
bb b--gray4 b--gray1-d bg-gray0-d`}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={this.props.api}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout, props.page)} className="pt2">
<h2 className={'dib f9 fw4 lh-solid v-top'}>
{props.resource.metadata.title}
</h2>
</Link>
<TabBar
location={props.location}
popout={props.popout}
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
/>
</div>
<div className="w-100 mt6 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="flex">
<LinkSubmit resourcePath={props.resourcePath} api={this.props.api} s3={props.s3} />
</div>
<div className="pb4">
{LinkList}
<Pagination
{...props}
key={props.resourcePath + props.page}
popout={props.popout}
resourcePath={props.resourcePath}
currentPage={currentPage}
totalPages={totalPages}
/>
</div>
</div>
</div>
</div>
);
}
}
export default Links;

View File

@ -1,220 +0,0 @@
import React, { Component } from 'react';
import urbitOb from 'urbit-ob';
import { Link } from 'react-router-dom';
import { InviteSearch } from '~/views/components/InviteSearch';
import { Spinner } from '~/views/components/Spinner';
import { makeRoutePath, deSig } from '~/logic/lib/util';
export class NewScreen extends Component {
constructor(props) {
super(props);
this.state = {
title: '',
description: '',
idName: '',
groups: [],
ships: [],
idError: false,
inviteError: false,
createGroup: false,
disabled: false
};
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.setInvite = this.setInvite.bind(this);
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (prevProps !== props) {
const target = `/${state.idName}`;
if (target in props.associations) {
props.history.push(makeRoutePath(target));
}
}
}
titleChange(event) {
const asciiSafe = event.target.value.toLowerCase()
.replace(/[^a-z0-9-]/g, '-');
this.setState({
idName: asciiSafe + '-' + Math.floor(Math.random()*10000), // uniqueness
title: event.target.value
});
}
descriptionChange(event) {
this.setState({
description: event.target.value
});
}
setInvite(value) {
this.setState({
groups: value.groups,
ships: value.ships
});
}
onClickCreate() {
const { props, state } = this;
if (!state.title) {
this.setState({
idError: true,
inviteError: false
});
return;
}
const appPath = `/${state.idName}`;
if (appPath in props.associations) {
this.setState({
inviteError: false,
idError: true,
success: false
});
return;
}
let isValid = true;
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
aud.forEach((mem) => {
if (!urbitOb.isValidPatp(mem)) {
isValid = false;
}
});
if (!isValid) {
this.setState({
inviteError: true,
idError: false,
success: false
});
return;
}
const target = state.groups.length === 0
? { ships: aud }
: { group: state.groups[0] };
if (this.textarea) {
this.textarea.value = '';
}
this.setState({
error: false,
success: true,
group: [],
ships: [],
disabled: true
}, () => {
const submit = props.api.links.createCollection(
appPath,
state.title,
state.description,
target,
state.createGroup
);
submit.then(() => {
this.setState({ disabled: false });
props.history.push(makeRoutePath(appPath));
});
});
}
render() {
const { props, state } = this;
const createClasses = state.idName
? 'pointer db f9 mt7 green2 bg-gray0-d ba pv3 ph4 b--green2'
: 'pointer db f9 mt7 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3';
const idClasses =
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
'focus-b--black focus-b--white-d ';
let idErrElem = (<span />);
if (state.idError) {
idErrElem = (
<span className="f9 inter red2 db pt2">
Collection must have a valid name.
</span>
);
}
return (
<div
className={
'h-100 w-100 mw6 pa3 pt4 overflow-x-hidden ' +
'bg-gray0-d white-d flex flex-column'
}
>
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~link">{'⟵ All Collections'}</Link>
</div>
<h2 className="mb3 f8">New Collection</h2>
<div className="w-100">
<p className="f8 mt3 lh-copy db">Name</p>
<textarea
className={idClasses}
placeholder="Cool Collection"
rows={1}
style={{
resize: 'none'
}}
onChange={this.titleChange}
/>
{idErrElem}
<p className="f8 mt3 lh-copy db">
Description
<span className="gray3"> (Optional)</span>
</p>
<textarea
className={idClasses}
placeholder="The hippest links"
rows={1}
style={{
resize: 'none'
}}
onChange={this.descriptionChange}
/>
<div className="mt4 db relative">
<p className="f8">
Invite
<span className="gray3"> (Optional)</span>
</p>
<Link className="green2 absolute right-0 bottom-0 f9" to="/~groups/new">Create Group</Link>
<p className="f9 gray2 db mv1">
Selected group or ships will be invited to the collection
</p>
</div>
<InviteSearch
associations={props.associations.contacts}
groups={props.groups}
contacts={props.contacts}
groupResults={true}
shipResults={true}
invites={{
groups: state.groups,
ships: state.ships
}}
setInvite={this.setInvite}
/>
<button
onClick={this.onClickCreate.bind(this)}
className={createClasses}
disabled={this.state.disabled}
>
Create Collection
</button>
<Spinner awaiting={this.state.disabled} classes="mt3" text="Creating collection..." />
</div>
</div>
);
}
}

View File

@ -0,0 +1,107 @@
import React, { useCallback } from "react";
import { RouteComponentProps } from "react-router-dom";
import { Box, Input, Col } from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import { AsyncButton } from "~/views/components/AsyncButton";
import { FormError } from "~/views/components/FormError";
import GroupSearch from "~/views/components/GroupSearch";
import GlobalApi from "~/logic/api/global";
import { stringToSymbol } from "~/logic/lib/util";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { Associations } from "~/types/metadata-update";
import { Notebooks } from "~/types/publish-update";
import { Groups, GroupPolicy } from "~/types/group-update";
const formSchema = Yup.object({
name: Yup.string().required("Collection must have a name"),
description: Yup.string(),
group: Yup.string(),
});
export function NewScreen(props: object) {
const { history, api } = props;
const waiter = useWaitForProps(props, 5000);
const onSubmit = async (values: object, actions) => {
const resourceId = stringToSymbol(values.name);
try {
const { name, description, group } = values;
if (!!group) {
await props.api.graph.createManagedGraph(
resourceId,
name,
description,
group
);
} else {
await props.api.graph.createUnmanagedGraph(
resourceId,
name,
description,
{ open: {
banRanks: [],
banned: [],
}
}
);
}
await waiter((p) => p?.graphKeys?.has(`${window.ship}/${resourceId}`));
actions.setStatus({ success: null });
history.push(`/~link/${window.ship}/${resourceId}`);
} catch (e) {
console.error(e);
actions.setStatus({ error: "Collection creation failed" });
}
};
return (
<Col p={3}>
<Box mb={4} color="black">New Collection</Box>
<Formik
validationSchema={formSchema}
initialValues={{ name: "", description: "", group: "" }}
onSubmit={onSubmit}>
<Form>
<Box
display="grid"
gridTemplateRows="auto"
gridRowGap={2}
gridTemplateColumns="300px">
<Input
id="name"
label="Name"
caption="Provide a name for your collection"
placeholder="eg. My Links"
/>
<Input
id="description"
label="Description"
caption="What's your collection about?"
placeholder="Collection description"
/>
<GroupSearch
id="group"
label="Group"
caption="What group is the collection for?"
associations={props.associations}
/>
<Box justifySelf="start">
<AsyncButton loadingText="Creating..." type="submit" border>
Create Collection
</AsyncButton>
</Box>
<FormError message="Collection creation failed" />
</Box>
</Form>
</Formik>
</Col>
);
}
export default NewScreen;

View File

@ -1,6 +1,5 @@
import React, { Component } from 'react';
import { uxToHex, makeRoutePath } from '~/logic/lib/util';
import { Link } from 'react-router-dom';
import { LoadingScreen } from './loading';
@ -21,13 +20,14 @@ export class SettingsScreen extends Component {
};
this.renderDelete = this.renderDelete.bind(this);
this.markAllAsSeen = this.markAllAsSeen.bind(this);
this.changeLoading = this.changeLoading.bind(this);
}
componentDidUpdate() {
const { props, state } = this;
console.log(props.resource);
if (Boolean(state.isLoading) && !props.resource) {
this.setState({
isLoading: false
@ -53,12 +53,11 @@ export class SettingsScreen extends Component {
awaiting: true,
type: 'Removing'
});
props.api.links.removeCollection(props.resourcePath)
.then(() => {
this.setState({
isLoading: false
});
});
props.api.graph.leaveGraph(
`~${props.match.params.ship}`,
props.match.params.name
);
}
deleteCollection() {
@ -69,32 +68,30 @@ export class SettingsScreen extends Component {
awaiting: true,
type: 'Deleting'
});
props.api.links.deleteCollection(props.resourcePath)
.then(() => {
this.setState({
isLoading: false
});
});
}
markAllAsSeen() {
this.props.api.links.seenLink(this.props.resourcePath);
console.log(props.match.params.name);
props.api.graph.deleteGraph(props.match.params.name);
}
renderRemove() {
return (
<div className="w-100 fl mt3">
<p className="f8 mt3 lh-copy db">Remove Collection</p>
<p className="f9 gray2 db mb4">
Remove this collection from your collection list.
</p>
<a onClick={this.removeCollection.bind(this)}
className="dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d pointer"
>
Remove collection
</a>
</div>
);
const { props } = this;
if (props.amOwner) {
return null;
} else {
return (
<div className="w-100 fl mt3">
<p className="f8 mt3 lh-copy db">Remove Collection</p>
<p className="f9 gray2 db mb4">
Remove this collection from your collection list.
</p>
<a onClick={this.removeCollection.bind(this)}
className="dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d pointer">
Remove collection
</a>
</div>
);
}
}
renderDelete() {
@ -110,8 +107,7 @@ export class SettingsScreen extends Component {
Delete this collection, for you and all group members.
</p>
<a onClick={this.deleteCollection.bind(this)}
className="dib f9 ba pa2 b--red2 red2 pointer bg-gray0-d mb4"
>
className="dib f9 ba pa2 b--red2 red2 pointer bg-gray0-d mb4">
Delete collection
</a>
</div>
@ -122,95 +118,46 @@ export class SettingsScreen extends Component {
render() {
const { props, state } = this;
if (props.groupPath === undefined) {
return <LoadingScreen />;
}
const title = props.resource.metadata.title || props.resourcePath;
console.log(props);
if (state.isLoading) {
return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}
>
<Link to="/~link">{'⟵ All Collections'}</Link>
</div>
<div
className="pl4 pt2 bb b--gray4 b--gray2-d bg-gray0-d flex relative overflow-x-scroll overflow-x-auto-l overflow-x-auto-xl flex-shrink-0"
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={this.props.sidebarShown}
popout={this.props.popout}
api={this.props.api}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout)}
className="pt2 white-d"
>
<h2
className="dib f9 fw4 lh-solid v-top"
style={{ width: 'max-content' }}
>
{props.resource.metadata.title}
</h2>
</Link>
<TabBar
location={props.location}
popout={props.popout}
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
/>
</div>
<div className="w-100 pl3 mt4 cf">
<h2 className="f8 pb2">Removing...</h2>
</div>
</div>
);
if (!props.graphResource || !props.resource.metadata.color) {
return <LoadingScreen />;
}
return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}
>
<div className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}>
<Link to="/~link">{'⟵ All Collections'}</Link>
</div>
<div
className="pl4 pt2 bb b--gray4 b--gray1-d flex relative overflow-x-scroll overflow-x-auto-l overflow-x-auto-xl flex-shrink-0"
style={{ height: 48 }}
>
className={
"pl4 pt2 bb b--gray4 b--gray1-d flex relative overflow-x-scroll " +
"overflow-x-auto-l overflow-x-auto-xl flex-shrink-0"
}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={this.props.sidebarShown}
popout={this.props.popout}
api={this.props.api}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout)}
className="pt2"
>
<Link to="/~link" className="pt2">
<h2
className="dib f9 fw4 lh-solid v-top"
style={{ width: 'max-content' }}
>
{props.resource.metadata.title}
style={{ width: 'max-content' }}>
{title}
</h2>
</Link>
<TabBar
location={props.location}
popout={props.popout}
popoutHref={makeRoutePath(props.resourcePath, true, props.page)}
settings={makeRoutePath(props.resourcePath, props.popout) + '/settings'}
popoutHref={`/~link/popout/${props.resource}/settings`}
settings={`/~link/${props.resource}/settings`}
/>
</div>
<div className="w-100 pl3 mt4 cf">
<h2 className="f8 pb2">Collection Settings</h2>
<p className="f8 mt3 lh-copy db">Mark all links as read</p>
<p className="f9 gray2 db mb4">Mark all links in this collection as read.</p>
<a className="dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d pointer"
onClick={this.markAllAsSeen}
>
Mark all as read
</a>
{this.renderRemove()}
{this.renderDelete()}
<MetadataSettings
@ -219,7 +166,7 @@ export class SettingsScreen extends Component {
api={props.api}
association={props.resource}
resource="collection"
app="link"
app="graph"
/>
<Spinner
awaiting={this.state.awaiting}

View File

@ -1,16 +1,12 @@
import React, { Component } from 'react';
import { ChannelsSidebar } from './lib/channel-sidebar';
import { ChannelSidebar } from './lib/channel-sidebar';
import ErrorBoundary from '~/views/components/ErrorBoundary';
export class Skeleton extends Component {
render() {
const { props } = this;
const rightPanelHide = props.rightPanelHide
? 'dn-s' : '';
const popout = props.popout
? props.popout : false;
const rightPanelHide = props.rightPanelHide ? 'dn-s' : '';
const popout = props.popout ? props.popout : false;
const popoutWindow = (popout)
? '' : 'ph4-m ph4-l ph4-xl pb4-m pb4-l pb4-xl';
@ -18,28 +14,25 @@ export class Skeleton extends Component {
const popoutBorder = (popout)
? '' : 'ba-m ba-l ba-xl b--gray4 b--gray1-d br1';
const linkInvites = ('/link' in props.invites)
const linkInvites = ('/link' in props.invites)
? props.invites['/link'] : {};
return (
<div className={'absolute w-100 ' + popoutWindow} style={{ height: 'calc(100% - 45px)' }}>
<div className={'absolute w-100 ' + popoutWindow}
style={{ height: 'calc(100% - 45px)' }}>
<div className={'bg-white bg-gray0-d cf w-100 h-100 flex ' + popoutBorder}>
<ChannelsSidebar
active={props.active}
popout={popout}
associations={props.associations}
invites={linkInvites}
groups={props.groups}
selected={props.selected}
sidebarShown={props.sidebarShown}
links={props.links}
listening={props.listening}
api={props.api}
/>
<div className={'h-100 w-100 flex-auto relative ' + rightPanelHide} style={{
flexGrow: 1
}}
>
<ChannelSidebar
active={props.active}
popout={popout}
associations={props.associations}
invites={linkInvites}
groups={props.groups}
selected={props.selected}
sidebarShown={props.sidebarShown}
api={props.api}
graphKeys={props.graphKeys} />
<div className={'h-100 w-100 flex-auto relative ' + rightPanelHide}
style={{ flexGrow: 1 }}>
<ErrorBoundary>
{props.children}
</ErrorBoundary>