Merge pull request #4342 from urbit/lf/graph-permissioning

graph: permissioning
This commit is contained in:
L 2021-02-02 18:56:38 -06:00 committed by GitHub
commit 453a0af1bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1780 additions and 950 deletions

View File

@ -1,5 +1,7 @@
/- *group
/- metadata=metadata-store
/+ store=graph-store
/+ metadata
/+ mdl=metadata
/+ res=resource
/+ graph
/+ group
@ -20,83 +22,82 @@
::
+$ agent (push-hook:push-hook config)
::
++ is-allowed
|= [=resource:res =bowl:gall requires-admin=?]
^- ?
=/ grp ~(. group bowl)
=/ met ~(. metadata bowl)
=/ group=(unit resource:res)
(peek-group:met %graph resource)
?~ group %.n
?: requires-admin
(is-admin:grp src.bowl u.group)
?| (is-member:grp src.bowl u.group)
(is-admin:grp src.bowl u.group)
==
::
++ is-allowed-remove
|= [=resource:res indices=(set index:store) =bowl:gall]
^- ?
=/ gra ~(. graph bowl)
?. (is-allowed resource bowl %.n)
%.n
%+ levy
~(tap in indices)
|= =index:store
^- ?
=/ =node:store
(got-node:gra resource index)
?| =(author.post.node src.bowl)
(is-allowed resource bowl %.y)
==
+$ state-null ~
+$ state-zero [%0 marks=(set mark)]
+$ versioned-state
$@ state-null
state-zero
--
::
=| state-zero
=* state -
%- agent:dbug
^- agent:gall
%- (agent:push-hook config)
^- agent
=<
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
grp ~(. group bowl)
gra ~(. graph bowl)
hc ~(. +> bowl)
::
++ on-init on-init:def
++ on-save !>(~)
++ on-load on-load:def
++ on-save !>(state)
++ on-load
|= =vase
=+ !<(old=versioned-state vase)
=? old ?=(~ old)
[%0 ~]
?> ?=(%0 -.old)
`this(state old)
::
++ on-poke on-poke:def
++ on-agent on-agent:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?+ wire (on-arvo:def wire sign-arvo)
::
[%perms @ @ ~]
?> ?=(?(%add %remove) i.t.t.wire)
=* mark i.t.wire
:_ this
(build-permissions mark i.t.t.wire %next)^~
==
::
++ on-fail on-fail:def
::
++ should-proxy-update
|= =vase
^- ?
=/ =update:store !<(update:store vase)
=* rid resource.q.update
?- -.q.update
%add-graph (is-allowed resource.q.update bowl %.y)
%remove-graph (is-allowed resource.q.update bowl %.y)
%add-nodes (is-allowed resource.q.update bowl %.n)
%remove-nodes (is-allowed-remove resource.q.update indices.q.update bowl)
%add-signatures (is-allowed resource.uid.q.update bowl %.n)
%remove-signatures (is-allowed resource.uid.q.update bowl %.y)
%archive-graph (is-allowed resource.q.update bowl %.y)
%add-graph %.n
%remove-graph %.n
%add-nodes (is-allowed-add:hc resource.q.update nodes.q.update)
%remove-nodes (is-allowed-remove:hc resource.q.update indices.q.update)
%add-signatures %.n
%remove-signatures %.n
%archive-graph %.n
%unarchive-graph %.n
%add-tag %.n
%remove-tag %.n
%keys %.n
%tags %.n
%tag-queries %.n
%run-updates (is-allowed resource.q.update bowl %.y)
%run-updates %.n
==
::
++ initial-watch
|= [=path =resource:res]
^- vase
?> (is-allowed resource bowl %.n)
?> (is-allowed resource)
!> ^- update:store
?~ path
:: new subscribe
@ -115,6 +116,15 @@
^- [(list card) agent]
=/ =update:store !<(update:store vase)
?+ -.q.update [~ this]
%add-graph
?~ mark.q.update `this
=* mark u.mark.q.update
?: (~(has in marks) mark) `this
:_ this(marks (~(put in marks) mark))
:~ (build-permissions:hc mark %add %sing)
(build-permissions:hc mark %remove %sing)
==
::
%remove-graph
:_ this
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
@ -124,3 +134,138 @@
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
==
--
|_ =bowl:gall
+* grp ~(. group bowl)
met ~(. mdl bowl)
gra ~(. graph bowl)
++ scry
|= [care=@t desk=@t =path]
%+ weld
/[care]/(scot %p our.bowl)/[desk]/(scot %da now.bowl)
path
::
++ scry-mark
|= =resource:res
.^ (unit mark)
(scry %gx %graph-store /graph-mark/(scot %p entity.resource)/[name.resource]/noun)
==
::
++ perm-mark-name
|= perm=@t
^- @t
(cat 3 'graph-permissions-' perm)
::
++ perm-mark
|= [=resource:res perm=@t vip=vip-metadata:metadata =indexed-post:store]
^- permissions:store
=- (check vip)
!< check=$-(vip-metadata:metadata permissions:store)
%. !>(indexed-post)
=/ mark (get-mark:gra resource)
?~ mark |=(=vase !>([%no %no %no]))
.^(tube:clay (scry %cc %home /[u.mark]/(perm-mark-name perm)))
::
++ add-mark
|= [=resource:res vip=vip-metadata:metadata =indexed-post:store]
(perm-mark resource %add vip indexed-post)
::
++ remove-mark
|= [=resource:res vip=vip-metadata:metadata =indexed-post:store]
(perm-mark resource %remove vip indexed-post)
::
++ get-permission
|= [=permissions:store is-admin=? writers=(set ship)]
^- permission-level:store
?: is-admin
admin.permissions
?: =(~ writers)
writer.permissions
?: (~(has in writers) src.bowl)
writer.permissions
reader.permissions
::
++ is-allowed
|= =resource:res
=/ group-res=resource:res
(need (peek-group:met %graph resource))
(is-member:grp src.bowl group-res)
::
++ get-roles-writers-variation
|= =resource:res
^- (unit [is-admin=? writers=(set ship) vip=vip-metadata:metadata])
=/ assoc=(unit association:metadata)
(peek-association:met %graph resource)
?~ assoc ~
=/ role=(unit (unit role-tag))
(role-for-ship:grp group.u.assoc src.bowl)
=/ writers=(set ship)
(get-tagged-ships:grp group.u.assoc [%graph resource %writers])
?~ role ~
=/ is-admin=?
?=(?([~ %admin] [~ %moderator]) u.role)
`[is-admin writers vip.metadatum.u.assoc]
::
++ node-to-indexed-post
|= =node:store
^- indexed-post:store
=* index index.post.node
[(snag (dec (lent index)) index) post.node]
::
++ is-allowed-add
|= [=resource:res nodes=(map index:store node:store)]
^- ?
%- (bond |.(%.n))
%+ biff (get-roles-writers-variation resource)
|= [is-admin=? writers=(set ship) vip=vip-metadata:metadata]
%- some
%+ levy ~(tap by nodes)
|= [=index:store =node:store]
=/ =permissions:store
%^ add-mark resource vip
(node-to-indexed-post node)
=/ =permission-level:store
(get-permission permissions is-admin writers)
~& permission-level
?- permission-level
%yes %.y
%no %.n
::
%self
=/ parent-index=index:store
(scag (dec (lent index)) index)
=/ parent-node=node:store
(got-node:gra resource parent-index)
=(author.post.parent-node src.bowl)
==
::
++ is-allowed-remove
|= [=resource:res indices=(set index:store)]
^- ?
%- (bond |.(%.n))
%+ biff (get-roles-writers-variation)
|= [is-admin=? writers=(set ship) vip=vip-metadata:metadata]
%- some
%+ levy ~(tap by indices)
|= =index:store
=/ =node:store
(got-node:gra resource index)
=/ =permissions:store
%^ remove-mark resource vip
(node-to-indexed-post node)
=/ =permission-level:store
(get-permission permissions is-admin writers)
?- permission-level
%yes %.y
%no %.n
%self =(author.post.node src.bowl)
==
::
++ build-permissions
|= [=mark kind=?(%add %remove) mode=?(%sing %next)]
^- card
=/ =wire /perms/[mark]/[kind]
=/ =mood:clay [%c da+now.bowl /[mark]/(perm-mark-name kind)]
=/ =rave:clay ?:(?=(%sing mode) [mode mood] [mode mood])
[%pass wire %arvo %c %warp our.bowl %home `rave]
--

View File

@ -37,26 +37,24 @@
+$ versioned-state
$% state-zero
state-one
state-two
==
::
+$ state-zero
$: %0
=groups:state-zero:store
==
::
[%0 *]
::
+$ state-one
$: %1
=groups
=groups:groups-state-one
==
::
+$ diff
$% [%group-update update:store]
[%group-initial groups]
+$ state-two
$: %2
=groups
==
--
::
=| state-one
=| state-two
=* state -
::
%- agent:dbug
@ -74,90 +72,37 @@
++ on-load
|= =old=vase
=/ old !<(versioned-state old-vase)
?: ?=(%1 -.old)
`this(state old)
|^
:- :~ [%pass / %agent [our.bowl dap.bowl] %poke %noun !>(%perm-upgrade)]
kick-all
==
=* paths ~(key by groups.old)
=/ [unmanaged=(list path) managed=(list path)]
(skid ~(tap in paths) |=(=path =('~' (snag 0 path))))
=. groups (all-unmanaged unmanaged)
=. groups (all-managed managed)
this
::
++ all-managed
|= paths=(list path)
^+ groups
?~ paths
groups
=/ [rid=resource =group]
(migrate-group i.paths)
%= $
paths t.paths
::
groups
(~(put by groups) rid group)
?- -.old
%2 `this(state old)
::
%1
%_ $
-.old %2
groups.old (groups-1-to-2 groups.old)
==
::
%0 $(old *state-two)
==
::
++ all-unmanaged
|= paths=(list path)
^+ groups
?~ paths
groups
?: |(=(/~/default i.paths) =(4 (lent i.paths)))
$(paths t.paths)
=/ [=resource =group]
(migrate-unmanaged i.paths)
%= $
paths t.paths
::
groups
(~(put by groups) resource group)
==
++ kick-all
^- card
:+ %give %kick
:_ ~
%~ tap by
%+ roll ~(val by sup.bowl)
|= [[=ship pax=path] paths=(set path)]
(~(put in paths) pax)
::
++ migrate-unmanaged
|= pax=path
^- [resource group]
=/ members=(set ship)
(~(got by groups.old) pax)
=| =invite:policy
?> ?=(^ pax)
=/ rid=resource
(resource-from-old-path t.pax)
++ groups-1-to-2
|= =groups:groups-state-one
^+ ^groups
%- ~(run by groups)
|= =group:groups-state-one
=/ =tags
(~(put ju *tags) %admin entity.rid)
:- rid
[members tags invite %.y]
::
++ resource-from-old-path
|= pax=path
^- resource
?> ?=([@ @ *] pax)
=/ ship
(slav %p i.pax)
[ship i.t.pax]
::
++ migrate-group
|= pax=path
=/ members
(~(got by groups.old) pax)
=| =invite:policy
=/ rid=resource
(resource-from-old-path pax)
=/ =tags
(~(put ju *tags) %admin entity.rid)
[rid members tags invite %.n]
(tags-1-to-2 tags.group)
[members.group tags [policy hidden]:group]
::
++ tags-1-to-2
|= =tags:groups-state-one
^- ^tags
%- ~(gas by *^tags)
%+ murn
~(tap by tags)
|= [=tag:groups-state-one ships=(set ship)]
?^ tag ~
`[tag ships]
--
::
++ on-poke
@ -273,8 +218,8 @@
|= arc=*
^- (quip card _state)
|^
=/ sty=state-one
[%1 (remake-groups ;;((tree [resource tree-group]) +.arc))]
=/ sty=state-two
[%2 (remake-groups ;;((tree [resource tree-group]) +.arc))]
:_ sty
%+ roll ~(tap by groups.sty)
|= [[rid=resource grp=group] out=(list card)]

View File

@ -39,6 +39,8 @@
++ on-save !>(state)
++ on-load
|= =vase
?: =(1 1)
`this
=+ !<(old=versioned-state vase)
|^
?: ?=(%2 -.old)

View File

@ -78,13 +78,19 @@
++ should-proxy-update
|= =vase
=+ !<(=update:store vase)
?. ?=(?(%add %remove %update) -.update)
?. ?=(?(%add %remove) -.update)
%.n
=/ role=(unit (unit role-tag))
(role-for-ship:grp group.update src.bowl)
=/ =metadatum:store
(need (peek-metadatum:met %contacts group.update))
?~ role %.n
?~ u.role %.n
?=(?(%admin %moderator) u.u.role)
?^ u.role
?=(?(%admin %moderator) u.u.role)
?. ?=(%add -.update) %.n
?& =(src.bowl entity.resource.resource.update)
?=(%member-metadata vip.metadatum)
==
::
++ take-update
|= =vase

View File

@ -104,4 +104,8 @@
index (snoc index atom)
nodes (tap:orm:store p.children.node)
==
::
++ get-mark
|= res=resource
(scry-for ,(unit mark) /graph-mark/(scot %p entity.res)/[name.res])
--

View File

@ -127,38 +127,17 @@
++ tags
|= =^tags
^- json
|^
:- %o
(~(uni by app) group)
++ group
^- (map @t json)
%- malt
%+ murn
~(tap by tags)
|= [=^tag ships=(^set ^ship)]
^- (unit [@t json])
?^ tag
~
`[tag (set ship ships)]
++ app
^- (map @t json)
=| app-tags=(map @t json)
=/ tags ~(tap by tags)
|-
?~ tags
app-tags
=* tag i.tags
?@ p.tag
$(tags t.tags)
=/ app=json
(~(gut by app-tags) app.p.tag [%o ~])
?> ?=(%o -.app)
=. p.app
(~(put by p.app) tag.p.tag (set ship q.tag))
=. app-tags
(~(put by app-tags) app.p.tag app)
$(tags t.tags)
--
%- pairs
%+ turn ~(tap by tags)
|= [=^tag ships=(^set ^ship)]
^- [@t json]
:_ (set ship ships)
?@ tag tag
;: (cury cat 3)
app.tag '\\'
tag.tag '\\'
(enjs-path:resource resource.tag)
==
::
++ set
|* [item=$-(* json) sit=(^set)]
@ -167,6 +146,7 @@
%+ turn
~(tap in sit)
item
::
++ tag
|= =^tag
^- json
@ -175,6 +155,7 @@
%- pairs
:~ app+s+app.tag
tag+s+tag.tag
resource+s+(enjs-path:resource resource.tag)
==
::
++ policy
@ -366,6 +347,7 @@
%. json
%- ot
:~ app+so
resource+dejs-path:resource
tag+so
==

View File

@ -83,6 +83,14 @@
:- %groups
(weld (en-path:resource rid) /join/(scot %p ship))
::
++ get-tagged-ships
|= [rid=resource =tag]
^- (set ship)
=/ grp=(unit group)
(scry-group rid)
?~ grp ~
(~(get ju tags.u.grp) tag)
::
++ is-managed
|= rid=resource
=/ group=(unit group)

View File

@ -0,0 +1,12 @@
/- *graph-store
|_ per=permissions
++ grad %noun
++ grow
|%
++ noun per
--
++ grab
|%
++ noun permissions
--
--

View File

@ -0,0 +1,12 @@
/- *graph-store
|_ per=permissions
++ grad %noun
++ grow
|%
++ noun per
--
++ grab
|%
++ noun permissions
--
--

View File

@ -1,10 +1,22 @@
/- *post
/- *post, met=metadata-store
|_ i=indexed-post
++ grow
|%
++ noun i
::
++ graph-permissions-add
|= vip=vip-metadata:met
?+ index.p.i !!
[@ ~] [%yes %yes %no]
==
::
++ graph-permissions-remove
|= vip=vip-metadata:met
?+ index.p.i !!
[@ ~] [%self %self %no]
==
::
++ notification-kind
::
?+ index.p.i ~
[@ ~] `[%message 0 %count %.n]
==

View File

@ -1,8 +1,29 @@
/- *post
/- *post, met=metadata-store
|_ i=indexed-post
++ grow
|%
++ noun i
::
++ graph-permissions-add
|= vip=vip-metadata:met
=/ reader
?=(%reader-comments vip)
?+ index.p.i !!
[@ ~] [%yes %yes %no]
[@ @ ~] [%yes %yes ?:(reader %yes %no)]
[@ @ @ ~] [%self %self %self]
==
::
++ graph-permissions-remove
|= vip=vip-metadata:met
=/ reader
?=(%reader-comments vip)
?+ index.p.i !!
[@ ~] [%yes %self %self]
[@ @ ~] [%yes %self %self]
[@ @ @ ~] [%yes %self %self]
==
::
++ notification-kind
?+ index.p.i ~
[@ ~] `[%link 0 %each %.y]

View File

@ -1,10 +1,27 @@
/- *post
/- *post, met=metadata-store
|_ i=indexed-post
++ grow
|%
++ noun i
++ graph-permissions-add
|= vip=vip-metadata:met
?+ index.p.i !!
[@ ~] [%yes %yes %no] :: new note
[@ %1 @ ~] [%self %self %no]
[@ %2 @ ~] [%yes %yes ?:(?=(%reader-comments vip) %yes %no)]
[@ %2 @ @ ~] [%self %self %self]
==
::
++ graph-permissions-remove
|= vip=vip-metadata:met
?+ index.p.i !!
[@ ~] [%yes %self %self]
[@ %1 @ @ ~] [%yes %self %self]
[@ %2 @ ~] [%yes %self %self]
[@ %2 @ @ ~] [%yes %self %self]
==
:: +notification-kind
:: Ignore all containers, only notify on content
:: ignore all containers, only notify on content
::
++ notification-kind
?+ index.p.i ~
@ -16,7 +33,7 @@
--
++ grab
|%
:: +noun: Validate publish post
:: +noun: validate publish post
::
++ noun
|= p=*

View File

@ -15,5 +15,4 @@
++ noun update:store
++ json action:dejs:store
--
::
--

View File

@ -1,5 +1,17 @@
/- *post
|%
::
+$ permissions
[admin=permission-level writer=permission-level reader=permission-level]
::
:: $permission-level: levels of permissions in increasing order
::
:: %no: May not add/remove node
:: %self: May only nodes beneath nodes that were added by
:: the same pilot, may remove nodes that the pilot 'owns'
:: %yes: May add a node or remove node
+$ permission-level
?(%no %self %yes)
+$ graph ((mop atom node) gth)
+$ marked-graph [p=graph q=(unit mark)]
::
@ -11,6 +23,7 @@
+$ update-log ((mop time logged-update) gth)
+$ update-logs (map resource update-log)
::
::
+$ internal-graph
$~ [%empty ~]
$% [%graph p=graph]

View File

@ -2,25 +2,6 @@
^?
|%
::
++ state-zero
|%
+$ group (set ship)
::
+$ group-action
$% [%add members=group pax=path] :: add member to group
[%remove members=group pax=path] :: remove member from group
[%bundle pax=path] :: create group at path
[%unbundle pax=path] :: delete group at path
==
::
+$ group-update
$% [%keys keys=(set path)] :: keys have changed
[%path members=group pax=path]
group-action
==
::
+$ groups (map path group)
--
:: $action: request to change group-store state
::
:: %add-group: add a group

View File

@ -2,6 +2,22 @@
::
^?
|%
::
++ groups-state-one
|%
+$ groups (map resource group)
::
+$ tag $@(group-tag [app=term tag=term])
::
+$ tags (jug tag ship)
::
+$ group
$: members=(set ship)
=tags
=policy
hidden=?
==
--
:: $groups: a mapping from group-ids to groups
::
+$ groups (map resource group)
@ -16,7 +32,7 @@
:: Tags may be used and recognised differently across apps.
:: for example, you could use tags like `%author`, `%bot`, `%flagged`...
::
+$ tag $@(group-tag [app=term tag=term])
+$ tag $@(group-tag [app=term =resource tag=term])
:: $role-tag: a kind of $group-tag that identifies a privileged user
::
:: These roles are

View File

@ -1,8 +1,4 @@
<<<<<<< HEAD
/- spider, graph-view, graph=graph-store, metadata=metadata-store, *group
=======
/- spider, graph-view, graph=graph-store, met=metadata-store, *group
>>>>>>> origin/la/contact-store
/- spider, graph-view, graph=graph-store, metadata=metadata-store, *group, group-store
/+ strandio, resource
=>
|%
@ -12,11 +8,7 @@
::
++ scry-metadata
|= rid=resource
<<<<<<< HEAD
=/ m (strand ,resource)
=======
=/ m (strand ,(unit resource))
>>>>>>> origin/la/contact-store
;< group=(unit resource) bind:m
%+ scry:strandio ,(unit resource)
;: weld
@ -24,11 +16,7 @@
(en-path:resource rid)
/noun
==
<<<<<<< HEAD
(pure:m (need group))
=======
(pure:m group)
>>>>>>> origin/la/contact-store
::
++ scry-group
|= rid=resource
@ -57,6 +45,27 @@
!> ^- action:metadata
[%remove group-rid [%graph rid]]
(pure:m ~)
::
++ delete-tags
|= [graph=resource grp-rid=resource =group]
=/ m (strand ,~)
^- form:m
=/ tags=(list [=tag tagged=(set ship)])
%+ skim ~(tap by tags.group) |= [=tag tagged=(set ship)]
?@ tag %.n
?& =(app.tag %graph)
=(resource.tag graph)
==
|- =* loop $
^- form:m
?~ tags
(pure:m ~)
;< ~ bind:m
%+ poke [entity.grp-rid %group-push-hook]
:- %group-update
!> ^- update:group-store
[%remove-tag grp-rid tag.i.tags tagged.i.tags]
loop(tags t.tags)
--
::
^- thread:spider
@ -72,6 +81,8 @@
(scry-metadata rid.action)
;< =group bind:m
(scry-group group-rid)
;< ~ bind:m
(delete-tags rid.action group-rid group)
;< ~ bind:m
(delete-graph group-rid rid.action)
?. hidden.group

View File

@ -23,9 +23,9 @@ export default class MetadataApi extends BaseApi<StoreState> {
'date-created': dateCreated,
creator,
'module': moduleName,
preview: false,
picture: '',
permissions: ''
preview: false,
vip: ''
}
}
});

View File

@ -1,8 +1,12 @@
import { roleTags, RoleTags, Group, Resource } from '~/types/group-update';
import { PatpNoSig, Path } from '~/types/noun';
import _ from "lodash";
import { roleTags, RoleTags, Group, Resource } from "~/types/group-update";
import { PatpNoSig, Path } from "~/types/noun";
import {deSig} from "./util";
export function roleForShip(group: Group, ship: PatpNoSig): RoleTags | undefined {
export function roleForShip(
group: Group,
ship: PatpNoSig
): RoleTags | undefined {
return roleTags.reduce((currRole, role) => {
const roleShips = group?.tags?.role?.[role];
return roleShips && roleShips.has(ship) ? role : currRole;
@ -10,11 +14,40 @@ export function roleForShip(group: Group, ship: PatpNoSig): RoleTags | undefined
}
export function resourceFromPath(path: Path): Resource {
const [, , ship, name] = path.split('/');
return { ship, name }
}
export function makeResource(ship: string, name:string) {
const [, , ship, name] = path.split("/");
return { ship, name };
}
export function makeResource(ship: string, name: string) {
return { ship, name };
}
export function isWriter(group: Group, resource: string) {
const writers: Set<string> | undefined = _.get(
group.tags,
["graph", resource, "writers"],
undefined
);
const admins = group.tags?.role?.admin ?? new Set();
if (_.isUndefined(writers)) {
return true;
} else {
return writers.has(window.ship) || admins.has(window.ship);
}
}
export function isChannelAdmin(group: Group, resource: string, ship: string = `~${window.ship}`) {
const role = roleForShip(group, ship.slice(1));
return (
isHost(resource, ship) ||
role === "admin" ||
role === "moderator"
);
}
export function isHost(resource: string, ship: string = `~${window.ship}`) {
const [, , host] = resource.split("/");
return ship === host;
}

View File

@ -5,9 +5,12 @@ import React, {
SyntheticEvent,
useMemo,
useEffect,
useRef,
} from "react";
import { Box } from "@tlon/indigo-react";
import { useOutsideClick } from "./useOutsideClick";
import { ModalOverlay } from "~/views/components/ModalOverlay";
type ModalFunc = (dismiss: () => void) => JSX.Element;
interface UseModalProps {
@ -19,11 +22,8 @@ interface UseModalResult {
showModal: () => void;
}
const stopPropagation = (e: SyntheticEvent) => {
e.stopPropagation();
};
export function useModal(props: UseModalProps): UseModalResult {
const innerRef = useRef<HTMLElement>();
const [modalShown, setModalShown] = useState(false);
const dismiss = useCallback(() => {
@ -44,54 +44,25 @@ export function useModal(props: UseModalProps): UseModalResult {
[modalShown, props.modal, dismiss]
);
const handleKeyDown = useCallback(
(event) => {
if (event.key === "Escape") {
dismiss();
}
},
[dismiss]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [modalShown]);
useOutsideClick(innerRef, dismiss);
const modal = useMemo(
() =>
!inner ? null : (
<Box
backgroundColor="scales.black30"
left="0px"
top="0px"
<ModalOverlay
ref={innerRef}
maxWidth="500px"
width="100%"
height="100%"
zIndex={10}
position="fixed"
bg="white"
borderRadius={2}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
display="flex"
justifyContent="center"
alignItems="center"
onClick={dismiss}
alignItems="stretch"
flexDirection="column"
>
<Box
maxWidth="500px"
width="100%"
bg="white"
borderRadius={2}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
onClick={stopPropagation}
display="flex"
alignItems="stretch"
flexDirection="column"
>
{inner}
</Box>
</Box>
{inner}
</ModalOverlay>
),
[inner, dismiss]
);

View File

@ -1,7 +1,7 @@
import { useEffect, RefObject } from "react";
export function useOutsideClick(
ref: RefObject<HTMLElement>,
ref: RefObject<HTMLElement | null | undefined>,
onClick: () => void
) {
useEffect(() => {
@ -14,10 +14,19 @@ export function useOutsideClick(
onClick();
}
}
function handleKeyDown(ev) {
if(ev.key === "Escape") {
onClick();
}
}
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKeyDown);
};
}, [ref.current, onClick]);
}

View File

@ -23,6 +23,12 @@ export const getModuleIcon = (mod: string) => {
return _.capitalize(mod);
}
export function wait(ms: number) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms);
});
}
export function appIsGraph(app: string) {
return app === 'publish' || app == 'link';
}

View File

@ -41,22 +41,16 @@ function decodePolicy(policy: Enc<GroupPolicy>): GroupPolicy {
}
function decodeTags(tags: Enc<Tags>): Tags {
console.log(tags);
return _.reduce(
tags,
(acc, tag, key): Tags => {
if (Array.isArray(tag)) {
acc.role[key] = new Set(tag);
(acc, ships, key): Tags => {
if (key.search(/\\/) === -1) {
acc.role[key] = new Set(ships);
return acc;
} else {
const app = _.reduce(
tag,
(inner, t, k) => {
inner[k] = new Set(t);
return inner;
},
{}
);
acc[key] = app;
const [app, tag, resource] = key.split('\\');
_.set(acc, [app, resource, tag], new Set(ships));
return acc;
}
},
@ -143,7 +137,7 @@ export default class GroupReducer<S extends GroupState> {
const resourcePath = resourceAsPath(resource);
const tags = state.groups[resourcePath].tags;
const tagAccessors =
'app' in tag ? [tag.app,tag.tag] : ['role', tag.tag];
'app' in tag ? [tag.app,tag.resource, tag.tag] : ['role', tag.tag];
const tagged = _.get(tags, tagAccessors, new Set());
for (const ship of ships) {
tagged.add(ship);
@ -158,7 +152,7 @@ export default class GroupReducer<S extends GroupState> {
const resourcePath = resourceAsPath(resource);
const tags = state.groups[resourcePath].tags;
const tagAccessors =
'app' in tag ? [tag.app,tag.tag] : ['role', tag.tag];
'app' in tag ? [tag.app, tag.resource, tag.tag] : ['role', tag.tag];
const tagged = _.get(tags, tagAccessors, new Set());
if (!tagged) {

View File

@ -8,6 +8,7 @@ interface RoleTag {
interface AppTag {
app: string;
resource: string;
tag: string;
}

View File

@ -62,7 +62,7 @@ export interface Metadata {
module: string;
picture: string;
preview: boolean;
permissions: Permissions;
vip: PermVariation;
}
export type Permissions = '' | 'reader-comments';
export type PermVariation = '' | 'reader-comments' | 'member-metadata';

View File

@ -1,2 +1,4 @@
import { Icon } from "@tlon/indigo-react";
export type PropFunc<T extends (...args: any[]) => any> = Parameters<T>[0];
export type IconRef = PropFunc<typeof Icon>['icon'];

View File

@ -14,6 +14,7 @@ import SubmitDragger from '~/views/components/SubmitDragger';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import { Loading } from '~/views/components/Loading';
import useS3 from '~/logic/lib/useS3';
import {isWriter} from '~/logic/lib/group';
type ChatResourceProps = StoreState & {
association: Association;
@ -38,6 +39,8 @@ export function ChatResource(props: ChatResourceProps) {
const chatInput = useRef<ChatInput>();
const canWrite = isWriter(group, station);
useEffect(() => {
const count = Math.min(50, unreadCount + 15);
props.api.graph.getNewest(owner, name, count);
@ -109,6 +112,7 @@ export function ChatResource(props: ChatResourceProps) {
location={props.location}
scrollTo={scrollTo ? parseInt(scrollTo, 10) : undefined}
/>
{ canWrite && (
<ChatInput
ref={chatInput}
api={props.api}
@ -121,7 +125,7 @@ export function ChatResource(props: ChatResourceProps) {
placeholder="Message..."
message={unsent[station] || ''}
deleteMessage={clearUnsent}
/>
/> )}
</Col>
);
}

View File

@ -61,7 +61,6 @@ export function LinkResource(props: LinkResourceProps) {
return <Center width='100%' height='100%'><LoadingSpinner/></Center>;
}
return (
<Col alignItems="center" height="100%" width="100%" overflowY="hidden">
<Switch>

View File

@ -15,6 +15,7 @@ import GlobalApi from "~/logic/api/global";
import VirtualScroller from "~/views/components/VirtualScroller";
import { LinkItem } from "./components/LinkItem";
import LinkSubmit from "./components/LinkSubmit";
import {isWriter} from "~/logic/lib/group";
interface LinkWindowProps {
association: Association;
@ -49,6 +50,7 @@ export function LinkWindow(props: LinkWindowProps) {
const first = graph.peekLargest()?.[0];
const [,,ship, name] = association.resource.split('/');
const canWrite = isWriter(props.group, association.resource)
const style = useMemo(() =>
({
@ -86,7 +88,7 @@ export function LinkWindow(props: LinkWindowProps) {
measure,
key: index.toString()
};
if(index.eq(first ?? bigInt.zero)) {
if(canWrite && index.eq(first ?? bigInt.zero)) {
return (
<>
<Col key={index.toString()} mx="auto" mt="4" maxWidth="768px" width="100%" flexShrink={0} px={3}>

View File

@ -52,7 +52,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
let adminLinks: JSX.Element | null = null;
if (window.ship === note?.post?.author) {
if (true || window.ship === note?.post?.author) {
adminLinks = (
<Box display="inline-block" verticalAlign="middle">
<Link to={`${baseUrl}/edit`}>

View File

@ -1,9 +1,10 @@
import React from "react";
import { RouteComponentProps } from "react-router-dom";
import { RouteComponentProps, Link } from "react-router-dom";
import { NotebookPosts } from "./NotebookPosts";
import { Col } from "@tlon/indigo-react";
import { Col, Box, Text, Button, Row } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global";
import { Contacts, Rolodex, Groups, Associations, Graph, Association, Unreads } from "~/types";
import { useShowNickname } from "~/logic/lib/util";
interface NotebookProps {
api: GlobalApi;
@ -35,8 +36,27 @@ export function Notebook(props: NotebookProps & RouteComponentProps) {
return null; // Waiting on groups to populate
}
const relativePath = (p: string) => props.baseUrl + p;
const contact = notebookContacts?.[ship];
const isOwn = `~${window.ship}` === ship;
console.log(association.resource);
const showNickname = useShowNickname(contact);
return (
<Col gapY="4" pt={4} mx="auto" px={3} maxWidth="768px">
<Row justifyContent="space-between">
<Box>
<Text display='block'>{association.metadata?.title}</Text>
<Text color="lightGray">by </Text>
<Text fontFamily={showNickname ? 'sans' : 'mono'}>
{showNickname ? contact?.nickname : ship}
</Text>
</Box>
</Row>
<Box borderBottom="1" borderBottomColor="washedGray" />
<NotebookPosts
graph={graph}
host={ship}

View File

@ -18,7 +18,7 @@ export class Writers extends Component {
const ships = values.ships.map(e => `~${e}`);
await api.groups.addTag(
resource,
{ app: 'publish', tag: `writers-${name}` },
{ app: 'graph', resource: association.resource, tag: `writers` },
ships
);
actions.resetForm();
@ -28,7 +28,8 @@ export class Writers extends Component {
actions.setStatus({ error: e.message });
}
};
const writers = Array.from(groups?.[association?.group]?.tags.publish?.[`writers-${name}`] || new Set()).map(e => cite(`~${e}`)).join(', ');
const writers = Array.from(groups?.[association?.group]?.tags.graph[association.resource]?.writers || []).map(s => `~${s}`).join(', ');
return (
<Box maxWidth='512px'>
@ -51,10 +52,14 @@ export class Writers extends Component {
</AsyncButton>
</Form>
</Formik>
{writers.length > 0 && <>
{writers.length > 0 ? <>
<Text display='block' mt='2'>Current writers:</Text>
<Text mt='2' display='block' mono>{writers}</Text>
</>}
</> :
<Text display='block' mt='2'>
All group members can write to this channel
</Text>
}
</Box>
);
}

View File

@ -32,7 +32,9 @@ export default function NewPost(props: NewPostProps & RouteComponentProps) {
try {
const [noteId, nodes] = newPost(title, body)
await api.graph.addNodes(ship, book, nodes)
await waiter(p => p.graph.has(noteId));
await waiter(p =>
p.graph.has(noteId) && !p.graph.get(noteId)?.post?.pending
);
history.push(`${props.baseUrl}/note/${noteId}`);
} catch (e) {
console.error(e);

View File

@ -4,11 +4,12 @@ import { Button, LoadingSpinner } from "@tlon/indigo-react";
import { useFormikContext } from "formik";
export function AsyncButton({
export function AsyncButton<T = any>({
children,
onSuccess = () => {},
...rest
}: Parameters<typeof Button>[0]) {
const { isSubmitting, status, isValid } = useFormikContext();
const { isSubmitting, status, isValid, setStatus } = useFormikContext<T>();
const [success, setSuccess] = useState<boolean | undefined>();
useEffect(() => {
@ -16,6 +17,7 @@ export function AsyncButton({
let done = false;
if ("success" in s) {
setSuccess(true);
onSuccess();
done = true;
} else if ("error" in s) {
setSuccess(false);
@ -25,6 +27,7 @@ export function AsyncButton({
setTimeout(() => {
setSuccess(undefined);
}, 1500);
done = false;
}
}, [status]);

View File

@ -11,6 +11,7 @@ import { createPost, createBlankNodeWithChildPost } from '~/logic/api/graph';
import { getLatestCommentRevision } from '~/logic/lib/publish';
import { scanForMentions } from '~/logic/lib/graph';
import { getUnreadCount } from '~/logic/lib/hark';
import {isWriter} from '~/logic/lib/group';
interface CommentsProps {
comments: GraphNode;
@ -92,18 +93,19 @@ export function Comments(props: CommentsProps) {
useEffect(() => {
console.log(`dismissing ${association?.resource}`);
return () => {
api.hark.markCountAsRead(association, parentIndex, 'comment')
};
}, [comments.post.index])
const readCount = children.length - getUnreadCount(props?.unreads, association.resource, parentIndex)
const readCount = children.length - getUnreadCount(props?.unreads, association.resource, parentIndex);
const canComment = isWriter(group, association.resource) || association.metadata.vip === 'reader-comments';
return (
<Col>
{( !props.editCommentId ? <CommentInput onSubmit={onSubmit} /> : null )}
{( !props.editCommentId && canComment ? <CommentInput onSubmit={onSubmit} /> : null )}
{( !!props.editCommentId ? (
<CommentInput
onSubmit={onEdit}

View File

@ -37,6 +37,7 @@ interface DropdownSearchExtraProps<C> {
placeholder?: string;
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: any) => void;
onFocus?: (e: FocusEvent) => void;
}
type DropdownSearchProps<C> = PropFunc<typeof Box> &
@ -53,6 +54,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
renderCandidate,
disabled,
placeholder,
onFocus = () => {},
onChange = () => {},
onBlur = () => {},
...rest
@ -101,7 +103,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
return () => {
mousetrap.unbind(["down", "tab"]);
mousetrap.unbind(["up", "shift+tab"]);
mousetrap.unbind("enter", onEnter);
mousetrap.unbind("enter");
};
}, [textarea.current, next, back, onEnter]);

View File

@ -0,0 +1,41 @@
import React, { useCallback, ReactNode } from "react";
import { useFormikContext } from "formik";
import { Row, Button } from "@tlon/indigo-react";
import { AsyncButton } from "./AsyncButton";
interface FormSubmitProps {
children?: ReactNode;
}
export function FormSubmit<T = any>(props: FormSubmitProps) {
const { children } = props;
const { initialValues, values, dirty, resetForm, isSubmitting } = useFormikContext<T>();
const handleSuccess = useCallback(() => {
resetForm({ errors: {}, touched: {}, values, status: {} });
}, [resetForm, values]);
const handleRevert = useCallback(() => {
resetForm({ errors: {}, touched: {}, values: initialValues, status: {} });
}, [resetForm, initialValues]);
return (
<Row
p="2"
bottom="0px"
justifyContent="flex-end"
gapX="2"
alignItems="center"
>
{dirty && !isSubmitting && (
<Button onClick={handleRevert} backgroundColor="washedGray">
Cancel
</Button>
)}
<AsyncButton onSuccess={handleSuccess} primary>
{children}
</AsyncButton>
</Row>
);
}

View File

@ -2,6 +2,7 @@ import React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { Box } from "@tlon/indigo-react";
import { PropFunc } from "~/types/util";
interface HoverBoxProps {
selected: boolean;
bg: string;
@ -16,7 +17,15 @@ export const HoverBox = styled(Box)<HoverBoxProps>`
}
`;
export const HoverBoxLink = ({ to, children, ...rest }) => (
interface HoverBoxLinkProps {
to: string;
}
export const HoverBoxLink = ({
to,
children,
...rest
}: HoverBoxLinkProps & PropFunc<typeof HoverBox>) => (
<Link to={to}>
<HoverBox {...rest}>{children}</HoverBox>
</Link>

View File

@ -0,0 +1,149 @@
import React, { useCallback, useMemo } from "react";
import styled from "styled-components";
import {
Icon,
Box,
Row,
BaseLabel,
Indicator,
Col,
Label,
} from "@tlon/indigo-react";
import { useField } from "formik";
type IconRadioProps = Parameters<typeof Row>[0] & {
id: string;
icon: string;
name: string;
disabled?: boolean;
caption?: string;
label: string;
};
// Hide this input completely
const HiddenInput = styled.input`
position: absolute;
opacity: 0;
height: 0;
width: 0;
margin: 0px;
`;
type IconIndicatorProps = Parameters<typeof Box> & {
disabled?: boolean;
selected?: boolean;
hasError?: boolean;
};
// stolen from indigo
// TODO: indigo should probably export this
const indicator = {
state: {
on: {
//"*": { fill: "white" },
backgroundColor: "blue",
borderColor: "blue",
},
off: {
//"*": { fill: "transparent" },
backgroundColor: "white",
borderColor: "lightGray",
},
onError: {
//"*": { fill: "white" },
backgroundColor: "red",
borderColor: "red",
},
offError: {
// "*": { fill: "transparent" },
backgroundColor: "washedRed",
borderColor: "red",
},
offDisabled: {
//"*": { fill: "transparent" },
backgroundColor: "washedGray",
borderColor: "lightGray",
},
onDisabled: {
//"*": { fill: "lightGray" },
backgroundColor: "washedGray",
borderColor: "lightGray",
},
},
};
const IconIndicator = ({ disabled, selected, hasError, children, ...rest }) => {
const style = useMemo(() => {
if (selected && disabled) return indicator.state.onDisabled;
if (selected && hasError) return indicator.state.onError;
if (selected) return indicator.state.on;
if (disabled) return indicator.state.offDisabled;
if (hasError) return indicator.state.offError;
return indicator.state.off;
}, [selected, disabled, hasError]);
return (
<Box borderRadius="1" border="1" {...rest} {...style}>
{children}
</Box>
);
};
export function IconRadio(props: IconRadioProps) {
const { id, icon, disabled, caption, label, name, ...rest } = props;
const [field, meta, { setTouched }] = useField({
name,
id,
value: id,
type: "radio",
});
const onChange = useCallback(
(e: React.ChangeEvent) => {
setTouched(true);
field.onChange(e);
},
[field.onChange, setTouched]
);
return (
<Row {...rest}>
<BaseLabel
htmlFor={id}
display="flex"
flexDirection="row"
cursor="pointer"
>
<IconIndicator
hasError={meta.touched && meta.error !== undefined}
selected={field.checked}
disabled={disabled}
mr="2"
>
<Icon
m="2"
color={field.checked ? "white" : "black"}
icon={icon as any}
/>
</IconIndicator>
<Col justifyContent="space-around">
<Label color={field.checked ? "blue" : "black"}>{label}</Label>
{caption ? (
<Label gray mt="2">
{caption}
</Label>
) : null}
<HiddenInput
{...field}
onChange={onChange}
value={id}
name={name}
id={id}
disabled={disabled}
type="radio"
/>
</Col>
</BaseLabel>
</Row>
);
}

View File

@ -0,0 +1,29 @@
import React from "react";
import { Box } from "@tlon/indigo-react";
import { PropFunc } from "~/types/util";
interface ModalOverlayProps {
spacing: PropFunc<typeof Box>["m"];
}
export const ModalOverlay = React.forwardRef(
(props: ModalOverlayProps & PropFunc<typeof Box>, ref) => {
const { spacing, ...rest } = props;
return (
<Box
backgroundColor="scales.black20"
left="0px"
top="0px"
width="100%"
height="100%"
zIndex={10}
position="fixed"
display="flex"
justifyContent="center"
alignItems="center"
p={spacing}
>
<Box ref={ref} {...rest} />
</Box>
);
}
);

View File

@ -1,8 +1,25 @@
import React, { useMemo, useCallback, ChangeEvent, useState, SyntheticEvent, useEffect } from "react";
import { Box, Label, Icon, Text, Row, Col, ErrorLabel } from "@tlon/indigo-react";
import React, {
useMemo,
useCallback,
ChangeEvent,
useState,
SyntheticEvent,
useEffect,
useRef,
} from "react";
import {
Box,
Label,
Icon,
Text,
Row,
Col,
ErrorLabel,
} from "@tlon/indigo-react";
import _ from "lodash";
import ob from "urbit-ob";
import { useField } from "formik";
import * as Yup from "yup";
import { useField, FieldArray, useFormikContext } from "formik";
import styled from "styled-components";
import { DropdownSearch } from "./DropdownSearch";
@ -13,18 +30,47 @@ import { HoverBox } from "./HoverBox";
const INVALID_SHIP_ERR = "Invalid ship";
interface InviteSearchProps {
interface InviteSearchProps<I extends string> {
autoFocus?: boolean;
disabled?: boolean;
label?: string;
caption?: string;
id: string;
id: I;
contacts: Rolodex;
groups: Groups;
hideSelection?: boolean;
maxLength?: number;
}
const getNicknameForShips = (groups: Groups, contacts: Rolodex) => {
const peerSet = new Set<string>();
const nicknames = new Map<string, string[]>();
_.forEach(groups, (group, path) => {
if (group.members.size > 0) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
peerSet.add(member);
}
}
const groupContacts = contacts[path];
if (groupContacts) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
if (groupContacts[member]) {
if (nicknames.has(member)) {
nicknames.get(member)?.push(groupContacts[member].nickname);
} else {
nicknames.set(member, [groupContacts[member].nickname]);
}
}
}
}
});
return [Array.from(peerSet), nicknames] as const;
};
const Candidate = ({ title, detail, selected, onClick }) => (
<HoverBox
display="flex"
@ -45,100 +91,46 @@ const Candidate = ({ title, detail, selected, onClick }) => (
</HoverBox>
);
export function ShipSearch(props: InviteSearchProps) {
type Value<I extends string> = {
[k in I]: string[];
};
const shipItemSchema = Yup.string().test(
"is-patp",
"${value} is not a valid @p",
ob.isValidPatp
);
export const shipSearchSchema = Yup.array(shipItemSchema).compact();
export const shipSearchSchemaInGroup = (members: string[]) =>
Yup.array(shipItemSchema.oneOf(members, "${value} not a member of this group")).compact();
export function ShipSearch<I extends string, V extends Value<I>>(
props: InviteSearchProps<I>
) {
const { id, label, caption } = props;
const [{}, meta, { setValue, setTouched, setError: _setError }] = useField<string[]>({
name: id,
multiple: true
});
const {
values,
touched,
errors,
initialValues,
setFieldValue,
} = useFormikContext<V>();
const setError = _setError as unknown as (s: string | undefined) => void;
const inputIdx = useRef(initialValues[id].length);
const { error, touched } = meta;
const selected: string[] = values[id] ?? [];
const [selected, setSelected] = useState([] as string[]);
const [inputShip, setInputShip] = useState(undefined as string | undefined);
const [inputTouched, setInputTouched] = useState(false);
const name = () => `${props.id}[${inputIdx.current}]`;
const checkInput = useCallback((valid: boolean, ship: string | undefined) => {
if(valid) {
setInputShip(ship);
setError(error === INVALID_SHIP_ERR ? undefined : error);
} else if (ship === undefined) {
return;
} else {
setError(INVALID_SHIP_ERR);
setInputTouched(false);
}
}, [setError, error, setInputTouched, setInputShip]);
const pills = selected.slice(0, inputIdx.current);
const onChange = useCallback(
(e: any) => {
let ship = `~${deSig(e.target.value) || ""}`;
if(ob.isValidPatp(ship)) {
checkInput(true, ship);
} else {
checkInput(ship.length !== 1, undefined)
}
},
[checkInput]
const [peers, nicknames] = useMemo(
() => getNicknameForShips(props.groups, props.contacts),
[props.contacts, props.groups]
);
const onBlur = useCallback(() => {
setInputTouched(true);
}, [setInputTouched]);
const onSelect = useCallback(
(s: string) => {
setTouched(true);
checkInput(true, undefined);
s = `${deSig(s)}`;
setSelected(v => _.uniq([...v, s]))
},
[setTouched, checkInput, setSelected]
);
const onRemove = useCallback(
(s: string) => {
setSelected(ships => ships.filter(ship => ship !== s))
},
[setSelected]
);
useEffect(() => {
const newValue = inputShip ? [...selected, inputShip] : selected;
setValue(newValue);
}, [inputShip, selected])
const [peers, nicknames] = useMemo(() => {
const peerSet = new Set<string>();
const contacts = new Map<string, string[]>();
_.forEach(props.groups, (group, path) => {
if (group.members.size > 0) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
peerSet.add(member);
}
}
const groupContacts = props.contacts[path];
if (groupContacts) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
if (groupContacts[member]) {
if (contacts.has(member)) {
contacts.get(member)?.push(groupContacts[member].nickname);
} else {
contacts.set(member, [groupContacts[member].nickname]);
}
}
}
}
});
return [Array.from(peerSet), contacts] as const;
}, [props.contacts, props.groups]);
const renderCandidate = useCallback(
(s: string, selected: boolean, onSelect: (s: string) => void) => {
const detail = _.uniq(nicknames.get(s)).join(", ");
@ -158,62 +150,87 @@ export function ShipSearch(props: InviteSearchProps) {
[nicknames]
);
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const newValue =
e.target.value?.length > 0 ? `~${deSig(e.target.value)}` : "";
setFieldValue(name(), newValue);
};
const error = _.compact(errors[id] as string[]);
return (
<Col>
<Label htmlFor={id}>{label}</Label>
{caption && (
<Label gray mt="2">
{caption}
</Label>
)}
<DropdownSearch<string>
mt="2"
isExact={(s) => {
const ship = `~${deSig(s)}`;
const result = ob.isValidPatp(ship);
return result ? deSig(s) ?? undefined : undefined;
}}
placeholder="Search for ships..."
candidates={peers}
renderCandidate={renderCandidate}
disabled={props.maxLength ? selected.length >= props.maxLength : false}
search={(s: string, t: string) =>
t.toLowerCase().startsWith(s.toLowerCase())
}
getKey={(s: string) => s}
onSelect={onSelect}
onChange={onChange}
onBlur={onBlur}
/>
<Row minHeight="34px" flexWrap="wrap">
{selected.map((s) => (
<Row
fontFamily="mono"
alignItems="center"
py={1}
px={2}
color="black"
borderRadius='2'
bg='washedGray'
fontSize={0}
mt={2}
mr={2}
>
<Text fontFamily="mono">{cite(s)}</Text>
<Icon
icon="X"
ml={2}
onClick={() => onRemove(s)}
cursor="pointer"
<FieldArray
name={id}
render={(arrayHelpers) => {
const onAdd = () => {
inputIdx.current += 1;
arrayHelpers.push("");
};
const onRemove = (idx: number) => {
inputIdx.current -= 1;
arrayHelpers.remove(idx);
};
return (
<Col>
<Label htmlFor={id}>{label}</Label>
{caption && (
<Label gray mt="2">
{caption}
</Label>
)}
<DropdownSearch<string>
mt="2"
isExact={(s) => {
const ship = `~${deSig(s)}`;
const result = ob.isValidPatp(ship);
return result ? deSig(s) ?? undefined : undefined;
}}
placeholder="Search for ships"
candidates={peers}
renderCandidate={renderCandidate}
disabled={
props.maxLength ? selected.length >= props.maxLength : false
}
search={(s: string, t: string) =>
(t || "").toLowerCase().startsWith(s.toLowerCase())
}
getKey={(s: string) => s}
onChange={onChange}
onSelect={onAdd}
/>
</Row>
))}
</Row>
<ErrorLabel
mt="3"
hasError={error === INVALID_SHIP_ERR ? inputTouched : !!(touched && error)}>
{error}
</ErrorLabel>
</Col>
<Row minHeight="34px" flexWrap="wrap">
{pills.map((s, i) => (
<Row
fontFamily="mono"
alignItems="center"
py={1}
px={2}
color="black"
borderRadius="2"
bg="washedGray"
fontSize={0}
mt={2}
mr={2}
>
<Text fontFamily="mono">{cite(s)}</Text>
<Icon
icon="X"
ml={2}
onClick={() => onRemove(i)}
cursor="pointer"
/>
</Row>
))}
</Row>
<ErrorLabel mt="3" hasError={error.length > 0}>
{error.join(", ")}
</ErrorLabel>
</Col>
);
}}
/>
);
}

View File

@ -1,127 +0,0 @@
import React, { useCallback } from "react";
import { Link, useHistory } from "react-router-dom";
import { Icon, Row, Col, Button, Text, Box, Action } from "@tlon/indigo-react";
import { Dropdown } from "~/views/components/Dropdown";
import { Association, NotificationGraphConfig } from "~/types";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { appIsGraph } from "~/logic/lib/util";
const ChannelMenuItem = ({
icon,
color = undefined as string | undefined,
children,
bottom = false,
}) => (
<Row
alignItems="center"
borderBottom={bottom ? 0 : 1}
borderBottomColor="lightGray"
px={2}
py={1}
>
<Icon color={color} icon={icon} />
{children}
</Row>
);
interface ChannelMenuProps {
association: Association;
api: GlobalApi;
graphNotificationConfig: NotificationGraphConfig;
chatNotificationConfig: string[];
}
export function ChannelMenu(props: ChannelMenuProps) {
const { association, api } = props;
const history = useHistory();
const { metadata } = association;
const app = metadata.module || association["app-name"];
const workspace = history.location.pathname.startsWith("/~landscape/home")
? "/home"
: association?.group;
const baseUrl = `/~landscape${workspace}/resource/${app}${association.resource}`;
const rid = association.resource;
const [,, ship, name] = rid.split("/");
const isOurs = ship.slice(1) === window.ship;
const isMuted =
props.graphNotificationConfig.watching.findIndex(
(a) => a.graph === rid && a.index === "/"
) === -1;
const onChangeMute = async () => {
const func = isMuted ? "listenGraph" : "ignoreGraph";
await api.hark[func](rid, "/");
};
const onUnsubscribe = useCallback(async () => {
await api.graph.leaveGraph(ship, name);
history.push(`/~landscape${workspace}`);
}, [api, association]);
const onDelete = useCallback(async () => {
if (confirm('Are you sure you want to delete this channel?')) {
await api.graph.deleteGraph(name);
history.push(`/~landscape${workspace}`);
}
}, [api, association]);
return (
<Dropdown
options={
<Col
backgroundColor="white"
border={1}
borderRadius={1}
borderColor="lightGray"
>
<ChannelMenuItem color="blue" icon="Inbox">
<StatelessAsyncAction
m="2"
bg="white"
name="notif"
onClick={onChangeMute}
>
{isMuted ? "Unmute" : "Mute"} this channel
</StatelessAsyncAction>
</ChannelMenuItem>
{isOurs ? (
<>
<ChannelMenuItem color="red" icon="TrashCan">
<Action
m="2"
backgroundColor="white"
destructive
onClick={onDelete}
>
Delete Channel
</Action>
</ChannelMenuItem>
<ChannelMenuItem bottom icon="Gear" color="black">
<Link to={`${baseUrl}/settings`}>
<Box fontSize={1} p="2">
Channel Settings
</Box>
</Link>
</ChannelMenuItem>
</>
) : (
<ChannelMenuItem color="red" bottom icon="ArrowEast">
<Action bg="white" m="2" destructive onClick={onUnsubscribe}>
Unsubscribe from Channel
</Action>
</ChannelMenuItem>
)}
</Col>
}
alignX="right"
alignY="top"
dropWidth="250px"
>
<Icon display="block" icon="Menu" color="gray" pr='2' />
</Dropdown>
);
}

View File

@ -0,0 +1,194 @@
import React from "react";
import _ from "lodash";
import * as Yup from "yup";
import {
Label,
ManagedToggleSwitchField as Checkbox,
Box,
Col,
Text,
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import { PermVariation, Association, Group, Groups, Rolodex } from "~/types";
import { shipSearchSchemaInGroup, } from "~/views/components/ShipSearch";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
import { FormSubmit } from "~/views/components/FormSubmit";
import { ChannelWritePerms } from "../ChannelWritePerms";
function PermissionsSummary(props: {
writersSize: number;
vip: PermVariation;
}) {
const { writersSize, vip } = props;
const description =
writersSize === 0
? "Currently, all members of the group can write to this channel"
: `Currently, only ${writersSize} ship${
writersSize > 1 ? "s" : ""
} can write to this channel`;
const vipDescription =
vip === "reader-comments" && writersSize !== 0
? ". All ships may comment"
: "";
return (
<Box
p="2"
border="1"
borderColor="lightBlue"
borderRadius="1"
backgroundColor="washedBlue"
>
<Text>
{description}
{vipDescription}
</Text>
</Box>
);
}
interface GraphPermissionsProps {
association: Association;
group: Group;
groups: Groups;
contacts: Rolodex;
api: GlobalApi;
}
interface FormSchema {
writePerms: "self" | "everyone" | "subset";
writers: string[];
readerComments: boolean;
}
const formSchema = (members: string[]) => {
return Yup.object({
writePerms: Yup.string(),
writers: shipSearchSchemaInGroup(members),
readerComments: Yup.boolean(),
});
};
export function GraphPermissions(props: GraphPermissionsProps) {
const { api, group, association } = props;
const writers = _.get(
group.tags,
["graph", association.resource, "writers"],
new Set()
);
let [, , hostShip] = association.resource.split("/");
hostShip = hostShip.slice(1);
const writePerms =
writers.size === 0
? ("everyone" as const)
: writers.size === 1 && writers.has(hostShip)
? ("self" as const)
: ("subset" as const);
const readerComments = association.metadata.vip === "reader-comments";
const initialValues = {
writePerms,
writers: Array.from(writers)
.filter((x) => x !== hostShip)
.map((s) => `~${s}`),
readerComments: association.metadata.vip === "reader-comments",
};
const onSubmit = async (values: FormSchema, actions) => {
values.writers = _.compact(values.writers);
const resource = resourceFromPath(association.group);
const tag = {
app: "graph",
resource: association.resource,
tag: "writers",
};
const allWriters = Array.from(writers).map((w) => `~${w}`);
if (values.readerComments !== readerComments) {
await api.metadata.update(association, {
vip: values.readerComments ? "reader-comments" : "",
});
}
if (values.writePerms === "everyone") {
if (writePerms === "everyone") {
actions.setStatus({ success: null });
return;
}
await api.groups.removeTag(resource, tag, allWriters);
} else if (values.writePerms === "self") {
if (writePerms === "self") {
actions.setStatus({ success: null });
return;
}
let promises: Promise<any>[] = [];
allWriters.length > 0 &&
promises.push(api.groups.removeTag(resource, tag, allWriters));
promises.push(api.groups.addTag(resource, tag, [`~${hostShip}`]));
await Promise.all(promises);
actions.setStatus({ success: null });
} else if (values.writePerms === "subset") {
const toRemove = _.difference(allWriters, values.writers);
const toAdd = [
..._.difference(values.writers, allWriters),
`~${hostShip}`,
];
let promises: Promise<any>[] = [];
toRemove.length > 0 &&
promises.push(api.groups.removeTag(resource, tag, toRemove));
toAdd.length > 0 &&
promises.push(api.groups.addTag(resource, tag, toAdd));
await Promise.all(promises);
actions.setStatus({ success: null });
}
};
const schema = formSchema(Array.from(group.members).map((m) => `~${m}`));
return (
<Formik
validationSchema={schema}
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form style={{ display: "contents" }}>
<Col mt="4" flexShrink={0} gapY="5">
<Col gapY="1">
<Text id="permissions" fontWeight="bold" fontSize="2">
Permissions
</Text>
<Text gray>
Add or remove read/write privileges to this channel. Group admins
can always write to a channel
</Text>
</Col>
<Col>
<Label mb="2">Permissions Summary</Label>
<PermissionsSummary
writersSize={writers.size}
vip={association.metadata.vip}
/>
</Col>
<ChannelWritePerms contacts={props.contacts} groups={props.groups} />
{association.metadata.module !== "chat" && (
<Checkbox
id="readerComments"
label="Allow readers to comment"
caption="If enabled, all members of the group can comment on this channel"
/>
)}
<FormSubmit>Update Permissions</FormSubmit>
</Col>
</Form>
</Formik>
);
}

View File

@ -0,0 +1,83 @@
import React from "react";
import {
Box,
ManagedTextInputField as Input,
ManagedCheckboxField as Checkbox,
Col,
Label,
Text,
Row,
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import { FormError } from "~/views/components/FormError";
import { ColorInput } from "~/views/components/ColorInput";
import { AsyncButton } from "~/views/components/AsyncButton";
import { uxToHex, wait } from "~/logic/lib/util";
import GlobalApi from "~/logic/api/global";
import { Association } from "~/types";
import { FormSubmit } from "~/views/components/FormSubmit";
interface FormSchema {
title: string;
description: string;
color: string;
}
interface ChannelDetailsProps {
api: GlobalApi;
association: Association;
}
export function ChannelDetails(props: ChannelDetailsProps) {
const { association, api } = props;
const { metadata } = association;
const initialValues: FormSchema = {
title: metadata?.title || "",
description: metadata?.description || "",
color: metadata?.color || "0x0",
};
const onSubmit = async (values: FormSchema, actions) => {
const { title, description } = values;
const color = uxToHex(values.color);
await api.metadata.update(association, { title, color, description });
actions.setStatus({ success: null });
};
return (
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form style={{ display: "contents" }}>
<Col mb="4" flexShrink={0} gapY="4">
<Col mb={3}>
<Text id="details" fontSize="2" fontWeight="bold">
Channel Details
</Text>
<Label gray mt="2">
Set the title, description and colour of the channel
</Label>
</Col>
<Input
id="title"
label="Title"
caption="Change the title of this channel"
/>
<Input
id="description"
label="Change description"
caption="Change the description of this channel"
/>
<ColorInput
id="color"
label="Color"
caption="Change the color of this channel"
/>
<FormSubmit>
Update Details
</FormSubmit>
<FormError message="Failed to update settings" />
</Col>
</Form>
</Formik>
);
}

View File

@ -0,0 +1,44 @@
import React from "react";
import { Col, Text, BaseLabel, Label } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global";
import { Association, NotificationGraphConfig } from "~/types";
import { StatelessAsyncToggle } from "~/views/components/StatelessAsyncToggle";
interface ChannelNotificationsProps {
api: GlobalApi;
association: Association;
notificationsGraphConfig: NotificationGraphConfig;
}
export function ChannelNotifications(props: ChannelNotificationsProps) {
const { api, association } = props;
const rid = association.resource;
const isMuted =
props.notificationsGraphConfig.watching.findIndex(
(a) => a.graph === rid && a.index === "/"
) === -1;
const onChangeMute = async () => {
const func = isMuted ? "listenGraph" : "ignoreGraph";
await api.hark[func](rid, "/");
};
return (
<Col mb="6" gapY="4">
<Text id="notifications" fontSize="2" fontWeight="bold">
Channel Notifications
</Text>
<BaseLabel display="flex" cursor="pointer">
<StatelessAsyncToggle selected={isMuted} onClick={onChangeMute} />
<Col>
<Label>Mute this channel</Label>
<Label gray mt="1">
Muting this channel will prevent it from sending updates to your
inbox
</Label>
</Col>
</BaseLabel>
</Col>
);
}

View File

@ -0,0 +1,76 @@
import React from "react";
import { Text, Col } from "@tlon/indigo-react";
import { SidebarItem } from "../SidebarItem";
import { isChannelAdmin } from "~/logic/lib/group";
export function ChannelPopoverRoutesSidebar(props: {
baseUrl: string;
isOwner: boolean;
isAdmin: boolean;
}) {
const { baseUrl, isAdmin, isOwner } = props;
const relativePath = (p: string) => `${baseUrl}${p}`;
return (
<Col
minWidth="200px"
borderRight="1"
borderRightColor="washedGray"
py="5"
gapY="2"
>
<Text mx="3" my="3" fontSize="1" fontWeight="medium">
Channel Settings
</Text>
<Text mx="3" my="2" gray>
Preferences
</Text>
<SidebarItem
icon="Inbox"
text="Notifications"
to={relativePath("/settings#notifications")}
/>
{!isOwner && (
<SidebarItem
icon="SignOut"
text="Unsubscribe"
color="red"
to={relativePath("/settings#unsubscribe")}
/>
)}
{isAdmin && (
<>
<Text mx="3" py="2" gray>
Administration
</Text>
<SidebarItem
icon="Boot"
text="Channel Details"
to={relativePath("/settings#details")}
/>
<SidebarItem
icon="Keyfile"
text="Permissions"
to={relativePath("/settings#permissions")}
/>
{ isOwner ? (
<SidebarItem
icon="TrashCan"
text="Archive Channel"
to={relativePath("/settings#archive")}
color="red"
/>
) : (
<SidebarItem
icon="TrashCan"
text="Archive Channel"
to={relativePath("/settings#remove")}
color="red"
/>
)}
</>
)}
</Col>
);
}

View File

@ -0,0 +1,142 @@
import React, { useRef } from "react";
import { ModalOverlay } from "~/views/components/ModalOverlay";
import { Col, Box, Text, Row } from "@tlon/indigo-react";
import { ChannelPopoverRoutesSidebar } from "./Sidebar";
import { ChannelDetails } from "./Details";
import { GraphPermissions } from "./ChannelPermissions";
import {
Association,
Groups,
Group,
Rolodex,
NotificationGraphConfig,
} from "~/types";
import GlobalApi from "~/logic/api/global";
import { useHashLink } from "~/logic/lib/useHashLink";
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
import { useHistory } from "react-router-dom";
import { ChannelNotifications } from "./Notifications";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import { wait } from "~/logic/lib/util";
import { isChannelAdmin, isHost } from "~/logic/lib/group";
interface ChannelPopoverRoutesProps {
baseUrl: string;
association: Association;
group: Group;
groups: Groups;
contacts: Rolodex;
api: GlobalApi;
notificationsGraphConfig: NotificationGraphConfig;
}
export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
const { association, group, api } = props;
useHashLink();
const overlayRef = useRef<HTMLElement>();
const history = useHistory();
useOutsideClick(overlayRef, () => {
history.push(props.baseUrl);
});
const handleUnsubscribe = async () => {
const [,,ship,name] = association.resource.split('/');
await api.graph.leaveGraph(ship, name);
};
const handleRemove = async () => {
await api.metadata.remove('graph', association.resource, association.group);
};
const handleArchive = async () => {
const [,,,name] = association.resource.split('/');
await api.graph.deleteGraph(name);
};
const canAdmin = isChannelAdmin(group, association.resource);
const isOwner = isHost(association.resource);
return (
<ModalOverlay
bg="transparent"
height="100%"
width="100%"
spacing={[3, 5, 7]}
ref={overlayRef}
>
<Row
border="1"
borderColor="lightGray"
borderRadius="2"
bg="white"
height="100%"
>
<ChannelPopoverRoutesSidebar
isAdmin={canAdmin}
isOwner={isOwner}
baseUrl={props.baseUrl}
/>
<Col height="100%" overflowY="auto" p="5" flexGrow={1}>
<ChannelNotifications {...props} />
{!isOwner && (
<Col mb="6">
<Text id="unsubscribe" fontSize="2" fontWeight="bold">
Unsubscribe from Channel
</Text>
<Text mt="1" maxWidth="450px" gray>
Unsubscribing from a channel will revoke your ability to read
its contents. Any permissions set by the channel host will still
apply once you have left.
</Text>
<Row mt="3">
<StatelessAsyncButton destructive onClick={handleUnsubscribe}>
Unsubscribe from {props.association.metadata.title}
</StatelessAsyncButton>
</Row>
</Col>
)}
{canAdmin && (
<>
<ChannelDetails {...props} />
<GraphPermissions {...props} />
{ isOwner ? (
<Col mt="5" mb="6">
<Text id="archive" fontSize="2" fontWeight="bold">
Archive channel
</Text>
<Text mt="1" maxWidth="450px" gray>
Archiving a channel will prevent further updates to the channel.
Users who are currently joined to the channel will retain a copy
of the channel.
</Text>
<Row mt="3">
<StatelessAsyncButton destructive onClick={handleArchive}>
Archive {props.association.metadata.title}
</StatelessAsyncButton>
</Row>
</Col>
) : (
<Col mt="5" mb="6">
<Text id="remove" fontSize="2" fontWeight="bold">
Remove channel from group
</Text>
<Text mt="1" maxWidth="450px" gray>
Removing a channel will prevent further updates to the channel.
Users who are currently joined to the channel will retain a copy
of the channel.
</Text>
<Row mt="3">
<StatelessAsyncButton destructive onClick={handleRemove}>
Remove {props.association.metadata.title}
</StatelessAsyncButton>
</Row>
</Col>
)}
</>
)}
</Col>
</Row>
</ModalOverlay>
);
}

View File

@ -1,110 +0,0 @@
import React, { useEffect } from "react";
import { AsyncButton } from "~/views/components/AsyncButton";
import * as Yup from "yup";
import {
Box,
ManagedTextInputField as Input,
Col,
Label,
Text,
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import GlobalApi from "~/logic/api/global";
import { uxToHex } from "~/logic/lib/util";
import { FormError } from "~/views/components/FormError";
import { ColorInput } from "~/views/components/ColorInput";
import { Association, Groups, Associations } from "~/types";
import Writers from '~/views/apps/publish/components/Writers';
import GroupifyForm from "./GroupifyForm";
interface FormSchema {
title: string;
description: string;
color: string;
}
interface ChannelSettingsProps {
association: Association;
groups: Groups;
associations: Associations;
api: GlobalApi;
}
export function ChannelSettings(props: ChannelSettingsProps) {
const { api, association } = props;
const { metadata } = association;
const initialValues: FormSchema = {
title: metadata?.title || "",
description: metadata?.description || "",
color: metadata?.color || "0x0",
};
const onSubmit = async (
values: FormSchema,
actions: FormikHelpers<FormSchema>
) => {
try {
const app = association["app-name"];
const resource = association.resource;
const group = association.group;
const date = metadata["date-created"];
const { title, description, color } = values;
await api.metadata.metadataAdd(
app,
resource,
group,
title,
description,
date,
uxToHex(color),
metadata.module
);
actions.setStatus({ success: null });
} catch (e) {
console.log(e);
actions.setStatus({ error: e.message });
}
};
return (
<Col gapY="6" overflowY="auto" p={4}>
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form style={{ display: "contents" }}>
<Col flexShrink="0" maxWidth="512px" gapY="4">
<Col mb={3}>
<Text fontWeight="bold">Channel Settings</Text>
<Label gray mt="2">
Set the title, description and colour of the channel
</Label>
</Col>
<Input
id="title"
label="Title"
caption="Change the title of this channel"
/>
<Input
id="description"
label="Change description"
caption="Change the description of this channel"
/>
<ColorInput
id="color"
label="Color"
caption="Change the color of this channel"
/>
<AsyncButton primary loadingText="Updating.." border>
Save
</AsyncButton>
<FormError message="Failed to update settings" />
</Col>
</Form>
</Formik>
<Box borderBottom="1" borderBottomColor="lightGray" width="100%" maxWidth="512px" />
{(metadata?.module === 'publish') && (<>
<Writers {...props} />
<Box borderBottom="1" borderBottomColor="lightGray" width="100%" maxWidth="512px" />
</>)}
<GroupifyForm {...props} />
</Col>
);
}

View File

@ -0,0 +1,45 @@
import React from "react";
import {
Label,
Box,
ManagedRadioButtonField as Radio,
Col,
} from "@tlon/indigo-react";
import { useFormikContext } from "formik";
import { Groups, Rolodex } from "~/types";
import { ShipSearch } from "~/views/components/ShipSearch";
export type WritePerms = "everyone" | "subset" | "self";
export interface ChannelWriteFieldSchema {
writePerms: WritePerms;
writers: string[];
}
interface ChannelWritePermsProps {
groups: Groups;
contacts: Rolodex;
}
export function ChannelWritePerms<
T extends ChannelWriteFieldSchema = ChannelWriteFieldSchema
>(props: ChannelWritePermsProps) {
const { values, errors } = useFormikContext<T>();
return (
<Col gapY="3">
<Label> Write Access</Label>
<Radio name="writePerms" id="everyone" label="All group members" />
<Radio name="writePerms" id="self" label="Only host" />
<Radio name="writePerms" id="subset" label="Host and selected ships" />
{values.writePerms === "subset" && (
<ShipSearch
groups={props.groups}
contacts={props.contacts}
id="writers"
label=""
maxLength={undefined}
/>
)}
</Col>
);
}

View File

@ -31,6 +31,7 @@ interface FormSchema {
color: string;
isPrivate: boolean;
picture: string;
adminMetadata: boolean;
}
const formSchema = Yup.object({
@ -38,6 +39,7 @@ const formSchema = Yup.object({
description: Yup.string(),
color: Yup.string(),
isPrivate: Yup.boolean(),
adminMetadata: Yup.boolean()
});
interface GroupAdminSettingsProps {
@ -58,6 +60,7 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
color: metadata?.color,
picture: metadata?.picture,
isPrivate: currentPrivate,
adminMetadata: metadata.vip !== 'member-metadata'
};
const onSubmit = async (
@ -65,13 +68,15 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
actions: FormikHelpers<FormSchema>
) => {
try {
const { title, description, picture, color, isPrivate } = values;
const { title, description, picture, color, isPrivate, adminMetadata } = values;
const uxColor = uxToHex(color);
const vip = adminMetadata ? '' : 'member-metadata';
await props.api.metadata.update(props.association, {
title,
description,
picture,
color: uxColor,
vip
});
if (isPrivate !== currentPrivate) {
const resource = resourceFromPath(props.association.group);
@ -135,6 +140,13 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
caption="If enabled, users must be invited to join the group"
disabled={disabled}
/>
<Checkbox
id="adminMetadata"
label="Restrict channel adding to admins"
caption="If enabled, users must be an admin to add a channel to the group"
disabled={disabled}
/>
<AsyncButton
disabled={disabled}
primary

View File

@ -19,24 +19,25 @@ import { resourceFromPath } from '~/logic/lib/group';
import { Associations } from '~/types/metadata-update';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { Groups } from '~/types/group-update';
import { ShipSearch } from '~/views/components/ShipSearch';
import { ShipSearch, shipSearchSchemaInGroup, shipSearchSchema } from '~/views/components/ShipSearch';
import { Rolodex, Workspace } from '~/types';
import {IconRadio} from '~/views/components/IconRadio';
import {ChannelWriteFieldSchema, ChannelWritePerms} from './ChannelWritePerms';
interface FormSchema {
type FormSchema = {
name: string;
description: string;
ships: string[];
moduleType: 'chat' | 'publish' | 'link';
writers: string[];
}
} & ChannelWriteFieldSchema;
const formSchema = (group, groups) => Yup.object({
const formSchema = (members?: string[]) => Yup.object({
name: Yup.string().required('Channel must have a name'),
description: Yup.string(),
ships: Yup.array(Yup.string()),
moduleType: Yup.string().required('Must choose channel type'),
writers: Yup.array(Yup.string().test('ingroup', 'Writers must be in group',
value => groups?.[group]?.members?.has(value)))
writers: members ? shipSearchSchemaInGroup(members) : shipSearchSchema,
writePerms: Yup.string()
});
interface NewChannelProps {
@ -59,15 +60,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
+ ((workspace?.type !== 'home') ? `-${Math.floor(Math.random() * 10000)}`
: '');
try {
const { name, description, moduleType, ships, writers } = values;
if(moduleType === 'publish' && writers.length > 0) {
const resource = resourceFromPath(group);
await api.groups.addTag(
resource,
{ app: 'publish', tag: `writers-${resId}` },
writers.map(s => `~${s}`)
);
}
let { name, description, moduleType, ships, writers } = values;
if (group) {
await api.graph.createManagedGraph(
resId,
@ -76,6 +69,22 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
group,
moduleType
);
const tag = {
app: 'graph',
resource: `/ship/~${window.ship}/${resId}`,
tag: 'writers'
};
const resource = resourceFromPath(group);
writers = _.compact(writers);
const us = `~${window.ship}`;
if(values.writePerms === 'self') {
await api.groups.addTag(resource, tag, [us]);
} else if(values.writePerms === 'subset') {
writers.push(us);
await api.groups.addTag(resource, tag, writers);
}
} else {
await api.graph.createUnmanagedGraph(
resId,
@ -100,36 +109,39 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
}
};
const members = group ? Array.from(groups[group]?.members).map(s => `~${s}`) : undefined;
return (
<Col overflowY="auto" p={3}>
<Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}>
<Text fontSize='0' bold>{'<- Back'}</Text>
</Box>
<Box fontWeight="bold" mb={4} color="black">
<Box fontSize="1" fontWeight="bold" mb={4} color="black">
New Channel
</Box>
<Formik
validationSchema={formSchema(group, groups)}
validationSchema={formSchema(members)}
initialValues={{
moduleType: 'chat',
name: '',
description: '',
group: '',
ships: [],
writePerms: 'everyone',
writers: []
}}
onSubmit={onSubmit}
>
{ ({ errors, values }) => <Form>
<Form>
<Col
maxWidth="348px"
gapY="4"
>
<Col gapY="2">
<Box color="black" mb={2}>Channel Type</Box>
<Radio label="Chat" id="chat" name="moduleType" />
<Radio label="Notebook" id="publish" name="moduleType" />
<Radio label="Collection" id="link" name="moduleType" />
<Box fontSize="1" color="black" mb={2}>Channel Type</Box>
<IconRadio icon="Chat" label="Chat" id="chat" name="moduleType" />
<IconRadio icon="Publish" label="Notebook" id="publish" name="moduleType" />
<IconRadio icon="Links" label="Collection" id="link" name="moduleType" />
</Col>
<Input
id="name"
@ -143,40 +155,19 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
caption="What's your channel about?"
placeholder="Channel description"
/>
{(workspace?.type === 'home') &&
{(workspace?.type === 'home') ? (
<ShipSearch
groups={props.groups}
contacts={props.contacts}
id="ships"
label="Invitees"
/>}
{(workspace?.type !== 'home' && values.moduleType === 'publish') &&
<>
<ShipSearch
groups={props.groups}
contacts={props.contacts}
caption="Add writers to restrict who can write to this
notebook, or leave blank to allow all group members to write"
id="writers"
label="Writers"
/>) : (
<ChannelWritePerms
groups={props.groups}
contacts={props.contacts}
/>
{errors.writers &&
<>
<Row>
<Icon
color='white'
mr='2'
backgroundColor='red'
borderRadius='999px'
icon="ExclaimationMarkBold"
/>
<Text color='red'>
{Array.from(new Set([...errors.writers]))}
</Text>
</Row>
</>
}
</>}
)}
<Box justifySelf="start">
<AsyncButton
primary
@ -189,7 +180,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
</Box>
<FormError message="Channel creation failed" />
</Col>
</Form>}
</Form>
</Formik>
</Col>
);

View File

@ -14,27 +14,8 @@ import { Participants } from "./Participants";
import {useHashLink} from "~/logic/lib/useHashLink";
import {DeleteGroup} from "./DeleteGroup";
import {resourceFromPath} from "~/logic/lib/group";
const SidebarItem = ({ selected, icon, text, to, children = null }) => {
return (
<HoverBoxLink
to={to}
selected={selected}
bg="white"
bgActive="washedGray"
display="flex"
px="3"
py="1"
justifyContent="space-between"
>
<Row>
<Icon icon={icon} mr='2'/>
<Text color={selected ? "black" : "gray"}>{text}</Text>
</Row>
{children}
</HoverBoxLink>
);
};
import {ModalOverlay} from "~/views/components/ModalOverlay";
import { SidebarItem } from "~/views/landscape/components/SidebarItem";
export function PopoverRoutes(
props: {
@ -72,106 +53,95 @@ export function PopoverRoutes(
render={(routeProps) => {
const { view } = routeProps.match.params;
return (
<Box
px={[3, 5, 8]}
py={[3, 5]}
backgroundColor='scales.black30'
left="0px"
top="0px"
<ModalOverlay
spacing={[3,5,7]}
ref={innerRef}
border={1}
borderColor="washedGray"
borderRadius={1}
width="100%"
height="100%"
zIndex={4}
position="fixed"
bg="white"
>
<Box
ref={innerRef}
border={1}
borderColor="washedGray"
borderRadius={1}
width="100%"
display="grid"
gridTemplateRows={["32px 1fr", "100%"]}
gridTemplateColumns={["100%", "250px 1fr"]}
height="100%"
bg="white"
width="100%"
>
<Box
display="grid"
gridTemplateRows={["32px 1fr", "100%"]}
gridTemplateColumns={["100%", "250px 1fr"]}
height="100%"
width="100%"
<Col
display={!!view ? ["none", "flex"] : "flex"}
borderRight={1}
borderRightColor="washedGray"
>
<Col
display={!!view ? ["none", "flex"] : "flex"}
borderRight={1}
borderRightColor="washedGray"
>
<Text my="4" mx="3" fontWeight="600" fontSize="2">Group Settings</Text>
<Col gapY="2">
<Text my="1" mx="3" gray>Group</Text>
<SidebarItem
icon="Inbox"
to={relativeUrl("/settings#notifications")}
text="Notifications"
/>
<SidebarItem
icon="Users"
to={relativeUrl("/participants")}
text="Participants"
selected={view === "participants"}
><Text gray>{groupSize}</Text>
</SidebarItem>
{ admin && (
<>
<Box pt="3" mb="1" mx="3">
<Text gray>Administration</Text>
</Box>
<SidebarItem
icon="Groups"
to={relativeUrl("/settings#group-details")}
text="Group Details"
/>
<SidebarItem
icon="Spaces"
to={relativeUrl("/settings#channels")}
text="Channel Management"
/>
</>
)}
<DeleteGroup owner={owner} api={props.api} association={props.association} />
</Col>
<Text my="4" mx="3" fontWeight="600" fontSize="2">Group Settings</Text>
<Col gapY="2">
<Text my="1" mx="3" gray>Group</Text>
<SidebarItem
icon="Inbox"
to={relativeUrl("/settings#notifications")}
text="Notifications"
/>
<SidebarItem
icon="Users"
to={relativeUrl("/participants")}
text="Participants"
selected={view === "participants"}
><Text gray>{groupSize}</Text>
</SidebarItem>
{ admin && (
<>
<Box pt="3" mb="1" mx="3">
<Text gray>Administration</Text>
</Box>
<SidebarItem
icon="Groups"
to={relativeUrl("/settings#group-details")}
text="Group Details"
/>
<SidebarItem
icon="Spaces"
to={relativeUrl("/settings#channels")}
text="Channel Management"
/>
</>
)}
<DeleteGroup owner={owner} api={props.api} association={props.association} />
</Col>
<Box
gridArea={"1 / 1 / 2 / 2"}
p={2}
display={["auto", "none"]}
>
<Link to={!!view ? relativeUrl("") : props.baseUrl}>
<Text>{"<- Back"}</Text>
</Link>
</Box>
<Box overflow="hidden">
{view === "settings" && (
<GroupSettings
baseUrl={`${props.baseUrl}/popover`}
group={props.group}
association={props.association}
api={props.api}
notificationsGroupConfig={props.notificationsGroupConfig}
associations={props.associations}
s3={props.s3}
/>
)}
{view === "participants" && (
<Participants
group={props.group}
contacts={props.contacts}
association={props.association}
api={props.api}
/>
)}
</Box>
</Col>
<Box
gridArea={"1 / 1 / 2 / 2"}
p={2}
display={["auto", "none"]}
>
<Link to={!!view ? relativeUrl("") : props.baseUrl}>
<Text>{"<- Back"}</Text>
</Link>
</Box>
<Box overflow="hidden">
{view === "settings" && (
<GroupSettings
baseUrl={`${props.baseUrl}/popover`}
group={props.group}
association={props.association}
api={props.api}
notificationsGroupConfig={props.notificationsGroupConfig}
associations={props.associations}
s3={props.s3}
/>
)}
{view === "participants" && (
<Participants
group={props.group}
contacts={props.contacts}
association={props.association}
api={props.api}
/>
)}
</Box>
</Box>
</Box>
</ModalOverlay>
);
}}
/>

View File

@ -13,6 +13,7 @@ import GlobalApi from "~/logic/api/global";
import { RouteComponentProps, Route, Switch } from "react-router-dom";
import { ChannelSettings } from "./ChannelSettings";
import { ResourceSkeleton } from "./ResourceSkeleton";
import {ChannelPopoverRoutes} from "./ChannelPopoverRoutes";
const TruncatedBox = styled(Box)`
white-space: nowrap;
@ -27,14 +28,14 @@ type ResourceProps = StoreState & {
} & RouteComponentProps;
export function Resource(props: ResourceProps) {
const { association, api, notificationsGraphConfig } = props;
const { association, api, notificationsGraphConfig, groups } = props;
const app = association.metadata.module || association["app-name"];
const rid = association.resource;
const selectedGroup = association.group;
const relativePath = (p: string) =>
`${props.baseUrl}/resource/${app}${rid}${p}`;
const skelProps = { api, association };
const skelProps = { api, association, groups };
let title = props.association.metadata.title;
if ('workspace' in props) {
if ('group' in props.workspace && props.workspace.group in props.associations.groups) {
@ -46,48 +47,35 @@ export function Resource(props: ResourceProps) {
<Helmet defer={false}>
<title>{props.notificationsCount ? `(${String(props.notificationsCount)}) ` : ''}{ title }</title>
</Helmet>
<ResourceSkeleton
{...skelProps}
baseUrl={relativePath("")}
>
{app === "chat" ? (
<ChatResource {...props} />
) : app === "publish" ? (
<PublishResource {...props} />
) : (
<LinkResource {...props} />
)}
</ResourceSkeleton>
<Switch>
<Route
path={relativePath("/settings")}
render={(routeProps) => {
return (
<ResourceSkeleton
baseUrl={props.baseUrl}
groupTags={props.groups?.[selectedGroup]?.tags}
{...skelProps}
>
<ChannelSettings
groups={props.groups}
contacts={props.contacts}
associations={props.associations}
api={api}
association={association}
/>
</ResourceSkeleton>
<ChannelPopoverRoutes
association={association}
group={props.groups?.[selectedGroup]}
groups={props.groups}
contacts={props.contacts}
api={props.api}
baseUrl={relativePath("")}
notificationsGraphConfig={notificationsGraphConfig}
/>
);
}}
/>
<Route
path={relativePath("")}
render={(routeProps) => (
<ResourceSkeleton
notificationsGraphConfig={props.notificationsGraphConfig}
notificationsChatConfig={props.notificationsChatConfig}
baseUrl={props.baseUrl}
groupTags={props.groups?.[selectedGroup]?.tags}
{...skelProps}
atRoot
>
{app === "chat" ? (
<ChatResource {...props} />
) : app === "publish" ? (
<PublishResource {...props} />
) : (
<LinkResource {...props} />
)}
</ResourceSkeleton>
)}
/>
</Switch>
</>
);

View File

@ -1,5 +1,5 @@
import React, { ReactNode } from "react";
import { Row, Box, Col, Text } from "@tlon/indigo-react";
import { Row, Icon, Box, Col, Text } from "@tlon/indigo-react";
import styled from "styled-components";
import { Link } from "react-router-dom";
@ -13,7 +13,8 @@ import GlobalApi from "~/logic/api/global";
import { RouteComponentProps, Route, Switch } from "react-router-dom";
import { ChannelSettings } from "./ChannelSettings";
import { ChannelMenu } from "./ChannelMenu";
import { NotificationGraphConfig } from "~/types";
import { NotificationGraphConfig, Groups } from "~/types";
import {isWriter} from "~/logic/lib/group";
const TruncatedBox = styled(Box)`
white-space: pre;
@ -22,22 +23,23 @@ const TruncatedBox = styled(Box)`
`;
type ResourceSkeletonProps = {
groups: Groups;
association: Association;
notificationsGraphConfig: NotificationGraphConfig;
api: GlobalApi;
baseUrl: string;
children: ReactNode;
atRoot?: boolean;
title?: string;
groupTags?: any;
};
export function ResourceSkeleton(props: ResourceSkeletonProps) {
const { association, api, baseUrl, children, atRoot, groupTags } = props;
const { association, api, baseUrl, children, atRoot, groups } = props;
const app = association?.metadata?.module || association["app-name"];
const rid = association.resource;
const group = groups[association.group];
const workspace =
baseUrl === "/~landscape/home" ? "/home" : association.group;
group?.hidden ? "/home" : association.group;
const title = props.title || association?.metadata?.title;
const [, , ship, resource] = rid.split("/");
@ -45,10 +47,10 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
const resourcePath = (p: string) => baseUrl + `/resource/${app}/ship/${ship}/${resource}` + p;
const isOwn = `~${window.ship}` === ship;
let isWriter = (app === 'publish') ? true : false;
let canWrite = (app === 'publish') ? true : false;
if (groupTags?.publish?.[`writers-${resource}`]) {
isWriter = isOwn || groupTags?.publish?.[`writers-${resource}`]?.has(window.ship);
if (!isWriter(group, association.resource)) {
canWrite = isOwn;
}
return (
@ -63,64 +65,49 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
borderBottom={1}
borderBottomColor="washedGray"
>
{atRoot ? (
<Box
borderRight={1}
borderRightColor="gray"
pr={3}
fontSize='1'
mr={3}
my="1"
display={["block", "none"]}
flexShrink={0}
<Box
borderRight={1}
borderRightColor="gray"
pr={3}
fontSize='1'
mr={3}
my="1"
display={["block", "none"]}
flexShrink={0}
>
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
</Box>
<Box px={1} mr={2} minWidth={0} display="flex">
<Text fontSize='2' fontWeight='700' display="inline-block" verticalAlign="middle" textOverflow="ellipsis" overflow="hidden" whiteSpace="pre" minWidth={0}>
{title}
</Text>
</Box>
<TruncatedBox
display={["none", "block"]}
verticalAlign="middle"
maxWidth='60%'
flexShrink={1}
title={association?.metadata?.description}
color="gray"
>
<RichText
color="gray"
mb="0"
display="inline-block"
disableRemoteContent
>
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
</Box>
) : (
<Box color="blue" pr={2} mr={2}>
<Link to={`/~landscape${workspace}/resource/${app}${rid}`}>
<Text color="blue">Go back to channel</Text>
</Link>
</Box>
)}
{atRoot && (
<>
<Box px={1} mr={2} minWidth={0} display="flex">
<Text fontSize='2' fontWeight='700' display="inline-block" verticalAlign="middle" textOverflow="ellipsis" overflow="hidden" whiteSpace="pre" minWidth={0}>
{title}
</Text>
</Box>
<TruncatedBox
display={["none", "block"]}
verticalAlign="middle"
maxWidth='60%'
flexShrink={1}
title={association?.metadata?.description}
color="gray"
>
<RichText
color="gray"
mb="0"
display="inline-block"
disableRemoteContent
>
{association?.metadata?.description}
</RichText>
</TruncatedBox>
<Box flexGrow={1} />
{isWriter && (
<Link to={resourcePath('/new')} style={{ flexShrink: '0' }}>
<Text bold pr='3' color='blue'>+ New Post</Text>
</Link>
)}
<ChannelMenu
graphNotificationConfig={props.notificationsGraphConfig}
association={association}
api={api}
/>
</>
)}
{association?.metadata?.description}
</RichText>
</TruncatedBox>
<Box flexGrow={1} />
{canWrite && (
<Link to={resourcePath('/new')} style={{ flexShrink: '0' }}>
<Text bold pr='3' color='blue'>+ New Post</Text>
</Link>
)}
<Link to={`${baseUrl}/settings`}>
<Icon icon="Menu" color="gray" pr="2" />
</Link>
</Box>
{children}
</Col>

View File

@ -85,6 +85,7 @@ export function Sidebar(props: SidebarProps) {
workspace={props.workspace}
/>
<SidebarListHeader
associations={associations}
contacts={props.contacts}
baseUrl={props.baseUrl}
groups={props.groups}

View File

@ -16,10 +16,11 @@ import { SidebarListConfig, Workspace } from "./types";
import { Link, useHistory } from 'react-router-dom';
import { getGroupFromWorkspace } from "~/logic/lib/workspace";
import { roleForShip } from "~/logic/lib/group";
import {Groups, Rolodex} from "~/types";
import {Groups, Rolodex, Associations} from "~/types";
export function SidebarListHeader(props: {
initialValues: SidebarListConfig;
associations: Associations;
groups: Groups;
contacts: Rolodex;
baseUrl: string;
@ -39,7 +40,10 @@ export function SidebarListHeader(props: {
const groupPath = getGroupFromWorkspace(props.workspace);
const role = props.groups?.[groupPath] ? roleForShip(props.groups[groupPath], window.ship) : undefined;
const isAdmin = (role === "admin") || (props.workspace?.type === 'home');
const memberMetadata =
groupPath ? props.associations.contacts?.[groupPath].metadata.vip === 'member-metadata' : false;
const isAdmin = memberMetadata || (role === "admin") || (props.workspace?.type === 'home');
return (
<Row

View File

@ -0,0 +1,44 @@
import React from "react";
import { Row, Icon, Text } from "@tlon/indigo-react";
import { IconRef, PropFunc } from "~/types/util";
import { HoverBoxLink } from "~/views/components/HoverBox";
interface SidebarItemProps {
selected?: boolean;
icon: IconRef;
text: string;
to: string;
color?: string;
children?: JSX.Element;
}
export const SidebarItem = ({
icon,
text,
to,
selected = false,
color = "black",
children,
...rest
}: SidebarItemProps & PropFunc<typeof HoverBoxLink>) => {
return (
<HoverBoxLink
to={to}
selected={selected}
bg="white"
bgActive="washedGray"
display="flex"
px="3"
py="1"
justifyContent="space-between"
{...rest}
>
<Row>
<Icon color={color} icon={icon as any} mr="2" />
<Text color={color}>{text}</Text>
</Row>
{children}
</HoverBoxLink>
);
};