diff --git a/pkg/arvo/app/link-listen-hook.hoon b/pkg/arvo/app/link-listen-hook.hoon index 948aadc57..11bd7cebd 100644 --- a/pkg/arvo/app/link-listen-hook.hoon +++ b/pkg/arvo/app/link-listen-hook.hoon @@ -1,21 +1,33 @@ :: link-listen-hook: get your friends' bookmarks :: -:: on-init, subscribes to all groups on this ship. for every ship in a group, -:: we subscribe to their link's local-pages and annotations -:: at the group path (through link-proxy-hook), -:: and forwards all entries into our link as submissions and comments. +:: subscribes to all %link resources in the metadata-store. +:: 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 group member fails, we assume it's because their -:: group definition hasn't been updated to include us yet. +:: 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. :: -/- *link, group-store -/+ default-agent, verb, dbug +/- *metadata-store, *link, group-store +/+ metadata, default-agent, verb, dbug :: |% +$ state-0 $: %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) @@ -52,7 +64,7 @@ ++ on-init ^- (quip card _this) :_ this - [watch-groups:do]~ + ~[watch-metadata:do watch-groups:do] :: ++ on-save !>(state) ++ on-load @@ -63,26 +75,27 @@ ++ on-agent |= [=wire =sign:agent:gall] ^- (quip card _this) - ?: ?=([%groups ~] wire) - =^ cards state + =^ cards state + ?+ wire ~|([dap.bowl %weird-agent-wire wire] !!) + [%metadata ~] + (take-metadata-sign:do sign) + :: + [%groups ~] (take-groups-sign:do sign) - [cards this] - ?: ?=([%links ?(%local-pages %annotations) @ ^] wire) - =^ cards state + :: + [%links ?(%local-pages %annotations) @ ^] (take-link-sign:do (wire-to-target t.wire) sign) - [cards this] - ?: ?=([%forward ^] wire) - =^ cards state + :: + [%forward ^] (take-forward-sign:do t.wire sign) - [cards this] - ?: ?=([%prod *] wire) - ~| [%weird-sign -.sign] - ?> ?=(%poke-ack -.sign) - ?~ p.sign [~ this] - %- (slog [leaf+"failed to prod" u.p.sign]) - [~ this] - ~| [dap.bowl %weird-wire wire] - !! + :: + [%prod *] + ?> ?=(%poke-ack -.sign) + ?~ p.sign [~ state] + %- (slog leaf+"prod failed" u.p.sign) + [~ state] + == + [cards this] :: ++ on-poke |= [=mark =vase] @@ -122,8 +135,65 @@ :: :: |_ =bowl:gall ++* md ~(. metadata bowl) :: -:: groups subscription +:: 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] + %associations + =/ socs=(list [=group-path resource]) + ~(tap in ~(key by associations.upd)) + =| cards=(list card) + |- ::TODO try for +roll maybe? + ?~ socs [cards state] + =^ caz state + =, i.socs + ?. =(%link app-name) [~ state] + (listen-to-group app-path group-path) + $(socs t.socs, cards (weld cards caz)) + :: + %add + ?> =(%link app-name.resource.upd) + (listen-to-group app-path.resource.upd group-path.upd) + :: + %remove + ?> =(%link app-name.resource.upd) + (leave-from-group app-path.resource.upd group-path.upd) + == +:: +:: groups subscriptions :: ++ watch-groups ^- card @@ -148,49 +218,98 @@ =* mark p.cage.sign =* vase q.cage.sign ?+ mark ~|([dap.bowl %unexpected-mark mark] !!) - %group-initial (handle-group-initial !<(groups:group-store vase)) + %group-initial [~ state] ::NOTE initial handled using metadata %group-update (handle-group-update !<(group-update:group-store vase)) == == :: -++ handle-group-initial - |= =groups:group-store - ^- (quip card _state) - =| cards=(list card) - =/ groups=(list [=path =group:group-store]) - ~(tap by groups) - |- - ?~ groups [cards state] - =^ caz state - %- handle-group-update - [%add [group path]:i.groups] - $(cards (weld cards caz), groups t.groups) -:: ++ handle-group-update |= upd=group-update:group-store ^- (quip card _state) - :_ state - ?+ -.upd ~ - ?(%path %add %remove) - =/ whos=(list ship) ~(tap in members.upd) - |- ^- (list card) - ?~ whos ~ - :: no need to subscribe to ourselves - :: - ?: =(our.bowl i.whos) - $(whos t.whos) + ?. ?=(?(%path %add %remove) -.upd) + [~ state] + =/ socs=(list app-path) + (app-paths-from-group:md %link pax.upd) + =/ whos=(list ship) + ~(tap in members.upd) + =| cards=(list card) + |- + =* loop-socs $ + ?~ socs [cards state] + |- + =* loop-whos $ + ?~ whos loop-socs(socs t.socs) + =^ caz state ?: ?=(%remove -.upd) - %+ weld - $(whos t.whos) - (end-link-subscriptions i.whos pax.upd) - :^ (start-link-subscription %local-pages i.whos pax.upd) - (start-link-subscription %annotations i.whos pax.upd) - (prod-other-listener i.whos pax.upd) - $(whos t.whos) - == + (leave-from-peer i.socs pax.upd i.whos) + (listen-to-peer i.socs pax.upd 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 + =- (fall - *group:group-store) + %^ scry-for (unit group:group-store) + %group-store + 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 + =- (fall - *group:group-store) + %^ scry-for (unit group:group-store) + %group-store + 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] + :_ =- state(reasoning -) + (~(del ju reasoning) [who app-path] group-path) + ?. (~(has ju reasoning) [who app-path] group-path) + ~ + (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 @@ -283,7 +402,7 @@ ++ take-retry |= =target ^- (list card) - :: relevant: whether :who is still in group :where + :: relevant: whether :who is still associated with resource :where :: =; relevant=? ?. relevant ~ @@ -291,16 +410,13 @@ ?: %- ~(has by wex.bowl) [[%links (target-to-wire target)] who.target %link-proxy-hook] | - %. who.target - %~ has in - =- (fall - *group:group-store) - .^ (unit group:group-store) - %gx - (scot %p our.bowl) + %+ lien (groups-from-resource:md %link where.target) + |= =group-path + ^- ? + =- (~(has in (fall - *group:group-store)) who.target) + %^ scry-for (unit group:group-store) %group-store - (scot %da now.bowl) - (snoc where.target %noun) - == + group-path :: ++ do-link-action |= [=wire =action] @@ -373,4 +489,14 @@ == %- (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) + == -- diff --git a/pkg/arvo/app/link-proxy-hook.hoon b/pkg/arvo/app/link-proxy-hook.hoon index 49341c2a5..ac6ae9e3d 100644 --- a/pkg/arvo/app/link-proxy-hook.hoon +++ b/pkg/arvo/app/link-proxy-hook.hoon @@ -4,12 +4,11 @@ :: stores if permission conditions are met. :: the patterns herein should one day be generalized into a proxy-hook lib. :: -:: this adopts a very primitive view of groups-store as containing only -:: groups of interesting (rather than uninteresting) ships. it sets the -:: permission condition to be that ship must be in group matching the path -:: it's subscribing to. -:: we check this on-watch, but also subscribe to groups so that we can kick -:: subscriptions if needed (eg ship removed from group). +:: 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. @@ -18,10 +17,10 @@ :: 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, & maybe +handle-group-update. +:: to touch are +permitted, +initial-response, & +kick-proxies. :: -/- group-store -/+ *link, default-agent, verb, dbug +/- group-store, *metadata-store +/+ *link, metadata, default-agent, verb, dbug |% +$ state-0 $: %0 @@ -48,7 +47,7 @@ ++ on-init ^- (quip card _this) :_ this - [watch-groups:do]~ + ~[watch-groups:do watch-metadata:do] :: ++ on-save !>(state) ++ on-load @@ -96,11 +95,15 @@ -- :: |_ =bowl:gall ++* md ~(. metadata bowl) +:: +:: permissions +:: ++ permitted |= [who=ship =path] ^- ? - :: we only expose group-specific /local-pages and /annotations, - :: and only to ships in the relevant group. + :: 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) @@ -110,12 +113,75 @@ `t.t.path ~ ?~ target | - =; group - ?& ?=(^ group) - (~(has in u.group) who) - == - %+ scry-for (unit group:group-store) - [%group-store u.target] + ~? !.^(? %gu (scot %p our.bowl) %metadata-store (scot %da now.bowl) ~) + %woah-md-s-not-booted ::TODO fallback if needed + %+ lien (groups-from-resource:md %link u.target) + |= =group-path + ^- ? + =- (~(has in (fall - *group:group-store)) who) + %^ scry-for (unit group:group-store) + %group-store + group-path +:: +++ 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 + =- (fall - *group:group-store) + %^ scry-for (unit group:group-store) + %group-store + group-path.upd :: :: groups subscription ::TODO largely copied from link-listen-hook. maybe make a store-listener lib? @@ -153,29 +219,26 @@ ^- (quip card _state) :_ state ?. ?=(%remove -.upd) ~ - =/ whos=(list ship) ~(tap in members.upd) - |- ^- (list card) - ?~ whos ~ - :: no need to remove to ourselves + :: if someone was removed from a group, find all link resources associated + :: with that group, then kick their subscriptions if they're no longer :: - ?: =(our.bowl i.whos) - $(whos t.whos) - :_ $(whos t.whos) - ::NOTE this depends kind of unfortunately on the fact that we only accept - :: subscriptions to /local-pages//* paths. it'd be more correct if we - :: "just" looked at all paths in the map, and found the matching ones. - ::TODO what exactly did i mean by this? - %+ kick-proxies i.whos - :~ [%local-pages pax.upd] - [%annotations '' pax.upd] - == + %- zing + %+ turn (app-paths-from-group:md %link pax.upd) + |= =app-path + ^- (list card) + %+ kick-revoked-permissions + app-path + ~(tap in members.upd) :: :: proxy subscriptions :: ++ kick-proxies - |= [who=ship paths=(list path)] + |= [who=ship =path] ^- card - [%give %kick paths `who] + =- [%give %kick - `who] + :~ [%local-pages path] + [%annotations %$ path] + == :: ++ handle-proxy-sign |= [=wire =sign:agent:gall] @@ -211,14 +274,10 @@ [%give %fact ~ %link-initial !>(initial)] ?+ path !! [%local-pages ^] - :- %local-pages - %+ scry-for (map ^path pages) - [%link-store path] + [%local-pages .^((map ^path pages) %gx path)] :: [%annotations %$ ^] - :- %annotations - %+ scry-for (per-path-url notes) - [%link-store path] + [%annotations .^((per-path-url notes) %gx %$ t.t.path)] == :: ++ start-proxy @@ -249,12 +308,14 @@ :: [(proxy-pass-link-store path %leave ~)]~ :: +:: helpers +:: ++ scry-for - |* [=mold app=term =path] + |* [=mold =app-name =path] .^ mold %gx (scot %p our.bowl) - app + app-name (scot %da now.bowl) (snoc `^path`path %noun) == diff --git a/pkg/arvo/app/link-view.hoon b/pkg/arvo/app/link-view.hoon index 87814a424..94c6a9778 100644 --- a/pkg/arvo/app/link-view.hoon +++ b/pkg/arvo/app/link-view.hoon @@ -10,7 +10,11 @@ :: /json/[n]/submission/[wood-url]/[some-group] nth matching submission :: /json/seen mark-as-read updates :: -/+ *link, *server, default-agent, verb +/- *link-view, + *invite-store, group-store, + group-hook, permission-hook, permission-group-hook, + metadata-hook, contact-view +/+ *link, metadata, *server, default-agent, verb, dbug :: |% +$ state-0 @@ -25,6 +29,7 @@ =* state - :: %+ verb | +%- agent:dbug ^- agent:gall =< |_ =bowl:gall @@ -42,6 +47,12 @@ :: =+ [dap.bowl /tile '/~link/js/tile.js'] [%pass /launch %agent [our.bowl %launch] %poke %launch-action !>(-)] + :: + =+ [%invite-action !>([%create /link])] + [%pass /invitatory/create %agent [our.bowl %invite-store] %poke -] + :: + =+ /invitatory/link + [%pass - %agent [our.bowl %invite-store] %watch -] == :: ++ on-save !>(state) @@ -65,6 +76,9 @@ :: %link-action [(handle-action:do !<(action vase)) ~] + :: + %link-view-action + (handle-view-action:do !<(view-action vase)) == :: ++ on-watch @@ -104,13 +118,18 @@ ?+ -.sign (on-agent:def wire sign) %kick :_ this - [%pass wire %agent [our.bowl %link-store] %watch wire]~ + =/ 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) - %link-initial [~ this] + %invite-update [(handle-invite-update:do !<(invite-update vase)) this] + %link-initial [~ this] :: %link-update :_ this @@ -135,6 +154,8 @@ -- :: |_ =bowl:gall ++* md ~(. metadata bowl) +:: ++ page-size 25 ++ get-paginated |* [p=(unit @ud) l=(list)] @@ -217,10 +238,214 @@ %- as-octs:mimes:html .^(@ %cx path) :: +++ do-poke + |= [app=term =mark =vase] + ^- card + [%pass /create/[app]/[mark] %agent [our.bowl app] %poke mark vase] +:: +++ handle-invite-update + |= upd=invite-update + ^- (list card) + ?. ?=(%accepted -.upd) ~ + ?. =(/link path.upd) ~ + :~ :: sync the group + :: + %^ do-poke %group-hook + %group-hook-action + !> ^- group-hook-action:group-hook + [%add ship path]:invite.upd + :: + :: sync the metadata + :: + %^ do-poke %metadata-hook + %metadata-hook-action + !> ^- metadata-hook-action:metadata-hook + [%add-synced ship path]:invite.upd + == +:: ++ handle-action |= =action ^- card [%pass /action %agent [our.bowl %link-store] %poke %link-action !>(action)] +:: +++ handle-view-action + |= act=view-action + ^- (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 real-group=?] + ^- (list card) + =/ group-path=^path + ?- -.members + %group path.members + :: + %ships + %+ weld + ?:(real-group ~ [~.~]~) + [(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] + == + ?: ?=(%group -.members) ~ + :: if the group is "real", make contact-view do the heavy lifting + :: + ?: real-group + :_ ~ + %^ do-poke %contact-view + %contact-view-action + !> ^- contact-view-action:contact-view + [%create group-path ships.members title description] + :: for "unmanaged" groups, do it ourselves + :: + :* :: create the new group + :: + %^ do-poke %group-store + %group-action + !> ^- group-action:group-store + [%bundle group-path] + :: + :: fill the new group + :: + %^ do-poke %group-store + %group-action + !> ^- group-action:group-store + [%add (~(put in ships.members) our.bowl) group-path] + :: + :: make group available + :: + %^ do-poke %group-hook + %group-hook-action + !> ^- group-hook-action:group-hook + [%add our.bowl group-path] + :: + :: mirror group into a permission + :: + %^ do-poke %permission-group-hook + %permission-group-hook-action + !> ^- permission-group-hook-action:permission-group-hook + [%associate group-path [group-path^%white ~ ~]] + :: + :: expose the permission + :: + %^ do-poke %permission-hook + %permission-hook-action + !> ^- permission-hook-action:permission-hook + [%add-owned group-path group-path] + :: + :: 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 + %+ 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 + !> ^- group-hook-action:group-hook + [%remove group-path] + :: + %^ do-poke %metadata-hook + %metadata-hook-action + !> ^- metadata-hook-action:metadata-hook + [%remove group-path] + :: + %^ do-poke %group-store + %group-action + !> ^- group-action:group-store + [%unbundle group-path] + == + :: 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) + :- %^ do-poke %group-store + %group-action + !> ^- group-action:group-store + [%add ships group-path] + :: for managed groups, rely purely on group logic for invites + :: + ?. ?=([%'~' ^] group-path) + ~ + :: for unmanaged groups, send invites manually + :: + %+ turn ~(tap in ships) + |= =ship + ^- card + %^ do-poke %invite-hook + %invite-action + !> ^- invite-action + :^ %invite /link + (sham group-path eny.bowl) + :* our.bowl + %group-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. diff --git a/pkg/arvo/app/link/img/search.png b/pkg/arvo/app/link/img/search.png new file mode 100644 index 000000000..7ac218304 Binary files /dev/null and b/pkg/arvo/app/link/img/search.png differ diff --git a/pkg/arvo/app/metadata-store.hoon b/pkg/arvo/app/metadata-store.hoon index 7bb860ac3..8e52aaa41 100644 --- a/pkg/arvo/app/metadata-store.hoon +++ b/pkg/arvo/app/metadata-store.hoon @@ -182,7 +182,7 @@ |= =group-path ^- ^associations %- ~(gas by *^associations) - %+ turn ~(tap in (~(got by group-indices) group-path)) + %+ turn ~(tap in (~(gut by group-indices) group-path ~)) |= =resource :- [group-path resource] (~(got by associations) [group-path resource]) diff --git a/pkg/arvo/lib/link.hoon b/pkg/arvo/lib/link.hoon index 199b029a3..599e0f077 100644 --- a/pkg/arvo/lib/link.hoon +++ b/pkg/arvo/lib/link.hoon @@ -43,13 +43,15 @@ ~| path (woad (slav %ta i.path)) :: -:: zip sorted a into sorted b, maintaining sort order -::TODO stdlib -++ merge-sorted +:: zip sorted a into sorted b, maintaining sort order, avoiding duplicates +:: +++ merge-sorted-unique |* [sort=$-([* *] ?) a=(list) b=(list)] |- ^- ?(_a _b) ?~ a b ?~ b a + ?: =(i.a i.b) + [i.a $(a t.a, b t.b)] ?: (sort i.a i.b) [i.a $(a t.a)] [i.b $(b t.b)] @@ -60,7 +62,7 @@ ::TODO we would just use +cury here but it don't work |= [a=^pages b=^pages] ^+ a - %+ merge-sorted + %+ merge-sorted-unique |= [a=page b=page] (gth time.a time.b) [a b] @@ -68,7 +70,7 @@ ++ submissions |= [a=^submissions b=^submissions] ^+ a - %+ merge-sorted + %+ merge-sorted-unique |= [a=submission b=submission] (gth time.a time.b) [a b] @@ -76,7 +78,7 @@ ++ notes |= [a=^notes b=^notes] ^+ a - %+ merge-sorted + %+ merge-sorted-unique |= [a=note b=note] (gth time.a time.b) [a b] @@ -84,7 +86,7 @@ ++ comments |= [a=^comments b=^comments] ^+ a - %+ merge-sorted + %+ merge-sorted-unique |= [a=comment b=comment] (gth time.a time.b) [a b] diff --git a/pkg/arvo/lib/metadata.hoon b/pkg/arvo/lib/metadata.hoon new file mode 100644 index 000000000..b83909674 --- /dev/null +++ b/pkg/arvo/lib/metadata.hoon @@ -0,0 +1,54 @@ +:: metadata: helpers for getting data from the metadata-store +:: +/- *metadata-store +:: +|_ =bowl:gall +++ app-paths-from-group + |= [=app-name =group-path] + ^- (list app-path) + %+ murn + %~ tap in + =- (~(gut by -) group-path ~) + .^ (jug ^group-path resource) + %gy + (scot %p our.bowl) + %metadata-store + (scot %da now.bowl) + /group-indices + == + |= =resource + ^- (unit app-path) + ?. =(app-name.resource app-name) ~ + `app-path.resource +:: +++ groups-from-resource + |= =resource + ^- (list group-path) + =; resources + %~ tap in + %+ ~(gut by resources) + resource + *(set group-path) + .^ (jug ^resource group-path) + %gy + (scot %p our.bowl) + %metadata-store + (scot %da now.bowl) + /resource-indices + == +:: +++ check-resource-permissions + |= [=ship =resource] + ^- ? + %+ lien (groups-from-resource resource) + |= =group-path + .^ ? + %gx + (scot %p our.bowl) + %permission-store + (scot %da now.bowl) + %permitted + (scot %p ship) + (snoc group-path %noun) + == +-- \ No newline at end of file diff --git a/pkg/arvo/mar/link/view-action.hoon b/pkg/arvo/mar/link/view-action.hoon new file mode 100644 index 000000000..f32241a34 --- /dev/null +++ b/pkg/arvo/mar/link/view-action.hoon @@ -0,0 +1,42 @@ +/- *link-view +=, dejs:format +|_ act=view-action +++ grab + |% + ++ noun view-action + ++ json + |^ %- of + :~ %create^create + %delete^delete + %invite^invite + == + :: + ++ create + %- ot + :~ 'path'^pa + 'title'^so + 'description'^so + 'members'^mems + 'realGroup'^bo + == + :: + ++ mems + (of %group^pa %ships^ships ~) + :: + ++ delete + (ot 'path'^pa ~) + :: + ++ invite + (ot 'path'^pa 'ships'^ships ~) + :: + ::TODO stdlib + ++ ships + (cu sy (ar (su ;~(pfix sig fed:ag)))) + -- + -- +:: +++ grow + |% + ++ noun act + -- +-- diff --git a/pkg/arvo/sur/link-view.hoon b/pkg/arvo/sur/link-view.hoon new file mode 100644 index 000000000..ae1d40221 --- /dev/null +++ b/pkg/arvo/sur/link-view.hoon @@ -0,0 +1,34 @@ +:: link-view: encapsulating link management +:: +|% +++ view-action + $% :: %create: create a new link collection + :: + :: with specified metadata and group. %ships creates a new group, + :: :real-group indicates whether it's global (&) or local (|). + :: + $: %create + =path + title=@t + description=@t + members=create-members + real-group=? + == + :: + :: %delete: remove collection from local metadata & stop syncing + :: + :: if the resource is associated with a real group, and we're the owner, + :: this deletes it for everyone + :: + [%delete =path] + :: + :: %invite: add to resource's group and send invite + :: + [%invite =path ships=(set ship)] + == +:: +++ create-members + $% [%group =path] + [%ships ships=(set ship)] + == +-- diff --git a/pkg/interface/link/src/css/custom.css b/pkg/interface/link/src/css/custom.css index e24604b35..dd760ef63 100644 --- a/pkg/interface/link/src/css/custom.css +++ b/pkg/interface/link/src/css/custom.css @@ -60,6 +60,10 @@ a { font-family: "Source Code Pro", monospace; } +.list-ship { + line-height: 2.2; +} + .c-default { cursor: default; } @@ -105,6 +109,23 @@ a { 100% {transform: rotate(360deg);} } +/* toggler checkbox */ + +.toggle::after { + content: ""; + height: 12px; + width: 12px; + background: white; + position: absolute; + top: 2px; + left: 2px; + border-radius: 100%; +} + +.toggle.checked::after { + left: 14px; +} + /* responsive */ @media all and (max-width: 34.375em) { .dn-s { @@ -164,6 +185,9 @@ a { .b--gray0-d { border-color: #333; } + .b--gray1-d { + border-color: #4d4d4d; + } .b--gray2-d { border-color: #7f7f7f; } @@ -180,6 +204,9 @@ a { .o-60-d { opacity: .6; } + .focus-b--white-d:focus { + border-color: #fff; + } a { color: #fff; } diff --git a/pkg/interface/link/src/js/api.js b/pkg/interface/link/src/js/api.js index f495f2c15..ffc922f7f 100644 --- a/pkg/interface/link/src/js/api.js +++ b/pkg/interface/link/src/js/api.js @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import _ from 'lodash'; -import { uuid, stringToTa } from '/lib/util'; +import { stringToTa } from '/lib/util'; import { store } from '/store'; import moment from 'moment'; @@ -13,10 +13,13 @@ class UrbitApi { this.invite = { accept: this.inviteAccept.bind(this), - decline: this.inviteDecline.bind(this), - invite: this.inviteInvite.bind(this) + decline: this.inviteDecline.bind(this) }; + this.groups = { + remove: this.groupRemove.bind(this) + } + this.bind = this.bind.bind(this); this.bindLinkView = this.bindLinkView.bind(this); } @@ -61,32 +64,26 @@ class UrbitApi { }); } - inviteAction(data) { - this.action("invite-store", "json", data); + groupsAction(data) { + this.action("group-store", "group-action", data); } - inviteInvite(path, ship) { - this.action("invite-hook", "json", - { - invite: { - path: '/chat', - invite: { - path, - ship: `~${window.ship}`, - recipient: ship, - app: 'chat-hook', - text: `You have been invited to /${window.ship}${path}`, - }, - uid: uuid() - } + groupRemove(path, members) { + this.groupsAction({ + remove: { + path, members } - ); + }); + } + + inviteAction(data) { + this.action("invite-store", "json", data); } inviteAccept(uid) { this.inviteAction({ accept: { - path: '/chat', + path: '/link', uid } }); @@ -95,7 +92,7 @@ class UrbitApi { inviteDecline(uid) { this.inviteAction({ decline: { - path: '/chat', + path: '/link', uid } }); @@ -144,6 +141,30 @@ class UrbitApi { ); } + linkViewAction(data) { + return this.action("link-view", "link-view-action", data); + } + + createCollection(path, title, description, members, realGroup) { + // members is either {group:'/group-path'} or {'ships':[~zod]}, + // with realGroup signifying if ships should become a managed group or not. + return this.linkViewAction({ + create: {path, title, description, members, realGroup} + }); + } + + deleteCollection(path) { + return this.linkViewAction({ + 'delete': {path} + }); + } + + inviteToCollection(path, ships) { + return this.linkViewAction({ + 'invite': {path, ships} + }); + } + linkAction(data) { return this.action("link-store", "link-action", data); } @@ -167,6 +188,29 @@ class UrbitApi { }); } + metadataAction(data) { + return this.action("metadata-hook", "metadata-action", data); + } + + metadataAdd(appPath, groupPath, title, description, dateCreated, color) { + return this.metadataAction({ + add: { + 'group-path': groupPath, + resource: { + 'app-path': appPath, + 'app-name': 'link' + }, + metadata: { + title, + description, + color, + 'date-created': dateCreated, + creator: `~${window.ship}` + } + } + }); + } + sidebarToggle() { let sidebarBoolean = true; if (store.state.sidebarShown === true) { diff --git a/pkg/interface/link/src/js/components/lib/channel-sidebar.js b/pkg/interface/link/src/js/components/lib/channel-sidebar.js index f039df397..9e9e1e8b4 100644 --- a/pkg/interface/link/src/js/components/lib/channel-sidebar.js +++ b/pkg/interface/link/src/js/components/lib/channel-sidebar.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { Route, Link } from 'react-router-dom'; import { ChannelsItem } from '/components/lib/channels-item'; +import { SidebarInvite } from '/components/lib/sidebar-invite'; export class ChannelsSidebar extends Component { // drawer to the left @@ -9,59 +10,38 @@ export class ChannelsSidebar extends Component { render() { const { props, state } = this; - let privateChannel = - Object.keys(props.groups) - .filter((path) => { - return (path === "/~/default") - }) - .map((path) => { - let name = "Private" - let selected = (props.selected === path); - let linkCount = !!props.links[path] ? props.links[path].totalItems : 0; - const unseenCount = !!props.links[path] - ? props.links[path].unseenCount - : linkCount + let sidebarInvites = Object.keys(props.invites) + .map((uid) => { return ( - - ) - }) - - let channelItems = - Object.keys(props.groups) - .filter((path) => { - return (!path.startsWith("/~/")) - }) - .map((path) => { - let name = path.substr(1); - let nameSeparator = name.indexOf("/"); - name = name.substr(nameSeparator + 1); - - let selected = (props.selected === path); - let linkCount = !!props.links[path] ? props.links[path].totalItems : 0; - const unseenCount = !!props.links[path] - ? props.links[path].unseenCount - : linkCount - - return ( - - ) + + ); }); - let activeClasses = (this.props.active === "channels") ? " " : "dn-s "; + const channelItems = + Object.keys(props.resources).map((path) => { + const meta = props.resources[path]; + const selected = (props.selected === path); + const linkCount = !!props.links[path] ? props.links[path].totalItems : 0; + const unseenCount = !!props.links[path] + ? props.links[path].unseenCount + : linkCount + + return ( + + ); + }); + + let activeClasses = (this.props.active === "collections") ? " " : "dn-s "; let hiddenClasses = true; @@ -70,7 +50,7 @@ export class ChannelsSidebar extends Component { if (this.props.popout) { hiddenClasses = false; } else { - hiddenClasses = this.props.sidebarShown; + hiddenClasses = this.props.sidebarShown; } return ( @@ -81,15 +61,15 @@ export class ChannelsSidebar extends Component { : "dn")}> ⟵ Landscape
-

- Your Collections -

- {privateChannel} +
+ + New Collection + +
+ {sidebarInvites} {channelItems}
diff --git a/pkg/interface/link/src/js/components/lib/channels-item.js b/pkg/interface/link/src/js/components/lib/channels-item.js index fbc354903..3514421ae 100644 --- a/pkg/interface/link/src/js/components/lib/channels-item.js +++ b/pkg/interface/link/src/js/components/lib/channels-item.js @@ -1,13 +1,13 @@ import React, { Component } from 'react' - -import { Route, Link } from 'react-router-dom'; +import { Route, Link } from 'react-router-dom'; +import { makeRoutePath } from '../../lib/util'; export class ChannelsItem extends Component { render() { const { props } = this; - let selectedClass = (props.selected) - ? "bg-gray5 bg-gray1-d b--gray4 b--gray2-d" + let selectedClass = (props.selected) + ? "bg-gray5 bg-gray1-d b--gray4 b--gray2-d" : "b--gray4 b--gray2-d"; let memberCount = props.memberList @@ -18,7 +18,7 @@ export class ChannelsItem extends Component { : null; return ( - +

{props.name}

diff --git a/pkg/interface/link/src/js/components/lib/comments-pagination.js b/pkg/interface/link/src/js/components/lib/comments-pagination.js index 595089ec6..9ba686d9c 100644 --- a/pkg/interface/link/src/js/components/lib/comments-pagination.js +++ b/pkg/interface/link/src/js/components/lib/comments-pagination.js @@ -1,13 +1,13 @@ import React, { Component } from 'react'; import { Route, Link } from 'react-router-dom'; -import { base64urlEncode } from '../../lib/util'; +import { makeRoutePath } from '../../lib/util'; export class CommentsPagination extends Component { render() { let props = this.props; - let prevPage = "/" + (Number(props.commentPage) - 1); - let nextPage = "/" + (Number(props.commentPage) + 1); + let prevPage = (Number(props.commentPage) - 1); + let nextPage = (Number(props.commentPage) + 1); let prevDisplay = ((Number(props.commentPage) > 0)) ? "dib" @@ -17,31 +17,16 @@ export class CommentsPagination extends Component { ? "dib" : "dn"; - let encodedUrl = base64urlEncode(props.url); - let popout = (props.popout) ? "/popout" : ""; - return (

+ to={makeRoutePath(props.resourcePath, props.popout, props.linkPage, props.url, props.linkIndex, prevPage)}> <- Previous Page + to={makeRoutePath(props.resourcePath, props.popout, props.linkPage, props.url, props.linkIndex, nextPage)}> Next Page ->
diff --git a/pkg/interface/link/src/js/components/lib/comments.js b/pkg/interface/link/src/js/components/lib/comments.js index 309c87ddb..1b1545cb6 100644 --- a/pkg/interface/link/src/js/components/lib/comments.js +++ b/pkg/interface/link/src/js/components/lib/comments.js @@ -19,7 +19,7 @@ export class Comments extends Component { ) { this.setState({requested: this.props.commentPage}); api.getCommentsPage( - this.props.groupPath, + this.props.resourcePath, this.props.url, this.props.commentPage); } @@ -73,8 +73,8 @@ export class Comments extends Component {
{commentsList} `~${mem}`); + + if (state.members.length === 0) { + this.setState({ + error: true, + success: false + }); + return; + } + + api.setSpinner(true); + + this.setState({ + error: false, + success: true, + members: [] + }, () => { + api.inviteToCollection(props.resourcePath, aud).then(() => { + api.setSpinner(false); + }); + }); + } + + setInvite(invite) { + this.setState({members: invite.ships}); + } + + render() { + const { props, state } = this; + + let modifyButtonClasses = "mt4 db f9 ba pa2 white-d bg-gray0-d b--black b--gray2-d pointer"; + if (state.error) { + modifyButtonClasses = modifyButtonClasses + ' gray3'; + } + + return ( +
+ + +
+ ); + } +} \ No newline at end of file diff --git a/pkg/interface/link/src/js/components/lib/invite-search.js b/pkg/interface/link/src/js/components/lib/invite-search.js new file mode 100644 index 000000000..02fff5d70 --- /dev/null +++ b/pkg/interface/link/src/js/components/lib/invite-search.js @@ -0,0 +1,369 @@ +import React, { Component } from "react"; +import urbitOb from "urbit-ob"; +import { Sigil } from "../lib/icons/sigil"; + +export class InviteSearch extends Component { + constructor(props) { + super(props); + this.state = { + groups: [], + peers: [], + contacts: new Map, + searchValue: "", + searchResults: { + groups: [], + ships: [] + }, + inviteError: false + }; + this.search = this.search.bind(this); + } + + componentDidMount() { + this.peerUpdate(); + } + + componentDidUpdate(prevProps) { + if (prevProps !== this.props) { + this.peerUpdate(); + } + } + + peerUpdate() { + let groups = Array.from(Object.keys(this.props.groups)); + groups = groups.filter(e => !e.startsWith("/~/")); + + let peers = [], + peerSet = new Set(), + contacts = new Map; + Object.keys(this.props.groups).map(group => { + if (this.props.groups[group].size > 0) { + let groupEntries = this.props.groups[group].values(); + for (let member of groupEntries) { + peerSet.add(member); + } + } + if (this.props.contacts[group]) { + let groupEntries = this.props.groups[group].values(); + for (let member of groupEntries) { + if (this.props.contacts[group][member]) { + if (contacts.has(member)) { + contacts.get(member).push(this.props.contacts[group][member].nickname); + } + else { + contacts.set(member, [this.props.contacts[group][member].nickname]); + } + } + } + } + }); + peers = Array.from(peerSet); + + this.setState({ groups: groups, peers: peers, contacts: contacts }); + } + + search(event) { + let searchTerm = event.target.value.toLowerCase().replace("~", ""); + + this.setState({ searchValue: event.target.value }); + + if (searchTerm.length < 2) { + this.setState({ searchResults: { groups: [], ships: [] } }); + } + + if (searchTerm.length > 2) { + if (this.state.inviteError === true) { + this.setState({ inviteError: false }); + } + + let groupMatches = []; + if (this.props.groupResults) { + groupMatches = this.state.groups.filter(e => { + return e.includes(searchTerm); + }); + } + + let shipMatches = this.state.peers.filter(e => { + return e.includes(searchTerm) && !this.props.invites.ships.includes(e); + }); + + for (let contact of this.state.contacts.keys()) { + let thisContact = this.state.contacts.get(contact); + let match = thisContact.filter(e => { + return e.toLowerCase().includes(searchTerm); + }); + if (match.length > 0) { + if (!(contact in shipMatches)) { + shipMatches.push(contact); + } + } + } + + this.setState({ + searchResults: { groups: groupMatches, ships: shipMatches } + }); + + let isValid = true; + if (!urbitOb.isValidPatp("~" + searchTerm)) { + isValid = false; + } + + if (shipMatches.length === 0 && isValid) { + shipMatches.push(searchTerm); + this.setState({ + searchResults: { groups: groupMatches, ships: shipMatches } + }); + } + } + } + + deleteGroup() { + let { ships } = this.props.invites; + this.setState({ + searchValue: "", + searchResults: { groups: [], ships: [] } + }); + this.props.setInvite({ groups: [], ships: ships }); + } + + deleteShip(ship) { + let { groups, ships } = this.props.invites; + this.setState({ + searchValue: "", + searchResults: { groups: [], ships: [] } + }); + ships = ships.filter(e => { + return e !== ship; + }); + this.props.setInvite({ groups: groups, ships: ships }); + } + + addGroup(group) { + this.setState({ + searchValue: "", + searchResults: { groups: [], ships: [] } + }); + this.props.setInvite({ groups: [group], ships: [] }); + } + + addShip(ship) { + let { groups, ships } = this.props.invites; + this.setState({ + searchValue: "", + searchResults: { groups: [], ships: [] } + }); + if (!ships.includes(ship)) { + ships.push(ship); + } + if (groups.length > 0) { + return false; + } + this.props.setInvite({ groups: groups, ships: ships }); + } + + submitShipToAdd(ship) { + let searchTerm = ship + .toLowerCase() + .replace("~", "") + .trim(); + let isValid = true; + if (!urbitOb.isValidPatp("~" + searchTerm)) { + isValid = false; + } + if (!isValid) { + this.setState({ inviteError: true, searchValue: "" }); + } else if (isValid) { + this.addShip(searchTerm); + this.setState({ searchValue: "" }); + } + } + + render() { + const { props, state } = this; + let searchDisabled = false; + if (props.invites.groups) { + if (props.invites.groups.length > 0) { + searchDisabled = true; + } + } + + let participants =
; + let searchResults =
; + + let invErrElem = ; + if (state.inviteError) { + invErrElem = ( + + Invited ships must be validly formatted ship names. + + ); + } + + if ( + state.searchResults.groups.length > 0 || + state.searchResults.ships.length > 0 + ) { + let groupHeader = + state.searchResults.groups.length > 0 ? ( +

Groups

+ ) : ( + "" + ); + + let shipHeader = + state.searchResults.ships.length > 0 ? ( +

Ships

+ ) : ( + "" + ); + + let groupResults = state.searchResults.groups.map(group => { + return ( +
  • this.addGroup(group)}> + {group} +
  • + ); + }); + + let shipResults = state.searchResults.ships.map(ship => { + let nicknames = (this.state.contacts.has(ship)) + ? this.state.contacts.get(ship).join(", ") + : ""; + return ( +
  • this.addShip(ship)}> + + {"~" + ship} + {nicknames} +
  • + ); + }); + + searchResults = ( +
    + {groupHeader} + {groupResults} + {shipHeader} + {shipResults} +
    + ); + } + + let groupInvites = props.invites.groups || []; + let shipInvites = props.invites.ships || []; + + if (groupInvites.length > 0 || shipInvites.length > 0) { + let groups = groupInvites.map(group => { + return ( + + {group} + this.deleteGroup(group)}> + x + + + ); + }); + + let ships = shipInvites.map(ship => { + return ( + + {"~" + ship} + this.deleteShip(ship)}> + x + + + ); + }); + + participants = ( +
    + Participants + {groups} {ships} +
    + ); + } + + return ( +
    + +