Merge pull request #2378 from urbit/m/link-meta

link: metadata integration
This commit is contained in:
ixv 2020-03-05 15:09:59 -08:00 committed by GitHub
commit 3c039bce39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 2425 additions and 358 deletions

View File

@ -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)
==
--

View File

@ -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)
==

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 B

View File

@ -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])

View File

@ -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]

View File

@ -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)
==
--

View File

@ -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
--
--

View File

@ -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)]
==
--

View File

@ -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;
}

View File

@ -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) {

View File

@ -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 (
<ChannelsItem
key={path}
link={path}
memberList={props.groups[path]}
selected={selected}
linkCount={linkCount}
unseenCount={unseenCount}
name={name}/>
)
})
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 (
<ChannelsItem
key={path}
link={path}
memberList={props.groups[path]}
selected={selected}
linkCount={linkCount}
unseenCount={unseenCount}
name={name}/>
)
<SidebarInvite
uid={uid}
invite={props.invites[uid]}
api={props.api} />
);
});
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 (
<ChannelsItem
key={path}
link={path}
memberList={props.groups[meta.group]}
selected={selected}
linkCount={linkCount}
unseenCount={unseenCount}
name={meta.title}/>
);
});
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")}>
<a className="db dn-m dn-l dn-xl f8 pb3 pl3" href="/"> Landscape</a>
<div className="overflow-y-scroll h-100">
<h2 className={"f8 f9-m f9-l f9-xl " +
"pt1 pt4-m pt4-l pt4-xl " +
"pr4 pb3 pb3-m pb3-l pb3-xl " +
"pl3 pl4-m pl4-l pl4-xl " +
"black-s gray2 white-d c-default " +
"bb b--gray4 b--gray2-d mb2 mb0-m mb0-l mb0-xl"}>
Your Collections
</h2>
{privateChannel}
<div className="w-100 bg-transparent pa4 bb b--gray4 b--gray1-d"
style={{paddingBottom: 10, paddingTop: 10}}>
<Link
className="dib f9 pointer green2 gray4-d mr4"
to={"/~link/new"}>
New Collection
</Link>
</div>
{sidebarInvites}
{channelItems}
</div>
</div>

View File

@ -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 (
<Link to={"/~link" + props.link}>
<Link to={makeRoutePath(props.link)}>
<div className={"w-100 v-mid f9 pl4 bb z1 pa3 pt4 pb4 b--gray4 b--gray1-d gray3-d pointer " + selectedClass}>
<p className="f9 pt1">{props.name}</p>
<p className="f9 gray2">

View File

@ -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 (
<div className="w-100 relative pt4 pb6">
<Link
className={"pb6 absolute inter f8 left-0 " + prevDisplay}
to={"/~link"
+ popout
+ props.groupPath
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/" + encodedUrl
+ "/comments" + prevPage}>
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={"/~link"
+ popout
+ props.groupPath
+ "/" + props.linkPage
+ "/" + props.linkIndex
+ "/" + encodedUrl
+ "/comments" + nextPage}>
to={makeRoutePath(props.resourcePath, props.popout, props.linkPage, props.url, props.linkIndex, nextPage)}>
Next Page ->
</Link>
</div>

View File

@ -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 {
<div>
{commentsList}
<CommentsPagination
key={props.groupPath + props.commentPage}
groupPath={props.groupPath}
key={props.resourcePath + props.commentPage}
resourcePath={props.resourcePath}
popout={props.popout}
linkPage={props.linkPage}
linkIndex={props.linkIndex}

View File

@ -0,0 +1,74 @@
import React, { Component } from 'react';
import { InviteSearch } from './invite-search';
export class InviteElement extends Component {
constructor(props) {
super(props);
this.state = {
members: [],
error: false,
success: false
};
this.setInvite = this.setInvite.bind(this);
}
modifyMembers() {
const { props, state } = this;
let aud = state.members.map(mem => `~${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 (
<div>
<InviteSearch
groups={{}}
contacts={props.contacts}
groupResults={false}
invites={{
groups: [],
ships: this.state.members
}}
setInvite={this.setInvite}
/>
<button
onClick={this.modifyMembers.bind(this)}
className={modifyButtonClasses}>
Invite
</button>
</div>
);
}
}

View File

@ -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 = <div />;
let searchResults = <div />;
let invErrElem = <span />;
if (state.inviteError) {
invErrElem = (
<span className="f9 inter red2 db pt2">
Invited ships must be validly formatted ship names.
</span>
);
}
if (
state.searchResults.groups.length > 0 ||
state.searchResults.ships.length > 0
) {
let groupHeader =
state.searchResults.groups.length > 0 ? (
<p className="f9 gray2 ph3 pb2">Groups</p>
) : (
""
);
let shipHeader =
state.searchResults.ships.length > 0 ? (
<p className="f9 gray2 pv2 ph3">Ships</p>
) : (
""
);
let groupResults = state.searchResults.groups.map(group => {
return (
<li
key={group}
className={
"list mono white-d f8 pv2 ph3 pointer" +
" hover-bg-gray4 hover-black-d"
}
onClick={e => this.addGroup(group)}>
<span className="mix-blend-diff black">{group}</span>
</li>
);
});
let shipResults = state.searchResults.ships.map(ship => {
let nicknames = (this.state.contacts.has(ship))
? this.state.contacts.get(ship).join(", ")
: "";
return (
<li
key={ship}
className={
"list mono white-d f8 pv1 ph3 pointer" +
" hover-bg-gray4 hover-black-d relative"
}
onClick={e => this.addShip(ship)}>
<Sigil
ship={"~" + ship}
size={24}
color="#000000"
classes="mix-blend-diff v-mid"
/>
<span className="v-mid ml2 mw5 truncate dib">{"~" + ship}</span>
<span className="absolute right-1 di truncate mw4 inter f9 pt1">{nicknames}</span>
</li>
);
});
searchResults = (
<div
className={
"absolute bg-white bg-gray0-d white-d" +
" pv3 z-1 w-100 mt1 ba b--white-d overflow-y-scroll mh-16"
}>
{groupHeader}
{groupResults}
{shipHeader}
{shipResults}
</div>
);
}
let groupInvites = props.invites.groups || [];
let shipInvites = props.invites.ships || [];
if (groupInvites.length > 0 || shipInvites.length > 0) {
let groups = groupInvites.map(group => {
return (
<span
key={group}
className={
"f9 mono black pa2 bg-gray5 bg-gray1-d" +
" ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default"
}>
{group}
<span
className="white-d ml3 mono pointer"
onClick={e => this.deleteGroup(group)}>
x
</span>
</span>
);
});
let ships = shipInvites.map(ship => {
return (
<span
key={ship}
className={
"f9 mono black pa2 bg-gray5 bg-gray1-d" +
" ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default"
}>
{"~" + ship}
<span
className="white-d ml3 mono pointer"
onClick={e => this.deleteShip(ship)}>
x
</span>
</span>
);
});
participants = (
<div
className={
"f9 gray2 bb bl br b--gray3 b--gray2-d bg-gray0-d " +
"white-d pa3 db w-100 inter"
}>
<span className="db gray2">Participants</span>
{groups} {ships}
</div>
);
}
return (
<div className="relative">
<img
src="/~link/img/search.png"
className="absolute invert-d"
style={{
height: 16,
width: 16,
top: 14,
left: 12
}}
/>
<textarea
ref={e => {
this.textarea = e;
}}
className={
"f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 w-100" +
" db focus-b--black focus-b--white-d"
}
placeholder="Search for ships or existing groups"
disabled={searchDisabled}
rows={1}
spellCheck={false}
style={{
resize: "none",
paddingLeft: 36
}}
onKeyPress={e => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
this.submitShipToAdd(this.state.searchValue);
}
}}
onChange={this.search}
value={state.searchValue}
/>
{searchResults}
{participants}
{invErrElem}
</div>
);
}
}
export default InviteSearch;

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Route, Link } from "react-router-dom";
import { base64urlEncode } from "../../lib/util";
import { makeRoutePath } from "../../lib/util";
import moment from "moment";
export class LinkPreview extends Component {
@ -114,16 +114,7 @@ export class LinkPreview extends Component {
{this.state.timeSinceLinkPost}
</span>
<Link
to={
"/~link" +
props.groupPath +
"/" +
props.page +
"/" +
props.linkIndex +
"/" +
base64urlEncode(props.url)
}
to={makeRoutePath(props.resourcePath, props.popout, props.page, props.url, props.linkIndex)}
className="v-top">
<span className="f9 inter gray2">{props.comments}</span>
</Link>

View File

@ -3,7 +3,7 @@ import moment from 'moment';
import { Sigil } from '/components/lib/icons/sigil';
import { Route, Link } from 'react-router-dom';
import { base64urlEncode } from '../../lib/util';
import { makeRoutePath } from '../../lib/util';
export class LinkItem extends Component {
constructor(props) {
@ -34,7 +34,7 @@ export class LinkItem extends Component {
}
markPostAsSeen() {
api.seenLink(this.props.groupPath, this.props.url);
api.seenLink(this.props.resourcePath, this.props.url);
}
render() {
@ -58,8 +58,6 @@ export class LinkItem extends Component {
hostname = hostname[4];
}
let encodedUrl = base64urlEncode(props.url);
let comments = props.comments + " comment" + ((props.comments === 1) ? "" : "s");
let member = this.props.member || false;
@ -90,7 +88,7 @@ export class LinkItem extends Component {
{this.state.timeSinceLinkPost}
</span>
<Link to=
{"/~link" + props.popout + props.groupPath + "/" + props.page + "/" + props.linkIndex + "/" + encodedUrl}
{makeRoutePath(props.resourcePath, props.popout, props.page, props.url, props.linkIndex)}
className="v-top"
onClick={this.markPostAsSeen}>
<span className="f9 inter gray2">

View File

@ -20,7 +20,7 @@ export class LinkSubmit extends Component {
? this.state.linkTitle
: this.state.linkValue;
api.setSpinner(true);
api.postLink(this.props.groupPath, link, title).then(r => {
api.postLink(this.props.resourcePath, link, title).then(r => {
api.setSpinner(false);
this.setState({ linkValue: "", linkTitle: "" });
});

View File

@ -1,44 +1,54 @@
import React, { Component } from 'react'
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { makeRoutePath } from '../../lib/util';
export class LinksTabBar extends Component {
render() {
let props = this.props;
let memColor = '',
popout = '';
setColor = '';
if (props.location.pathname.includes('/members')) {
memColor = 'black';
if (props.location.pathname.includes('/settings')) {
memColor = 'gray3';
setColor = 'black white-d';
} else if (props.location.pathname.includes('/members')) {
memColor = 'black white-d';
setColor = 'gray3';
} else {
memColor = 'gray3';
memColor = 'gray3';
setColor = 'gray3';
}
(props.location.pathname.includes('/popout'))
? popout = "popout/"
: popout = "";
let hidePopoutIcon = (this.props.popout)
let hidePopoutIcon = (props.popout)
? "dn-m dn-l dn-xl"
: "dib-m dib-l dib-xl";
return (
<div className="dib pt2 flex-shrink-0 flex-grow-1">
{!!props.isOwner ? (
<div className={"dib f8 pl6"}>
<div className="dib flex-shrink-0 flex-grow-1">
{!!props.amOwner ? (
<div className={"dib pt2 f9 pl6 lh-solid"}>
<Link
className={"no-underline " + memColor}
to={`/~link/` + popout + `members` + props.groupPath}>
to={makeRoutePath(props.resourcePath, props.popout) + '/members'}>
Members
</Link>
</div>
) : (
<div className="dib" style={{ width: 0 }}></div>
)}
<a href={`/~link/popout` + props.groupPath} target="_blank"
className="dib fr">
<div className={"dib pt2 f9 pl6 pr6 lh-solid"}>
<Link
className={"no-underline " + setColor}
to={makeRoutePath(props.resourcePath, props.popout) + '/settings'}>
Settings
</Link>
</div>
<a href={makeRoutePath(props.resourcePath, true, props.page)}
target="_blank"
className="dib fr pt2 pr1">
<img
className={`flex-shrink-0 pr4 dn` + hidePopoutIcon}
className={`flex-shrink-0 pr3 dn ` + hidePopoutIcon}
src="/~link/img/popout.png"
height="16"
width="16"/>

View File

@ -0,0 +1,52 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import { Sigil } from '/components/lib/icons/sigil';
import { uxToHex } from '/lib/util';
export class MemberElement extends Component {
onRemove() {
const { props } = this;
//TODO don't really need to use link-view here, but should we anyway?
api.groups.remove(props.groupPath, [`~${props.ship}`]);
}
render() {
const { props } = this;
let actionElem;
if (props.ship === props.owner) {
actionElem = (
<p className="w-20 dib list-ship black white-d f8 c-default">
Host
</p>
);
} else if (props.amOwner && window.ship !== props.ship) {
actionElem = (
<a onClick={this.onRemove.bind(this)}
className="w-20 dib list-ship black white-d f8 pointer">
Ban
</a>
);
} else {
actionElem = (
<span></span>
);
}
let name = !!props.contact
? `${props.contact.nickname} (~${props.ship})` : `~${props.ship}`;
let color = !!props.contact ? uxToHex(props.contact.color) : '000000';
return (
<div className="flex mb2">
<Sigil ship={props.ship} size={32} color={`#${color}`} />
<p className={
"w-70 mono list-ship dib v-mid black white-d ml2 nowrap f8"
}>{name}</p>
{actionElem}
</div>
);
}
}

View File

@ -1,30 +1,31 @@
import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import { makeRoutePath } from '../../lib/util';
export class Pagination extends Component {
render() {
let props = this.props;
let prevPage = "/" + (Number(props.page) - 1);
let nextPage = "/" + (Number(props.page) + 1);
let prevPage = (Number(props.page) - 1);
let nextPage = (Number(props.page) + 1);
let prevDisplay = ((props.currentPage > 0))
? "dib absolute left-0"
: "dn";
let nextDisplay = ((props.currentPage + 1) < props.totalPages)
? "dib absolute right-0"
let nextDisplay = ((props.currentPage + 1) < props.totalPages)
? "dib absolute right-0"
: "dn";
return (
<div className="w-100 inter relative pv6">
<div className={prevDisplay + " inter f8"}>
<Link to={"/~link" + props.popout + props.groupPath + prevPage}>
<Link to={makeRoutePath(props.resourcePath, props.popout, prevPage)}>
&#60;- Previous Page
</Link>
</div>
<div className={nextDisplay + " inter f8"}>
<Link to={"/~link" + props.popout + props.groupPath + nextPage}>
<Link to={makeRoutePath(props.resourcePath, props.popout, nextPage)}>
Next Page ->
</Link>
</div>

View File

@ -0,0 +1,39 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import _ from 'lodash';
export class SidebarInvite extends Component {
onAccept() {
api.invite.accept(this.props.uid);
}
onDecline() {
api.invite.decline(this.props.uid);
}
render() {
const { props } = this;
return (
<div className='w-100 bg-transparent pa4 bb b--gray4 b--gray1-d'>
<div className='w-100 v-mid'>
<p className="dib f8 mono gray4-d">
{props.invite.text}
</p>
</div>
<a
className="dib pointer pa2 f9 bg-green2 white mt4"
onClick={this.onAccept.bind(this)}>
Accept Invite
</a>
<a
className="dib pointer ml4 pa2 f9 bg-black bg-gray0-d white mt4"
onClick={this.onDecline.bind(this)}>
Decline
</a>
</div>
)
}
}

View File

@ -5,7 +5,8 @@ import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
import { api } from '../api';
import { Route, Link } from 'react-router-dom';
import { Comments } from './lib/comments';
import { getContactDetails } from '../lib/util';
import { LoadingScreen } from './loading';
import { makeRoutePath, getContactDetails } from '../lib/util';
export class LinkDetail extends Component {
constructor(props) {
@ -29,7 +30,7 @@ export class LinkDetail extends Component {
// if we have no preloaded data, and we aren't expecting it, get it
if (!this.state.data.title) {
api.getSubmission(
this.props.groupPath, this.props.url, this.updateData.bind(this)
this.props.resourcePath, this.props.url, this.updateData.bind(this)
);
}
}
@ -46,7 +47,7 @@ export class LinkDetail extends Component {
api.setSpinner(true);
api.postComment(
this.props.groupPath,
this.props.resourcePath,
url,
this.state.comment
).then(() => {
@ -62,9 +63,13 @@ export class LinkDetail extends Component {
render() {
let props = this.props;
let popout = (props.popout) ? "/popout" : "";
const data = this.state.data || props.data;
if (!data.ship) {
return <LoadingScreen/>;
}
let ship = data.ship || "zod";
let title = data.title || "";
let url = data.url || "";
@ -98,10 +103,10 @@ export class LinkDetail extends Component {
/>
<Link
className="dib f9 fw4 pt2 gray2 lh-solid"
to={"/~link" + popout + props.groupPath + "/" + props.page}>
{"<- Collection index"}
to={makeRoutePath(props.resourcePath, props.popout, props.page)}>
{`<- ${props.resource.title} index`}
</Link>
<LinksTabBar {...props} popout={popout} groupPath={props.groupPath} />
<LinksTabBar {...props} popout={props.popout} resourcePath={props.resourcePath} />
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
@ -111,7 +116,7 @@ export class LinkDetail extends Component {
comments={comments}
nickname={nickname}
ship={ship}
groupPath={props.groupPath}
resourcePath={props.resourcePath}
page={props.page}
linkIndex={props.linkIndex}
time={this.state.data.time}
@ -143,8 +148,8 @@ export class LinkDetail extends Component {
</button>
</div>
<Comments
groupPath={props.groupPath}
key={props.groupPath + props.commentPage}
resourcePath={props.resourcePath}
key={props.resourcePath + props.commentPage}
comments={props.comments}
commentPage={props.commentPage}
contacts={props.contacts}

View File

@ -1,4 +1,5 @@
import React, { Component } from 'react'
import { LoadingScreen } from './loading';
import { LinksTabBar } from './lib/links-tabbar';
import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
import { Route, Link } from "react-router-dom";
@ -6,7 +7,7 @@ import { LinkItem } from '/components/lib/link-item.js';
import { LinkSubmit } from '/components/lib/link-submit.js';
import { Pagination } from '/components/lib/pagination.js';
import { getContactDetails } from '../lib/util';
import { makeRoutePath, getContactDetails } from '../lib/util';
//TODO Avatar support once it's in
export class Links extends Component {
@ -25,17 +26,21 @@ export class Links extends Component {
(!this.props.links[linkPage] ||
this.props.links.local[linkPage])
) {
api.getPage(this.props.groupPath, this.props.page);
api.getPage(this.props.resourcePath, this.props.page);
}
}
markAllAsSeen() {
api.seenLink(this.props.groupPath);
api.seenLink(this.props.resourcePath);
}
render() {
let props = this.props;
let popout = (props.popout) ? "/popout" : "";
if (!props.resource.title) {
return <LoadingScreen/>;
}
let linkPage = props.page;
let links = !!props.links[linkPage]
@ -77,8 +82,8 @@ export class Links extends Component {
color={color}
member={member}
comments={commentCount}
groupPath={props.groupPath}
popout={popout}
resourcePath={props.resourcePath}
popout={props.popout}
/>
)
})
@ -89,36 +94,31 @@ export class Links extends Component {
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}>
<Link to="/~link/">{"⟵ All Channels"}</Link>
<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 bn-m bn-l bn-xl b--gray4 b--gray1-d bg-gray0-d`}
bb b--gray4 b--gray1-d bg-gray0-d`}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}/>
<Link to={`/~link` + popout + props.groupPath} className="pt2">
<h2
className={`dib f9 fw4 v-top lh-solid` +
(props.groupPath.includes("/~/")
? ""
: " mono")}>
{(props.groupPath.includes("/~/"))
? "Private"
: props.groupPath.substr(1)}
<Link to={makeRoutePath(props.resourcePath, props.popout, props.page)} className="pt2">
<h2 className={`dib f9 fw4 v-top`}>
{props.resource.title}
</h2>
</Link>
<LinksTabBar
{...props}
popout={popout}
groupPath={props.groupPath + "/" + props.page}/>
popout={props.popout}
page={props.page}
resourcePath={props.resourcePath}/>
</div>
<div className="w-100 mt2 flex justify-center overflow-y-scroll ph4 pb4">
<div className="w-100 mw7">
<div className="flex">
<LinkSubmit groupPath={props.groupPath}/>
<LinkSubmit resourcePath={props.resourcePath}/>
</div>
<div className="pb4">
<span
@ -129,9 +129,9 @@ export class Links extends Component {
{LinkList}
<Pagination
{...props}
key={props.groupPath + props.page}
popout={popout}
groupPath={props.groupPath}
key={props.resourcePath + props.page}
popout={props.popout}
resourcePath={props.resourcePath}
currentPage={currentPage}
totalPages={totalPages}
/>

View File

@ -0,0 +1,15 @@
import React, { Component } from 'react';
export class LoadingScreen extends Component {
render() {
return (
<div className="h-100 w-100 overflow-x-hidden flex flex-column bg-white bg-gray0-d dn db-ns">
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
Loading...
</p>
</div>
</div>
);
}
}

View File

@ -0,0 +1,106 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import { Link } from 'react-router-dom';
import { store } from '/store';
import urbitOb from 'urbit-ob';
import { LoadingScreen } from './loading';
import { LinksTabBar } from '/components/lib/links-tabbar';
import { MemberElement } from '/components/lib/member-element';
import { InviteElement } from '/components/lib/invite-element';
import { SidebarSwitcher } from '/components/lib/icons/icon-sidebar-switch.js';
import { makeRoutePath } from '/lib/util';
export class MemberScreen extends Component {
render() {
const { props, state } = this;
if (!props.groupPath) {
return <LoadingScreen/>;
}
const isManaged = ('/~/' !== props.groupPath.slice(0,3));
let members = Array.from(props.group).map((mem) => {
let contact = (mem in props.contactDetails)
? props.contactDetails[mem] : false;
return (
<MemberElement
key={mem}
amOwner={props.amOwner}
contact={contact}
ship={mem}
groupPath={props.groupPath}
resourcePath={props.resourcePath}
/>
);
});
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--gray1-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}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout)}
className="pt2 white-d">
<h2
className="dib f9 fw4 v-top"
style={{ width: "max-content" }}>
{props.resource.title}
</h2>
</Link>
<LinksTabBar
{...props}
groupPath={props.groupPath}
resourcePath={props.resourcePath}
amOwner={props.amOwner}
popout={props.popout}
/>
</div>
<div className="w-100 pl3 mt0 mt4-m mt4-l mt4-xl cf pr6">
<div className="w-100 w-50-l w-50-xl fl pa2 pr3 pt3 pt0-l pt0-xl">
<p className="f8 pb2">Members</p>
<p className="f9 gray2 mb3">
{ 'Everyone with permission to use this collection.' +
((isManaged && props.amOwner)
? ' Removing someone removes them from the group.'
: '')
}
</p>
{members}
</div>
{ !props.amOwner ? null : (
<div className="w-100 w-50-l w-50-xl fl pa2 pr3 pt3 pt0-l pt0-xl">
<p className="f8 pb2">Modify Permissions</p>
<p className="f9 gray2 mb3">
{ 'Invite someone to this collection.' +
(isManaged
? ' Adding someone adds them to the group.'
: '')
}
</p>
<InviteElement
groupPath={props.groupPath}
resourcePath={props.resourcePath}
permissions={props.permission}
contacts={props.contacts}
/>
</div>
)}
</div>
</div>
);
}
}

View File

@ -0,0 +1,239 @@
import React, { Component } from 'react';
import { InviteSearch } from './lib/invite-search';
import { Route, Link } from 'react-router-dom';
import { makeRoutePath, isPatTa, deSig } from '/lib/util';
import urbitOb from 'urbit-ob';
export class NewScreen extends Component {
constructor(props) {
super(props);
this.state = {
title: '',
description: '',
idName: '',
groups: [],
ships: [],
idError: false,
inviteError: false,
createGroup: true
};
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.setInvite = this.setInvite.bind(this);
this.createGroupChange = this.createGroupChange.bind(this);
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (prevProps !== props) {
let target = `/${state.idName}`;
if (target in props.resources) {
props.history.push(makeRoutePath(target));
}
}
}
titleChange(event) {
let 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
});
}
createGroupChange(event) {
this.setState({
createGroup: !!event.target.checked,
});
}
onClickCreate() {
const { props, state } = this;
if (!state.title) {
this.setState({
idError: true,
inviteError: false
});
return;
}
let appPath = `/${state.idName}`;
if (appPath in props.resources) {
this.setState({
inviteError: false,
idError: true,
success: false
});
return;
}
let isValid = true;
let 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: []
}, () => {
api.setSpinner(true);
let submit = api.createCollection(
appPath,
state.title,
state.description,
target,
state.createGroup
);
submit.then(() => {
api.setSpinner(false);
props.history.push(makeRoutePath(appPath));
})
});
}
render() {
const { props, state } = this;
let createGroupClasses = state.createGroup
? "relative checked bg-green2 br3 h1 toggle v-mid z-0"
: "relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0";
let 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";
let 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>
);
}
let createGroupToggle = <div/>
if (state.groups.length === 0) {
createGroupToggle = (
<div className="mt7">
<input
type="checkbox"
style={{ WebkitAppearance: "none", width: 28 }}
className={createGroupClasses}
onChange={this.createGroupChange}
/>
<span className="dib f9 white-d inter ml3">Create Group</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Participants will share this group across applications
</p>
</div>
);
}
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}
/>
<p className="f8 mt4 lh-copy db">
Invite
<span className="gray3"> (Optional)</span>
</p>
<p className="f9 gray2 db mb2 pt1">
Selected entities will be able to post to the collection
</p>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
groupResults={true}
invites={{
groups: state.groups,
ships: state.ships
}}
setInvite={this.setInvite}
/>
{createGroupToggle}
<button
onClick={this.onClickCreate.bind(this)}
className={createClasses}>
Create Collection
</button>
</div>
</div>
);
}
}

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { BrowserRouter, Route, Link } from "react-router-dom";
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
import classnames from 'classnames';
import _ from 'lodash';
@ -7,10 +7,17 @@ import { api } from '/api';
import { subscription } from '/subscription';
import { store } from '/store';
import { Skeleton } from '/components/skeleton';
import { NewScreen } from '/components/new';
import { MemberScreen } from '/components/member';
import { SettingsScreen } from '/components/settings';
import { Links } from '/components/links-list';
import { LinkDetail } from '/components/link';
import { base64urlDecode } from '../lib/util';
import { makeRoutePath, amOwnerOfGroup, base64urlDecode } from '../lib/util';
//NOTE route paths make the assumption that a resource identifier is always
// just a single /path element. technically, backend supports /longer/paths
// but no tlon-sanctioned frontend creates those right now, so we're opting
// out of supporting them completely for the time being.
export class Root extends Component {
constructor(props) {
@ -26,62 +33,169 @@ export class Root extends Component {
let contacts = !!state.contacts ? state.contacts : {};
const groups = !!state.groups ? state.groups : {};
const resources = !!state.resources ? state.resources : {};
let links = !!state.links ? state.links : {};
let comments = !!state.comments ? state.comments : {};
const seen = !!state.seen ? state.seen : {};
const invites = '/link' in state.invites ?
state.invites['/link'] : {};
return (
<BrowserRouter>
<BrowserRouter><Switch>
<Route exact path="/~link"
render={ (props) => {
return (
<Skeleton
active="channels"
active="collections"
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
rightPanelHide={true}
sidebarShown={true}
sidebarShown={state.sidebarShown}
links={links}>
<div className="h-100 w-100 overflow-x-hidden flex flex-column bg-white bg-gray0-d dn db-ns">
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
<p className="f8 pt3 gray2 w-100 h-100 dtc v-mid tc">
Collections are shared across groups. To create a new collection, <a className="black white-d" href="/~contacts">create a group</a>.
Select or create a collection to begin.
</p>
</div>
</div>
</Skeleton>
);
}} />
<Route exact path="/~link/(popout)?/:ship/:channel/:page?"
render={ (props) => {
// groups/contacts and link channels are the same thing in ver 1
<Route exact path="/~link/new"
render={(props) => {
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
rightPanelHide={true}
sidebarShown={state.sidebarShown}
links={links}>
<NewScreen
resources={resources}
groups={groups}
contacts={contacts}
{...props}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~link/join/:resource"
render={ (props) => {
const resourcePath = '/' + props.match.params.resource;
props.history.push(makeRoutePath(resourcePath));
}}
/>
<Route exact path="/~link/(popout)?/:resource/members"
render={(props) => {
const popout = props.match.url.includes("/popout/");
const resourcePath = '/' + props.match.params.resource;
const resource = resources[resourcePath] || {};
let groupPath =
`/${props.match.params.ship}/${props.match.params.channel}`;
let contactDetails = contacts[groupPath] || {};
const contactDetails = contacts[resource.group] || {};
const group = groups[resource.group] || new Set([]);
const amOwner = amOwnerOfGroup(resource.group);
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
selected={resourcePath}
rightPanelHide={true}
sidebarShown={state.sidebarShown}
links={links}>
<MemberScreen
sidebarShown={state.sidebarShown}
resource={resource}
contacts={contacts}
contactDetails={contactDetails}
groupPath={resource.group}
group={group}
amOwner={amOwner}
resourcePath={resourcePath}
popout={popout}
{...props}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~link/(popout)?/:resource/settings"
render={ (props) => {
const popout = props.match.url.includes("/popout/");
const resourcePath = '/' + props.match.params.resource;
const resource = resources[resourcePath] || false;
const contactDetails = contacts[resource.group] || {};
const group = groups[resource.group] || new Set([]);
const amOwner = amOwnerOfGroup(resource.group);
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
selected={resourcePath}
rightPanelHide={true}
sidebarShown={state.sidebarShown}
links={links}>
<SettingsScreen
sidebarShown={state.sidebarShown}
resource={resource}
contacts={contacts}
contactDetails={contactDetails}
groupPath={resource.group}
group={group}
amOwner={amOwner}
resourcePath={resourcePath}
popout={popout}
{...props}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~link/(popout)?/:resource/:page?"
render={ (props) => {
const resourcePath = '/' + props.match.params.resource;
const resource = resources[resourcePath] || {};
const amOwner = amOwnerOfGroup(resource.group);
let contactDetails = contacts[resource.group] || {};
let page = props.match.params.page || 0;
let popout = props.match.url.includes("/popout/");
let channelLinks = !!links[groupPath]
? links[groupPath]
let channelLinks = !!links[resourcePath]
? links[resourcePath]
: {local: {}};
let channelComments = !!comments[groupPath]
? comments[groupPath]
let channelComments = !!comments[resourcePath]
? comments[resourcePath]
: {};
const channelSeen = !!seen[groupPath]
? seen[groupPath]
const channelSeen = !!seen[resourcePath]
? seen[resourcePath]
: {};
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
active="links"
selected={groupPath}
selected={resourcePath}
sidebarShown={state.sidebarShown}
sidebarHideMobile={true}
popout={popout}
@ -93,7 +207,9 @@ export class Root extends Component {
comments={channelComments}
seen={channelSeen}
page={page}
groupPath={groupPath}
resourcePath={resourcePath}
resource={resource}
amOwner={amOwner}
popout={popout}
sidebarShown={state.sidebarShown}
/>
@ -101,47 +217,53 @@ export class Root extends Component {
)
}}
/>
<Route exact path="/~link/(popout)?/:ship/:channel/:page/:index/:encodedUrl/(comments)?/:commentpage?"
<Route exact path="/~link/(popout)?/:resource/:page/:index/:encodedUrl/:commentpage?"
render={ (props) => {
let groupPath =
`/${props.match.params.ship}/${props.match.params.channel}`;
const resourcePath = '/' + props.match.params.resource;
const resource = resources[resourcePath] || {};
const amOwner = amOwnerOfGroup(resource.group);
let popout = props.match.url.includes("/popout/");
let contactDetails = contacts[groupPath] || {};
let contactDetails = contacts[resource.group] || {};
let index = props.match.params.index || 0;
let page = props.match.params.page || 0;
let url = base64urlDecode(props.match.params.encodedUrl);
let data = !!links[groupPath]
? !!links[groupPath][page]
? links[groupPath][page][index]
let data = !!links[resourcePath]
? !!links[resourcePath][page]
? links[resourcePath][page][index]
: {}
: {};
let coms = !comments[groupPath]
let coms = !comments[resourcePath]
? undefined
: comments[groupPath][url];
: comments[resourcePath][url];
let commentPage = props.match.params.commentpage || 0;
return (
<Skeleton
spinner={state.spinner}
resources={resources}
invites={invites}
groups={groups}
active="links"
selected={groupPath}
selected={resourcePath}
sidebarShown={state.sidebarShown}
sidebarHideMobile={true}
popout={popout}
links={links}>
<LinkDetail
{...props}
resource={resource}
page={page}
url={url}
linkIndex={index}
contacts={contactDetails}
groupPath={groupPath}
resourcePath={resourcePath}
groupPath={resource.group}
amOwner={amOwner}
popout={popout}
sidebarShown={state.sidebarShown}
data={data}
@ -152,7 +274,7 @@ export class Root extends Component {
)
}}
/>
</BrowserRouter>
</Switch></BrowserRouter>
)
}
}

View File

@ -0,0 +1,299 @@
import React, { Component } from 'react';
import classnames from 'classnames';
import { deSig, uxToHex } from '/lib/util';
import { Route, Link } from "react-router-dom";
import { LoadingScreen } from './loading';
import { LinksTabBar } from '/components/lib/links-tabbar';
import SidebarSwitcher from './lib/icons/icon-sidebar-switch';
import { makeRoutePath } from '../lib/util';
export class SettingsScreen extends Component {
constructor(props) {
super(props);
this.state = {
isLoading: false,
title: "",
description: "",
color: ""
};
this.changeTitle = this.changeTitle.bind(this);
this.changeDescription = this.changeDescription.bind(this);
this.changeColor = this.changeColor.bind(this);
this.renderDelete = this.renderDelete.bind(this);
this.renderMetadataSettings = this.renderMetadataSettings.bind(this);
}
componentDidMount() {
if (this.props.resource) {
this.setState({
title: this.props.resource.title,
description: this.props.resource.description,
color: uxToHex(this.props.resource.color || '0x0')
});
}
}
componentDidUpdate(prevProps, prevState) {
const { props, state } = this;
if (!!state.isLoading && !props.resource) {
this.setState({
isLoading: false
}, () => {
api.setSpinner(false);
props.history.push('/~link');
});
}
if (props.resource && (prevProps !== props)) {
this.setState({
title: props.resource.title,
description: props.resource.description,
color: uxToHex(props.resource.color || '0x0')
});
}
}
changeTitle() {
this.setState({title: event.target.value})
}
changeDescription() {
this.setState({description: event.target.value});
}
changeColor() {
this.setState({color: event.target.value});
}
deleteCollection() {
const { props, state } = this;
api.deleteCollection(props.resourcePath);
api.setSpinner(true);
this.setState({
isLoading: true
});
}
renderDelete() {
const { props, state } = this;
const isManaged = ('/~/' !== props.groupPath.slice(0,3));
let deleteButtonClasses = (props.amOwner) ? 'b--red2 red2 pointer bg-gray0-d' : 'b--grey3 grey3 bg-gray0-d c-default';
let leaveButtonClasses = (!props.amOwner) ? "pointer" : "c-default";
let deleteClasses = 'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray0-d pointer';
let deleteText = 'Remove this collection from your collection list.';
let deleteAction = 'Remove';
if (props.amOwner && isManaged) {
deleteText = 'Delete this collection. (All group members will no longer see this chat.)';
deleteAction = 'Delete';
deleteClasses = 'dib f9 ba pa2 b--red2 red2 pointer bg-gray0-d';
}
return (
<div className="w-100 fl mt3">
<p className="f8 mt3 lh-copy db">Delete Collection</p>
<p className="f9 gray2 db mb4">{deleteText}</p>
<a onClick={this.deleteCollection.bind(this)}
className={deleteClasses}>{deleteAction + ' collection'}</a>
</div>
);
}
renderMetadataSettings() {
const { props, state } = this;
const { resource } = props;
return(
<div>
<div className={"w-100 pb6 fl mt3 " + ((props.amOwner) ? '' : 'o-30')}>
<p className="f8 mt3 lh-copy">Rename</p>
<p className="f9 gray2 db mb4">Change the name of this collection</p>
<div className="relative w-100 flex"
style={{maxWidth: "29rem"}}>
<input
className={"f8 ba b--gray3 b--gray2-d bg-gray0-d white-d " +
"focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3"}
value={this.state.title}
disabled={!props.amOwner}
onChange={this.changeTitle}
/>
<span className={"f8 absolute pa3 inter " +
((props.amOwner) ? "pointer" : "")}
style={{ right: 12, top: 1 }}
ref="rename"
onClick={() => {
if (props.amOwner) {
api.setSpinner(true);
api.metadataAdd(
props.resourcePath,
props.groupPath,
state.title,
props.resource.description,
props.resource['date-created'],
uxToHex(props.resource.color)
).then(() => {
api.setSpinner(false);
this.refs.rename.innerText = "Saved";
});
}
}}>
Save
</span>
</div>
<p className="f8 mt3 lh-copy">Change description</p>
<p className="f9 gray2 db mb4">
Change the description of this collection
</p>
<div className="relative w-100 flex"
style={{ maxWidth: "29rem" }}>
<input
className={"f8 ba b--gray3 b--gray2-d bg-gray0-d white-d " +
"focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3"}
value={this.state.description}
disabled={!props.amOwner}
onChange={this.changeDescription}
/>
<span className={"f8 absolute pa3 inter " +
((props.amOwner) ? "pointer" : "")}
style={{ right: 12, top: 1 }}
ref="description"
onClick={() => {
if (props.amOwner) {
api.setSpinner(true);
api.metadataAdd(
props.resourcePath,
props.groupPath,
props.resource.title,
state.description,
props.resource['date-created'],
uxToHex(props.resource.color)
).then(() => {
api.setSpinner(false);
this.refs.description.innerText = "Saved";
});
}
}}>
Save
</span>
</div>
<p className="f8 mt3 lh-copy">Change color</p>
<p className="f9 gray2 db mb4">Give this collection a color when viewing group channels</p>
<div className="relative w-100 flex"
style={{ maxWidth: "20rem" }}>
<input
className={"f8 ba b--gray3 b--gray2-d bg-gray0-d white-d " +
"focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3"}
value={this.state.color}
disabled={!props.amOwner}
onChange={this.changeColor}
/>
<span className={"f8 absolute pa3 inter " +
((props.amOwner) ? "pointer" : "")}
style={{ right: 12, top: 1 }}
ref="color"
onClick={() => {
if (props.amOwner && state.color.match(/[0-9A-F]{6}/i)) {
api.setSpinner(true);
api.metadataAdd(
props.resourcePath,
props.groupPath,
props.resource.title,
props.resource.description,
props.resource['date-created'],
state.color
).then(() => {
api.setSpinner(false);
this.refs.color.innerText = "Saved";
});
}
}}>
Save
</span>
</div>
</div>
</div>
)
}
render() {
const { props, state } = this;
const isinPopout = this.props.popout ? "popout/" : "";
let writeGroup = Array.from(props.group.values());
if (props.groupPath === undefined) {
return <LoadingScreen/>;
}
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}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout)}
className="pt2 white-d">
<h2
className="dib f9 fw4 v-top"
style={{ width: "max-content" }}>
{props.resource.title}
</h2>
</Link>
<LinksTabBar {...props}/>
</div>
<div className="w-100 pl3 mt4 cf">
<h2 className="f8 pb2">Removing...</h2>
</div>
</div>
);
}
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--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}
/>
<Link to={makeRoutePath(props.resourcePath, props.popout)}
className="pt2">
<h2
className="dib f9 fw4 v-top"
style={{ width: "max-content" }}>
{props.resource.title}
</h2>
</Link>
<LinksTabBar {...props}/>
</div>
<div className="w-100 pl3 mt4 cf">
<h2 className="f8 pb2">Collection Settings</h2>
{this.renderDelete()}
{this.renderMetadataSettings()}
</div>
</div>
);
}
}

View File

@ -24,9 +24,11 @@ export class Skeleton extends Component {
<HeaderBar spinner={this.props.spinner} />
<div className={`cf w-100 h-100 flex ` + popoutBorder}>
<ChannelsSidebar
popout={popout}
groups={this.props.groups}
active={this.props.active}
popout={popout}
resources={this.props.resources}
invites={this.props.invites}
groups={this.props.groups}
selected={this.props.selected}
sidebarShown={this.props.sidebarShown}
links={this.props.links}/>

View File

@ -1,17 +1,27 @@
import _ from 'lodash';
import classnames from 'classnames';
export function uuid() {
let str = "0v"
str += Math.ceil(Math.random()*8)+"."
for (var i = 0; i < 5; i++) {
let _str = Math.ceil(Math.random()*10000000).toString(32);
_str = ("00000"+_str).substr(-5,5);
str += _str+".";
export function makeRoutePath(
resource, popout = false, page = 0, url = null, index = 0, compage = 0
) {
let route = '/~link' + (popout ? '/popout' : '') + resource;
if (!url) {
if (page !== 0) {
route = route + '/' + page;
}
} else {
route = `${route}/${page}/${index}/${base64urlEncode(url)}`;
if (compage !== 0) {
route = route + '/' + compage;
}
}
return route;
}
return str.slice(0,-1);
export function amOwnerOfGroup(groupPath) {
if (!groupPath) return false;
const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)[2];
return (window.ship === groupOwner);
}
export function getContactDetails(contact) {

View File

@ -0,0 +1,63 @@
import _ from 'lodash';
export class GroupUpdateReducer {
reduce(json, state) {
let data = _.get(json, 'group-update', false);
if (data) {
this.add(data, state);
this.remove(data, state);
this.bundle(data, state);
this.unbundle(data, state);
this.keys(data, state);
this.path(data, state);
}
}
add(json, state) {
let data = _.get(json, 'add', false);
if (data) {
for (let member of data.members) {
state.groups[data.path].add(member);
}
}
}
remove(json, state) {
let data = _.get(json, 'remove', false);
if (data) {
for (let member of data.members) {
state.groups[data.path].delete(member);
}
}
}
bundle(json, state) {
let data = _.get(json, 'bundle', false);
if (data) {
state.groups[data.path] = new Set();
}
}
unbundle(json, state) {
let data = _.get(json, 'unbundle', false);
if (data) {
delete state.groups[data.path];
}
}
keys(json, state) {
let data = _.get(json, 'keys', false);
if (data) {
state.groupKeys = new Set(data.keys);
}
}
path(json, state) {
let data = _.get(json, 'path', false);
if (data) {
state.groups[data.path] = new Set([data.members]);
}
}
}

View File

@ -11,6 +11,10 @@ export class InviteUpdateReducer {
this.accepted(data, state);
this.decline(data, state);
}
data = _.get(json, 'invite-initial', false);
if (data) {
state.invites = data;
}
}
create(json, state) {
@ -37,7 +41,6 @@ export class InviteUpdateReducer {
accepted(json, state) {
let data = _.get(json, 'accepted', false);
if (data) {
console.log(data);
delete state.invites[data.path][data.uid];
}
}

View File

@ -0,0 +1,69 @@
import _ from 'lodash';
export class MetadataReducer {
reduce(json, state) {
let data = _.get(json, 'metadata-update', false);
if (data) {
this.associations(data, state);
this.add(data, state);
this.remove(data, state);
this.update(data, state);
}
}
associations(json, state) {
let data = _.get(json, 'associations', false);
if (data) {
let metadata = new Map;
Object.keys(data).map((key) => {
let assoc = data[key];
if (assoc['app-name'] !== 'link') {
return;
}
if (state.resources[assoc['app-path']]) {
console.error('beware! overwriting previous data', data['app-path']);
}
state.resources[assoc['app-path']] = {
group: assoc['group-path'],
...assoc.metadata
};
});
}
}
add(json, state) {
let data = _.get(json, 'add', false);
if (data) {
if (state.resources[data['app-path']]) {
console.error('beware! overwriting previous data', data['app-path']);
}
this.update({'update-metadata': data}, state);
}
}
remove(json, state) {
let data = _.get(json, 'remove', false);
if (data) {
if (data['app-name'] !== 'link') {
return;
}
const have = state.resources[data['app-path']];
if (have && have.group === data['group-path']) {
delete state.resources[data['app-path']];
}
}
}
update(json, state) {
let data = _.get(json, 'update-metadata', false);
if (data) {
if (data['app-name'] !== 'link') {
return;
}
state.resources[data['app-path']] = {
group: data['group-path'],
...data.metadata
};
}
}
}

View File

@ -1,6 +1,9 @@
import { InitialReducer } from '/reducers/initial';
import { GroupUpdateReducer } from '/reducers/group-update';
import { ContactUpdateReducer } from '/reducers/contact-update.js';
import { PermissionUpdateReducer } from '/reducers/permission-update';
import { MetadataReducer } from '/reducers/metadata-update.js';
import { InviteUpdateReducer } from '/reducers/invite-update';
import { LinkUpdateReducer } from '/reducers/link-update';
import { LocalReducer } from '/reducers/local.js';
import _ from 'lodash';
@ -11,6 +14,8 @@ class Store {
this.state = {
contacts: {},
groups: {},
resources: {},
invites: {},
links: {},
comments: {},
seen: {},
@ -20,8 +25,11 @@ class Store {
};
this.initialReducer = new InitialReducer();
this.groupUpdateReducer = new GroupUpdateReducer();
this.contactUpdateReducer = new ContactUpdateReducer();
this.permissionUpdateReducer = new PermissionUpdateReducer();
this.metadataReducer = new MetadataReducer();
this.inviteUpdateReducer = new InviteUpdateReducer();
this.localReducer = new LocalReducer();
this.linkUpdateReducer = new LinkUpdateReducer();
this.setState = () => {};
@ -41,8 +49,11 @@ class Store {
console.log('event', json);
this.initialReducer.reduce(json, this.state);
this.groupUpdateReducer.reduce(json, this.state);
this.contactUpdateReducer.reduce(json, this.state);
this.permissionUpdateReducer.reduce(json, this.state);
this.metadataReducer.reduce(json, this.state);
this.inviteUpdateReducer.reduce(json, this.state);
this.localReducer.reduce(json, this.state);
this.linkUpdateReducer.reduce(json, this.state);

View File

@ -11,16 +11,25 @@ export class Subscription {
}
initializeLinks() {
// add invite, permissions flows once link stores are more than
// group-specific
api.bind('/all', 'PUT', api.authTokens.ship, 'group-store',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this)
);
api.bind('/primary', 'PUT', api.authTokens.ship, 'contact-view',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this)
);
api.bind('/primary', 'PUT', api.authTokens.ship, 'invite-view',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this));
api.bind('/app-name/link', 'PUT', api.authTokens.ship, 'metadata-store',
this.handleEvent.bind(this),
this.handleError.bind(this),
this.handleQuitAndResubscribe.bind(this)
);
// open a subscription for all submissions
api.getPage('', 0);