groups-js: update frontend for new group store

This commit is contained in:
Liam Fitzgerald 2020-07-02 11:11:18 +10:00
parent 26c610f8d2
commit 2bf1969312
13 changed files with 553 additions and 632 deletions

View File

@ -1,321 +0,0 @@
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import GroupsApi from '../../api/groups';
import GroupsSubscription from '../../subscription/groups';
import GroupsStore from '../../store/groups';
import './css/custom.css';
import { Skeleton } from './components/skeleton';
import { NewScreen } from './components/new';
import { ContactSidebar } from './components/lib/contact-sidebar';
import { ContactCard } from './components/lib/contact-card';
import { AddScreen } from './components/lib/add-contact';
import GroupDetail from './components/lib/group-detail';
export default class GroupsApp extends Component {
constructor(props) {
super(props);
}
componentDidMount() {
document.title = 'OS1 - Groups';
// preload spinner asset
new Image().src = '/~landscape/img/Spinner.png';
this.props.subscription.startApp('groups')
}
componentWillUnmount() {
this.props.subscription.stopApp('groups')
}
render() {
const { props } = this;
const contacts = props.contacts || {};
const defaultContacts =
(Boolean(props.contacts) && '/~/default' in props.contacts) ?
props.contacts['/~/default'] : {};
const groups = props.groups ? props.groups : {};
const invites =
(Boolean(props.invites) && '/contacts' in props.invites) ?
props.invites['/contacts'] : {};
const associations = props.associations ? props.associations : {};
const selectedGroups = props.selectedGroups ? props.selectedGroups : [];
const s3 = props.s3 ? props.s3 : {};
const { api } = props;
return (
<Switch>
<Route exact path="/~groups"
render={(props) => {
return (
<Skeleton
activeDrawer="groups"
selectedGroups={selectedGroups}
history={props.history}
api={api}
contacts={contacts}
groups={groups}
invites={invites}
associations={associations}
>
<div className="h-100 w-100 overflow-x-hidden bg-white bg-gray0-d dn db-ns">
<div className="pl3 pr3 pt2 dt pb3 w-100 h-100">
<p className="f9 pt3 gray2 w-100 h-100 dtc v-mid tc">
Select a group to begin.
</p>
</div>
</div>
</Skeleton>
);
}}
/>
<Route exact path="/~groups/new"
render={(props) => {
return (
<Skeleton
history={props.history}
selectedGroups={selectedGroups}
api={api}
contacts={contacts}
groups={groups}
invites={invites}
associations={associations}
activeDrawer="rightPanel"
>
<NewScreen
history={props.history}
groups={groups}
contacts={contacts}
api={api}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~groups/(detail)?/(settings)?/:ship/:group/"
render={(props) => {
const groupPath =
`/${props.match.params.ship}/${props.match.params.group}`;
const groupContacts = contacts[groupPath] || {};
const group = groups[groupPath] || new Set([]);
const detail = Boolean(props.match.url.includes('/detail'));
const settings = Boolean(props.match.url.includes('/settings'));
const association = (associations.contacts?.[groupPath])
? associations.contacts[groupPath]
: {};
return (
<Skeleton
history={props.history}
selectedGroups={selectedGroups}
api={api}
contacts={contacts}
invites={invites}
groups={groups}
activeDrawer={(detail || settings) ? 'detail' : 'contacts'}
selected={groupPath}
associations={associations}
>
<ContactSidebar
contacts={groupContacts}
defaultContacts={defaultContacts}
group={group}
activeDrawer={(detail || settings) ? 'detail' : 'contacts'}
api={api}
path={groupPath}
{...props}
/>
<GroupDetail
association={association}
path={groupPath}
group={group}
activeDrawer={(detail || settings) ? 'detail' : 'contacts'}
settings={settings}
associations={associations}
api={api}
{...props}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~groups/add/:ship/:group"
render={(props) => {
const groupPath =
`/${props.match.params.ship}/${props.match.params.group}`;
const groupContacts = contacts[groupPath] || {};
const group = groups[groupPath] || new Set([]);
return (
<Skeleton
history={props.history}
selectedGroups={selectedGroups}
api={api}
contacts={contacts}
groups={groups}
invites={invites}
activeDrawer="rightPanel"
selected={groupPath}
associations={associations}
>
<ContactSidebar
contacts={groupContacts}
defaultContacts={defaultContacts}
group={group}
activeDrawer="rightPanel"
path={groupPath}
api={api}
{...props}
/>
<AddScreen
api={api}
groups={groups}
path={groupPath}
history={props.history}
contacts={contacts}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~groups/share/:ship/:group"
render={(props) => {
const groupPath =
`/${props.match.params.ship}/${props.match.params.group}`;
const shipPath = `${groupPath}/${window.ship}`;
const rootIdentity = defaultContacts[window.ship] || {};
const groupContacts = contacts[groupPath] || {};
const contact =
(window.ship in groupContacts) ?
groupContacts[window.ship] : {};
const group = groups[groupPath] || new Set([]);
return (
<Skeleton
history={props.history}
api={api}
selectedGroups={selectedGroups}
contacts={contacts}
groups={groups}
invites={invites}
activeDrawer="rightPanel"
selected={groupPath}
associations={associations}
>
<ContactSidebar
activeDrawer="rightPanel"
contacts={groupContacts}
defaultContacts={defaultContacts}
group={group}
path={groupPath}
api={api}
selectedContact={shipPath}
{...props}
/>
<ContactCard
api={api}
history={props.history}
contact={contact}
path={groupPath}
ship={window.ship}
share={true}
rootIdentity={rootIdentity}
s3={s3}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~groups/view/:ship/:group/:contact"
render={(props) => {
const groupPath =
`/${props.match.params.ship}/${props.match.params.group}`;
const shipPath =
`${groupPath}/${props.match.params.contact}`;
const groupContacts = contacts[groupPath] || {};
const contact =
(props.match.params.contact in groupContacts) ?
groupContacts[props.match.params.contact] : {};
const group = groups[groupPath] || new Set([]);
const rootIdentity =
props.match.params.contact === window.ship ?
defaultContacts[window.ship] : null;
return (
<Skeleton
history={props.history}
api={api}
selectedGroups={selectedGroups}
contacts={contacts}
groups={groups}
invites={invites}
activeDrawer="rightPanel"
selected={groupPath}
associations={associations}
>
<ContactSidebar
activeDrawer="rightPanel"
contacts={groupContacts}
defaultContacts={defaultContacts}
group={group}
path={groupPath}
api={api}
selectedContact={shipPath}
{...props}
/>
<ContactCard
api={api}
history={props.history}
contact={contact}
path={groupPath}
ship={props.match.params.contact}
rootIdentity={rootIdentity}
s3={s3}
/>
</Skeleton>
);
}}
/>
<Route exact path="/~groups/me"
render={(props) => {
const me = defaultContacts[window.ship] || {};
return (
<Skeleton
history={props.history}
api={api}
selectedGroups={selectedGroups}
contacts={contacts}
groups={groups}
invites={invites}
activeDrawer="rightPanel"
selected="me"
associations={associations}
>
<ContactCard
api={api}
history={props.history}
path="/~/default"
contact={me}
s3={s3}
ship={window.ship}
/>
</Skeleton>
);
}}
/>
</Switch>
);
}
}

View File

@ -1,10 +1,6 @@
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import GroupsApi from '../../api/groups';
import GroupsSubscription from '../../subscription/groups';
import GroupsStore from '../../store/groups';
import './css/custom.css';
import { Skeleton } from './components/skeleton';
@ -54,7 +50,9 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
props.invites['/contacts'] : {};
const selectedGroups = props.selectedGroups ? props.selectedGroups : [];
const s3 = props.s3 ? props.s3 : {};
const { api, associations, groups } = props;
const groups = props.groups || {};
const associations = props.associations || {};
const { api } = props;
return (

View File

@ -0,0 +1,125 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { Spinner } from '../../../components/Spinner';
import urbitOb from 'urbit-ob';
export class JoinScreen extends Component {
constructor(props) {
super(props);
this.state = {
group: '',
error: false,
awaiting: null,
disable: false
};
this.groupChange = this.groupChange.bind(this);
}
componentDidMount() {
// direct join from incoming URL
if ((this.props.ship) && (this.props.name)) {
const incomingGroup = `/${this.props.ship}/${this.props.notebook}`;
this.setState({ group: incomingGroup }, () => {
this.onClickJoin();
});
}
}
componentDidUpdate() {
if (this.props.groups) {
if (this.state.awaiting) {
const group = `/ship/${this.state.group}`;
if (group in this.props.groups) {
this.props.history.push(`/~groups${group}`);
}
}
}
}
onClickJoin() {
const { props, state } = this;
const { group } = state;
const [ship, name] = group.split('/');
const text = 'Joining group';
this.props.api.contactView.join({ ship, name }).then(() => {
this.setState({ awaiting: text });
});
}
groupChange(event) {
const [ship, name] = event.target.value.split('/');
const validGroup = urbitOb.isValidPatp(ship);
this.setState({
group: event.target.value,
error: !validGroup
});
}
render() {
const { state } = this;
let joinClasses = 'db f9 green2 ba pa2 b--green2 bg-gray0-d pointer';
let errElem = (<span />);
if (state.error) {
joinClasses = 'db f9 gray2 ba pa2 b--gray3 bg-gray0-d';
errElem = (
<span className="f9 inter red2 db">
Group must have a valid name.
</span>
);
}
return (
<div className={'h-100 w-100 pt4 overflow-x-hidden flex flex-column ' +
'bg-gray0-d white-d pa3'}
>
<div
className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8"
>
<Link to="/~groups/">{'⟵ All Groups'}</Link>
</div>
<h2 className="mb3 f8">Join an Existing Group</h2>
<div className="w-100">
<p className="f8 lh-copy mt3 db">Enter a <span className="mono">~ship/group-name</span></p>
<p className="f9 gray2 mb4">Group names use lowercase, hyphens, and slashes.</p>
<textarea
ref={ (e) => {
this.textarea = e;
} }
className={'f7 mono ba bg-gray0-d white-d pa3 mb2 db ' +
'focus-b--black focus-b--white-d b--gray3 b--gray2-d nowrap '}
placeholder="~zod/dream-journal"
spellCheck="false"
rows={1}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.onClickJoin();
}
}}
style={{
resize: 'none'
}}
onChange={this.groupChange}
value={this.state.group}
/>
{errElem}
<br />
<button
disabled={this.state.error}
onClick={this.onClickJoin.bind(this)}
className={joinClasses}
>Join Group</button>
<Spinner awaiting={this.state.awaiting} classes="mt4" text={this.state.text} />
</div>
</div>
);
}
}

View File

@ -1,90 +0,0 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { InviteSearch } from '../../../../components/InviteSearch';
import { Spinner } from '../../../../components/Spinner';
export class AddScreen extends Component {
constructor(props) {
super(props);
this.state = {
invites: {
groups: [],
ships: []
},
awaiting: false
};
this.invChange = this.invChange.bind(this);
}
invChange(value) {
this.setState({
invites: value
});
}
onClickAdd() {
const { props, state } = this;
const aud = state.invites.ships
.map(ship => `~${ship}`);
if (this.textarea) {
this.textarea.value = '';
}
this.setState({
error: false,
success: true,
invites: {
groups: [],
ships: []
},
awaiting: true
}, () => {
const submit = props.api.groups.add(props.path, aud);
submit.then(() => {
this.setState({ awaiting: false });
props.history.push('/~groups' + props.path);
});
});
}
render() {
const { props } = this;
return (
<div className="h-100 w-100 flex flex-column overflow-y-scroll white-d">
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 pl3 pt3 f8">
<Link to={'/~groups' + props.path}>{'⟵ All Contacts'}</Link>
</div>
<div className="w-100 w-70-l w-70-xl mb4 pr6 pr0-l pr0-xl">
<h2 className="f8 pl4 pt4">Add Group Members</h2>
<p className="f9 pl4 gray2 lh-copy">Invite ships to your group</p>
<div className="relative pl4 mt2 pb6">
<InviteSearch
groups={props.groups}
contacts={props.contacts}
groupResults={false}
shipResults={true}
invites={this.state.invites}
setInvite={this.invChange}
/>
</div>
<button
onClick={this.onClickAdd.bind(this)}
className="ml4 f8 ba pa2 b--green2 green2 pointer bg-transparent"
>
Add Members
</button>
<Link to="/~groups">
<button className="f8 ml4 ba pa2 b--black pointer bg-transparent b--white-d white-d">Cancel</button>
</Link>
<Spinner awaiting={this.state.awaiting} classes="mt4 pl4" text="Inviting to group..." />
</div>
</div>
);
}
}
export default AddScreen;

View File

@ -0,0 +1,122 @@
import React, { Component } from 'react';
import _ from 'lodash';
import { Link } from 'react-router-dom';
import { InviteSearch, Invites } from '../../../../components/InviteSearch';
import { Spinner } from '../../../../components/Spinner';
import { uuid } from '../../../../lib/util';
import { Groups } from '../../../../types/group-update';
import { Rolodex } from '../../../../types/contact-update';
import { Path } from '../../../../types/noun';
import GlobalApi from '../../../../api/global';
import { History } from 'history';
interface AddScreenState {
invites: Invites;
awaiting: boolean;
}
interface AddScreenProps {
path: Path;
contacts: Rolodex;
groups: Groups;
api: GlobalApi;
history: History;
}
export class AddScreen extends Component<AddScreenProps, AddScreenState> {
constructor(props) {
super(props);
this.state = {
invites: {
groups: [],
ships: [],
},
awaiting: false,
};
this.invChange = this.invChange.bind(this);
}
invChange(value) {
this.setState({
invites: value,
});
}
onClickAdd() {
const { props, state } = this;
let [, , ship, name] = props.path.split('/');
const resource = { ship, name };
const aud = state.invites.ships.map((ship) => `~${ship}`);
this.setState(
{
invites: {
groups: [],
ships: [],
},
awaiting: true,
},
() => {
const submit = aud.reduce(
(acc, recipient) =>
acc.then(() => {
return props.api.contacts.invite(resource, recipient);
}),
Promise.resolve()
);
submit.then(() => {
this.setState({ awaiting: false });
props.history.push('/~groups' + props.path);
});
}
);
}
render() {
const { props } = this;
return (
<div className='h-100 w-100 flex flex-column overflow-y-scroll white-d'>
<div className='w-100 dn-m dn-l dn-xl inter pt1 pb6 pl3 pt3 f8'>
<Link to={'/~groups' + props.path}>{'⟵ All Contacts'}</Link>
</div>
<div className='w-100 w-70-l w-70-xl mb4 pr6 pr0-l pr0-xl'>
<h2 className='f8 pl4 pt4'>Add Group Members</h2>
<p className='f9 pl4 gray2 lh-copy'>Invite ships to your group</p>
<div className='relative pl4 mt2 pb6'>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
groupResults={false}
shipResults={true}
invites={this.state.invites}
setInvite={this.invChange}
/>
</div>
<button
onClick={this.onClickAdd.bind(this)}
className='ml4 f8 ba pa2 b--green2 green2 pointer bg-transparent'
>
Add Members
</button>
<Link to='/~groups'>
<button className='f8 ml4 ba pa2 b--black pointer bg-transparent b--white-d white-d'>
Cancel
</button>
</Link>
<Spinner
awaiting={this.state.awaiting}
classes='mt4 pl4'
text='Inviting to group...'
/>
</div>
</div>
);
}
}
export default AddScreen;

View File

@ -5,8 +5,29 @@ import { ShareSheet } from './share-sheet';
import { Sigil } from '../../../../lib/sigil';
import { Spinner } from '../../../../components/Spinner';
import { cite } from '../../../../lib/util';
import { roleForShip, resourceFromPath } from '../../../../lib/group';
import { Path, PatpNoSig } from '../../../../types/noun';
import { Rolodex, Contacts, Contact } from '../../../../types/contact-update';
import { Groups, Group } from '../../../../types/group-update';
import GlobalApi from '../../../../api/global';
export class ContactSidebar extends Component {
interface ContactSidebarProps {
activeDrawer: 'contacts' | 'detail' | 'rightPanel';
groups: Groups;
group: Group
contacts: Contacts;
path: Path;
api: GlobalApi;
defaultContacts: Contacts;
selectedContact?: PatpNoSig;
}
interface ContactSidebarState {
awaiting: boolean;
}
export class ContactSidebar extends Component<ContactSidebarProps, ContactSidebarState> {
constructor(props) {
super(props);
this.state = {
@ -16,10 +37,14 @@ export class ContactSidebar extends Component {
render() {
const { props } = this;
const group = new Set(Array.from(props.group));
const responsiveClasses =
props.activeDrawer === 'contacts' ? 'db' : 'dn db-ns';
const group = props.groups[props.path];
const members = new Set(group.members || []);
const me = (window.ship in props.contacts)
? props.contacts[window.ship]
: (window.ship in props.defaultContacts)
@ -49,13 +74,13 @@ export class ContactSidebar extends Component {
/>
</>
);
group.delete(window.ship);
members.delete(window.ship);
const contactItems =
Object.keys(props.contacts)
.filter(c => c !== window.ship)
.map((contact) => {
group.delete(contact);
members.delete(contact);
const path = props.path + '/' + contact;
const obj = props.contacts[contact];
return (
@ -72,11 +97,16 @@ export class ContactSidebar extends Component {
);
});
const adminOpt = (props.path.includes(`~${window.ship}/`))
? 'dib' : 'dn';
const role = roleForShip(group, window.ship);
const resource = resourceFromPath(props.path);
const groupItems =
Array.from(group).map((member) => {
Array.from(members).map((member) => {
const memberRole = roleForShip(group, member);
const adminOpt = (role === 'admin' && memberRole !== 'admin')
|| (role === 'moderator' &&
(memberRole !== 'admin' && memberRole !== 'moderator'))
? 'dib' : 'dn';
return (
<div
key={member}
@ -99,7 +129,7 @@ export class ContactSidebar extends Component {
style={{ paddingTop: 6 }}
onClick={() => {
this.setState({ awaiting: true }, (() => {
props.api.groups.remove(props.path, [`~${member}`])
props.api.groups.remove(resource, [`~${member}`])
.then(() => {
this.setState({ awaiting: false });
});

View File

@ -1,8 +1,10 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { Spinner } from '../../../../components/Spinner';
import { GroupView } from '../../../../components/Group';
import { deSig, uxToHex } from '../../../../lib/util';
export class GroupDetail extends Component {
constructor(props) {
super(props);
@ -158,8 +160,8 @@ export class GroupDetail extends Component {
<p className="f9 mw5 mw3-m mw4-l">{title}</p>
<p className="f9 gray2">{description}</p>
<p className="f9">
{props.group.size + ' participant' +
((props.group.size === 1) ? '' : 's')}
{props.group.members.size + ' participant' +
((props.group.members.size === 1) ? '' : 's')}
</p>
</div>
<p className={'gray2 f9 mb2 pt6 ' + (isEmpty ? 'dn' : '')}>Group Channels</p>
@ -172,25 +174,32 @@ export class GroupDetail extends Component {
renderSettings() {
const { props } = this;
const groupOwner = (deSig(props.match.params.ship) === window.ship);
const { group, association } = props;
const association = props.association;
const groupOwner = (deSig(props.match.params.ship) === window.ship);
const deleteButtonClasses = (groupOwner) ? 'b--red2 red2 pointer bg-gray0-d' : 'b--gray3 gray3 bg-gray0-d c-default';
const tags = [
{ description: 'Admin', tag: 'admin', addDescription: 'Make Admin' },
{ description: 'Moderator', tag: 'moderator', addDescription: 'Make Moderator' },
{ description: 'Janitor', tag: 'janitor', addDescription: 'Make Janitor' }
];
return (
<div className="pa4 w-100 h-100 white-d">
<div className="pa4 w-100 h-100 white-d overflow-y-auto">
<div className="f8 f9-m f9-l f9-xl w-100">
<Link to={'/~groups/detail' + props.path}>{'⟵ Channels'}</Link>
</div>
{ group && <GroupView permissions className="mt6" resourcePath={props.path} group={group} tags={tags} api={props.api} /> }
<div className={(groupOwner) ? '' : 'o-30'}>
<p className="f8 mt3 lh-copy">Rename</p>
<p className="f9 gray2 mb4">Change the name of this group</p>
<p className="f9 mt3 lh-copy">Rename</p>
<p className="f9 gray2 mb2">Change the name of this group</p>
<div className="relative w-100 flex"
style={{ maxWidth: '29rem' }}
>
<input
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
className={'f9 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={!groupOwner}
@ -214,13 +223,13 @@ export class GroupDetail extends Component {
}}
/>
</div>
<p className="f8 mt3 lh-copy">Change description</p>
<p className="f9 gray2 mb4">Change the description of this group</p>
<p className="f9 mt3 lh-copy">Change description</p>
<p className="f9 gray2 mb2">Change the description of this group</p>
<div className="relative w-100 flex"
style={{ maxWidth: '29rem' }}
>
<input
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
className={'f9 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={!groupOwner}
@ -244,8 +253,8 @@ export class GroupDetail extends Component {
}}
/>
</div>
<p className="f8 mt3 lh-copy">Delete Group</p>
<p className="f9 gray2 mb4">
<p className="f9 mt3 lh-copy">Delete Group</p>
<p className="f9 gray2 mb2">
Permanently delete this group. All current members will no longer see this group.
</p>
<a className={'dib f9 ba pa2 ' + deleteButtonClasses}

View File

@ -8,7 +8,7 @@ export class GroupItem extends Component {
const selectedClass = (props.selected) ? 'bg-gray4 bg-gray1-d' : '';
const memberCount = Math.max(
props.group.size,
props.group.members.size,
Object.keys(props.contacts).length
);

View File

@ -13,6 +13,7 @@ export class GroupSidebar extends Component {
render() {
const { props } = this;
const { api } = props;
const selectedClass = (props.selected === 'me') ? 'bg-gray4 bg-gray1-d' : 'bg-white bg-gray0-d';
@ -123,6 +124,9 @@ export class GroupSidebar extends Component {
<Link to="/~groups/new" className="dib">
<p className="f9 pt4 pl4 green2 bn">Create Group</p>
</Link>
<Link to="/~groups/join" className="dib">
<p className="f9 pt4 pl4 green2 bn">Join Group</p>
</Link>
<Welcome contacts={props.contacts} />
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Your Identity</h2>
{rootIdentity}

View File

@ -3,7 +3,11 @@ import React, { Component } from 'react';
export class SidebarInvite extends Component {
onAccept() {
const { props } = this;
props.api.invite.accept('/contacts', props.uid);
const [,,ship, name] = props.invite.path.split('/');
const resource = { ship, name };
props.api.contacts.join(resource).then(() => {
props.api.invite.accept('/contacts', props.uid);
});
props.history.push(`/~groups${props.invite.path}`);
}

View File

@ -1,193 +0,0 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { InviteSearch } from '../../../components/InviteSearch';
import { Spinner } from '../../../components/Spinner';
import { RouteComponentProps } from 'react-router-dom';
type NewScreenProps = RouteComponentProps & {
}
export class NewScreen extends Component {
constructor(props) {
super(props);
this.state = {
groupName: '',
title: '',
description: '',
invites: {
ships: [],
groups: [],
},
privacy: false,
// color: '',
groupNameError: false,
awaiting: false
};
this.groupNameChange = this.groupNameChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.invChange = this.invChange.bind(this);
this.groupPrivacyChange = this.groupPrivacyChange.bind(this);
}
groupNameChange(event) {
const asciiSafe = event.target.value.toLowerCase()
.replace(/[^a-z0-9~_.-]/g, '-');
this.setState({
groupName: asciiSafe,
title: event.target.value
});
}
descriptionChange(event) {
this.setState({ description: event.target.value });
}
invChange(value) {
this.setState({
invites: value
});
}
groupPrivacyChange(event) {
this.setState({
privacy: event.target.checked
});
}
onClickCreate() {
const { props, state } = this;
if (!state.groupName) {
this.setState({
groupNameError: true
});
return;
}
const aud = state.invites.ships.map(ship => `~${ship}`);
const policy = state.privacy
? { 'invite': {
pending: aud
}}
: { 'open': {
'ban-ranks': [],
'banned': []
}};
if (this.textarea) {
this.textarea.value = '';
}
this.setState({
error: false,
success: true,
invites: '',
awaiting: true
}, () => {
props.api.contacts.create(
group,
aud,
this.state.title,
this.state.description
).then(() => {
this.setState({ awaiting: false });
props.history.push(`/~groups/ship/~${window.ship}/${state.groupName}`);
});
});
}
render() {
let groupNameErrElem = (<span />);
if (this.state.groupNameError) {
groupNameErrElem = (
<span className="f9 inter red2 ml3 mt1 db">
Group must have a name.
</span>
);
}
return (
<div className="h-100 w-100 mw6 pa3 pt4 overflow-x-hidden bg-gray0-d white-d flex flex-column">
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~groups/">{'⟵ All Groups'}</Link>
</div>
<div className="w-100 mb4 pr6 pr0-l pr0-xl">
<h2 className="f8">Create New Group</h2>
<h2 className="f8 pt6">Group Name</h2>
<textarea
className={
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 mt2 ' +
'focus-b--black focus-b--white-d'
}
rows={1}
placeholder="Jazz Maximalists Research Unit"
style={{
resize: 'none',
height: 48,
paddingTop: 14
}}
onChange={this.groupNameChange}
/>
{groupNameErrElem}
<h2 className="f8 pt6">Description <span className="gray2">(Optional)</span></h2>
<textarea
className={
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 mt2 ' +
'focus-b--black focus-b--white-d'
}
rows={1}
placeholder="Two trumpeters and a microphone"
style={{
resize: 'none',
height: 48,
paddingTop: 14
}}
onChange={this.descriptionChange}
/>
<div className="mv7">
<input
type="checkbox"
style={{ WebkitAppearance: 'none', width: 28 }}
onChange={this.groupPrivacyChange}
className={privacySwitchClasses}
/>
<span className="dib f9 white-d inter ml3">Private Group</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
If private, new members must be invited
</p>
</div>
{ this.state.privacy && (
<>
<h2 className="f8 pt6">Invite <span className="gray2">(Optional)</span></h2>
<p className="f9 gray2 lh-copy">Selected ships will be invited to your group</p>
<div className="relative pb6 mt2">
<InviteSearch
groups={[]}
contacts={this.props.contacts}
groupResults={false}
shipResults={true}
invites={this.state.invites}
setInvite={this.invChange}
/>
</div>
</>
)}
<button
onClick={this.onClickCreate.bind(this)}
className="f9 ba pa2 b--green2 green2 pointer bg-transparent"
>
Start Group
</button>
<Link to="/~groups">
<button className="f9 ml3 ba pa2 b--black pointer bg-transparent b--white-d white-d">Cancel</button>
</Link>
<Spinner awaiting={this.state.awaiting} classes="mt4" text="Creating group..." />
</div>
</div>
);
}
}

View File

@ -0,0 +1,228 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { InviteSearch, Invites } from '../../../components/InviteSearch';
import { Spinner } from '../../../components/Spinner';
import { RouteComponentProps } from 'react-router-dom';
import { Groups, GroupPolicy } from '../../../types/group-update';
import { Contacts, Rolodex } from '../../../types/contact-update';
import GlobalApi from '../../../api/global';
import { Patp, PatpNoSig, Enc } from '../../../types/noun';
type NewScreenProps = Pick<RouteComponentProps, 'history'> & {
groups: Groups;
contacts: Rolodex;
api: GlobalApi;
};
type TextChange = React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>;
type BooleanChange = React.ChangeEvent<HTMLInputElement>;
interface NewScreenState {
groupName: string;
title: string;
description: string;
invites: Invites;
privacy: boolean;
groupNameError: boolean;
awaiting: boolean;
}
export class NewScreen extends Component<NewScreenProps, NewScreenState> {
constructor(props) {
super(props);
this.state = {
groupName: '',
title: '',
description: '',
invites: { ships: [], groups: [] },
privacy: false,
// color: '',
groupNameError: false,
awaiting: false,
};
this.groupNameChange = this.groupNameChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.invChange = this.invChange.bind(this);
this.groupPrivacyChange = this.groupPrivacyChange.bind(this);
}
groupNameChange(event: TextChange) {
const asciiSafe = event.target.value
.toLowerCase()
.replace(/[^a-z0-9~_.-]/g, '-');
this.setState({
groupName: asciiSafe,
title: event.target.value,
});
}
descriptionChange(event: TextChange) {
this.setState({ description: event.target.value });
}
invChange(value: Invites) {
this.setState({
invites: value,
});
}
groupPrivacyChange(event: BooleanChange) {
this.setState({
privacy: event.target.checked,
});
}
onClickCreate() {
const { props, state } = this;
if (!state.groupName) {
this.setState({
groupNameError: true,
});
return;
}
const aud = state.invites.ships.map((ship) => `~${ship}`);
const policy: Enc<GroupPolicy> = state.privacy
? {
invite: {
pending: aud,
},
}
: {
open: {
banRanks: [],
banned: [],
},
};
const { groupName } = this.state;
this.setState(
{
invites: { ships: [], groups: [] },
awaiting: true,
},
() => {
props.api.contacts
.create(groupName, policy, this.state.title, this.state.description)
.then(() => {
this.setState({ awaiting: false });
props.history.push(
`/~groups/ship/~${window.ship}/${state.groupName}`
);
});
}
);
}
render() {
let groupNameErrElem = <span />;
if (this.state.groupNameError) {
groupNameErrElem = (
<span className='f9 inter red2 ml3 mt1 db'>
Group must have a name.
</span>
);
}
const privacySwitchClasses = this.state.privacy
? 'relative checked bg-green2 br3 h1 toggle v-mid z-0'
: 'relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0';
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='/~groups/'>{'⟵ All Groups'}</Link>
</div>
<div className='w-100 mb4 pr6 pr0-l pr0-xl'>
<h2 className='f8'>Create New Group</h2>
<h2 className='f8 pt6'>Group Name</h2>
<textarea
className={
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 mt2 ' +
'focus-b--black focus-b--white-d'
}
rows={1}
placeholder='Jazz Maximalists Research Unit'
style={{
resize: 'none',
height: 48,
paddingTop: 14,
}}
onChange={this.groupNameChange}
/>
{groupNameErrElem}
<h2 className='f8 pt6'>
Description <span className='gray2'>(Optional)</span>
</h2>
<textarea
className={
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 mt2 ' +
'focus-b--black focus-b--white-d'
}
rows={1}
placeholder='Two trumpeters and a microphone'
style={{
resize: 'none',
height: 48,
paddingTop: 14,
}}
onChange={this.descriptionChange}
/>
<div className='mv7'>
<input
type='checkbox'
style={{ WebkitAppearance: 'none', width: 28 }}
onChange={this.groupPrivacyChange}
className={privacySwitchClasses}
/>
<span className='dib f9 white-d inter ml3'>Private Group</span>
<p className='f9 gray2 pt1' style={{ paddingLeft: 40 }}>
If private, new members must be invited
</p>
</div>
{this.state.privacy && (
<>
<h2 className='f8 pt6'>
Invite <span className='gray2'>(Optional)</span>
</h2>
<p className='f9 gray2 lh-copy'>
Selected ships will be invited to your group
</p>
<div className='relative pb6 mt2'>
<InviteSearch
groups={{}}
contacts={this.props.contacts}
groupResults={false}
shipResults={true}
invites={this.state.invites}
setInvite={this.invChange}
/>
</div>
</>
)}
<button
onClick={this.onClickCreate.bind(this)}
className='f9 ba pa2 b--green2 green2 pointer bg-transparent'
>
Start Group
</button>
<Link to='/~groups'>
<button className='f9 ml3 ba pa2 b--black pointer bg-transparent b--white-d white-d'>
Cancel
</button>
</Link>
<Spinner
awaiting={this.state.awaiting}
classes='mt4'
text='Creating group...'
/>
</div>
</div>
);
}
}

View File

@ -172,3 +172,8 @@ export type GroupUpdate =
| GroupUpdateInitialGroup;
export type GroupAction = Omit<GroupUpdate, 'initialGroup' | 'initial'>;
export const groupBunts = {
group: (): Group => ({ members: new Set(), tags: { role: {} }, hidden: false, policy: groupBunts.policy() }),
policy: (): GroupPolicy => ({ open: { banned: new Set(), banRanks: new Set() } })
};