chat: UX overhaul for new groups

Removes public unmanaged chats, allows group DMs.

Renames variables that only made sense during sig-prepended unmanaged
paths usage.
This commit is contained in:
Matilde Park 2020-07-15 15:34:54 -04:00
parent 3f29e98918
commit f82a464719
5 changed files with 225 additions and 231 deletions

View File

@ -147,7 +147,7 @@ export default class ChatApp extends React.Component<ChatAppProps, {}> {
/>
<Route
exact
path="/~chat/new/dm/:ship"
path="/~chat/new/dm/:ship?"
render={(props) => {
const ship = props.match.params.ship;

View File

@ -1,4 +1,5 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { ChannelItem } from './channel-item';
export class GroupItem extends Component {
@ -14,10 +15,10 @@ export class GroupItem extends Component {
}
const channels = props.channels ? props.channels : [];
const first = (props.index === 0) ? 'pt1' : 'pt4';
const first = (props.index === 0) ? 'mt1 ' : 'mt4 ';
const channelItems = channels.sort((a, b) => {
if (props.index === '/~/') {
if (props.index === 'dm') {
const aPreview = props.messagePreviews[a];
const bPreview = props.messagePreviews[b];
const aWhen = aPreview ? aPreview.when : 0;
@ -63,10 +64,23 @@ export class GroupItem extends Component {
/>
);
});
let dmLink = <div />;
if (props.index === 'dm') {
dmLink = <Link
className="absolute right-0 f9 top-0 mr4 green2 bg-gray5 bg-gray1-d b--transparent br1"
to="/~chat/new/dm"
style={{ padding: '0rem 0.2rem' }}
>
+ DM
</Link>;
}
return (
<div className={first}>
<div className={first + 'relative'}>
<p className="f9 ph4 fw6 pb2 gray3">{title}</p>
{channelItems}
{dmLink}
{channelItems}
</div>
);
}

View File

@ -1,26 +1,36 @@
import React, { Component } from "react";
import { Spinner } from "../../../components/Spinner";
import { Link } from "react-router-dom";
import urbitOb from "urbit-ob";
import React, { Component } from 'react';
import { Spinner } from '../../../components/Spinner';
import { Link } from 'react-router-dom';
import { InviteSearch } from '../../../components/InviteSearch';
import urbitOb from 'urbit-ob';
import { deSig } from '../../../lib/util';
export class NewDmScreen extends Component {
constructor(props) {
super(props);
this.state = {
ship: null,
ships: [],
station: null,
awaiting: false
awaiting: false,
title: '',
idName: '',
description: ''
};
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.onClickCreate = this.onClickCreate.bind(this);
this.setInvite = this.setInvite.bind(this);
}
componentDidMount() {
const { props } = this;
if (props.autoCreate && urbitOb.isValidPatp(props.autoCreate)) {
const addedShip = this.state.ships;
addedShip.push(props.autoCreate.slice(1));
this.setState(
{
ship: props.autoCreate.slice(1),
ships: addedShip,
awaiting: true
},
this.onClickCreate
@ -40,65 +50,188 @@ export class NewDmScreen extends Component {
}
}
titleChange(event) {
const asciiSafe = event.target.value.toLowerCase()
.replace(/[^a-z0-9~_.-]/g, '-');
this.setState({
idName: asciiSafe,
title: event.target.value
});
}
descriptionChange(event) {
this.setState({
description: event.target.value
});
}
setInvite(value) {
this.setState({
ships: value.ships
});
}
onClickCreate() {
const { props, state } = this;
const station = `/~${window.ship}/dm--${state.ship}`;
if (state.ships.length === 1) {
const station = `/~${window.ship}/dm--${state.ships[0]}`;
const theirStation = `/~${state.ship}/dm--${window.ship}`;
const theirStation = `/~${state.ships[0]}/dm--${window.ship}`;
if (station in props.inbox) {
props.history.push(`/~chat/room${station}`);
return;
}
if (theirStation in props.inbox) {
props.history.push(`/~chat/room${theirStation}`);
return;
}
const aud = state.ship !== window.ship ? [`~${state.ship}`] : [];
this.setState(
{
station
},
() => {
const groupPath = `/ship${station}`;
props.api.chat.create(
`~${window.ship} <-> ~${state.ship}`,
"",
station,
groupPath,
{ invite: { pending: aud } },
aud,
true,
false
);
if (station in props.inbox) {
props.history.push(`/~chat/room${station}`);
return;
}
);
if (theirStation in props.inbox) {
props.history.push(`/~chat/room${theirStation}`);
return;
}
const aud = state.ship !== window.ship ? [`~${state.ships[0]}`] : [];
let title = `~${window.ship} <-> ~${state.ships[0]}`;
if (state.title !== '') {
title = state.title;
}
this.setState(
{
station, awaiting: true
},
() => {
const groupPath = `/ship/~${window.ship}/dm--${state.ships[0]}`;
props.api.chat.create(
title,
state.description,
station,
groupPath,
{ invite: { pending: aud } },
aud,
true,
false
);
}
);
}
if (state.ships.length > 1) {
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
let title = 'Direct Message';
if (state.title !== '') {
title = state.title;
} else {
const asciiSafe = title.toLowerCase()
.replace(/[^a-z0-9~_.-]/g, '-');
this.setState({ idName: asciiSafe });
}
const station = `/~${window.ship}/${state.idName}-${Math.floor(Math.random() * 10000)}`;
this.setState(
{
station, awaiting: true
},
() => {
const groupPath = `/ship${station}`;
props.api.chat.create(
title,
state.description,
station,
groupPath,
{ invite: { pending: aud } },
aud,
true,
false
);
}
);
}
}
render() {
const { props, state } = this;
const createClasses = (state.idName || state.ships.length >= 1)
? 'pointer dib f9 green2 bg-gray0-d ba pv3 ph4 b--green2 mt4'
: 'pointer dib f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3 mt4';
const idClasses =
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
'focus-b--black focus-b--white-d mt1 ';
return (
<div
className={
"h-100 w-100 mw6 pa3 pt4 overflow-x-hidden " +
"bg-gray0-d white-d flex flex-column"
'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="/~chat/">{"⟵ All Chats"}</Link>
<Link to="/~chat/">{'⟵ All Chats'}</Link>
</div>
<h2 className="mb3 f8">New DM</h2>
<h2 className="mb3 f8">New Direct Message</h2>
<div className="w-100">
<p className="f8 mt4 db">
Name
<span className="gray3"> (Optional)</span>
</p>
<textarea
className={idClasses}
placeholder="The Passage"
rows={1}
style={{
resize: 'none'
}}
onChange={this.titleChange}
/>
<p className="f8 mt4 db">
Description
<span className="gray3"> (Optional)</span>
</p>
<textarea
className={idClasses}
placeholder="The most beautiful direct message"
rows={1}
style={{
resize: 'none'
}}
onChange={this.descriptionChange}
/>
<p className="f8 mt4 db">
Invite Members
</p>
<p className="f9 gray2 db mv1">
Selected ships will be invited to the direct message
</p>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
associations={props.associations}
groupResults={false}
shipResults={true}
invites={{
groups: [],
ships: state.ships
}}
setInvite={this.setInvite}
/>
<button
onClick={this.onClickCreate.bind(this)}
className={createClasses}
>
Create Direct Message
</button>
<Spinner
awaiting={this.state.awaiting}
classes="mt4"
text="Creating chat..."
text="Creating Direct Message..."
/>
</div>
</div>
</div>
);
}
}

View File

@ -3,7 +3,6 @@ import { InviteSearch } from '../../../components/InviteSearch';
import { Spinner } from '../../../components/Spinner';
import { Link } from 'react-router-dom';
import { deSig } from '../../../lib/util';
import urbitOb from 'urbit-ob';
export class NewScreen extends Component {
constructor(props) {
@ -14,9 +13,8 @@ export class NewScreen extends Component {
idName: '',
groups: [],
ships: [],
privacy: 'open',
privacy: 'invite',
idError: false,
inviteError: false,
allowHistory: true,
createGroup: false,
awaiting: false
@ -24,10 +22,7 @@ export class NewScreen extends Component {
this.titleChange = this.titleChange.bind(this);
this.descriptionChange = this.descriptionChange.bind(this);
this.allowHistoryChange = this.allowHistoryChange.bind(this);
this.setInvite = this.setInvite.bind(this);
this.createGroupChange = this.createGroupChange.bind(this);
this.privacyChange = this.privacyChange.bind(this);
}
componentDidUpdate(prevProps, prevState) {
@ -63,42 +58,13 @@ export class NewScreen extends Component {
});
}
createGroupChange(event) {
if (event.target.checked) {
this.setState({
createGroup: Boolean(event.target.checked)
});
} else {
this.setState({
createGroup: Boolean(event.target.checked)
});
}
}
privacyChange(event) {
if (event.target.checked) {
this.setState({
privacy: 'open'
});
} else {
this.setState({
privacy: 'invite'
});
}
}
allowHistoryChange(event) {
this.setState({ allowHistory: Boolean(event.target.checked) });
}
onClickCreate() {
const { props, state } = this;
const grouped = (this.state.createGroup || (this.state.groups.length > 0));
if (!state.title) {
this.setState({
idError: true,
inviteError: false
idError: true
});
return;
}
@ -107,33 +73,13 @@ export class NewScreen extends Component {
if (station in props.inbox) {
this.setState({
inviteError: false,
idError: true,
success: false
});
return;
}
let isValid = true;
const aud = state.ships.map(mem => `~${deSig(mem.trim())}`);
aud.forEach((mem) => {
if (!urbitOb.isValidPatp(mem)) {
isValid = false;
}
});
if(state.ships.length === 1 && state.privacy === 'invite' && !state.createGroup) {
props.history.push(`/~chat/new/dm/${aud[0]}`);
}
if (!isValid) {
this.setState({
inviteError: true,
idError: false,
success: false
});
return;
}
if (this.textarea) {
this.textarea.value = '';
@ -148,13 +94,7 @@ export class NewScreen extends Component {
ships: [],
awaiting: true
}, () => {
// if we want a "proper group" that can be managed from the contacts UI,
// we make a path of the form /~zod/cool-group
// if not, we make a path of the form /~/~zod/free-chat
let appPath = `/~${window.ship}${station}`;
// if (!state.createGroup && state.groups.length === 0) {
// appPath = `/~${appPath}`;
// }
const appPath = `/~${window.ship}${station}`;
let groupPath = `/ship${appPath}`;
if (state.groups.length > 0) {
groupPath = state.groups[0];
@ -178,21 +118,14 @@ export class NewScreen extends Component {
render() {
const { props, state } = this;
let privacySwitchClasses = (state.privacy === 'invite')
? 'relative checked bg-green2 br3 h1 toggle v-mid z-0'
: 'relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0';
const createGroupClasses = state.createGroup
? 'relative checked bg-green2 br3 h1 toggle v-mid z-0'
: 'relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0';
const createClasses = state.idName
? 'pointer db f9 green2 bg-gray0-d ba pv3 ph4 b--green2'
: 'pointer db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3';
? 'pointer db f9 green2 bg-gray0-d ba pv3 ph4 b--green2 mt4'
: 'pointer db f9 gray2 ba bg-gray0-d pa2 pv3 ph4 b--gray3 mt4';
const idClasses =
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 db w-100 ' +
'focus-b--black focus-b--white-d ';
'focus-b--black focus-b--white-d mt1 ';
let idErrElem = (<span />);
if (state.idError) {
@ -203,24 +136,6 @@ export class NewScreen extends Component {
);
}
let createGroupToggle = <div />;
if (state.groups.length === 0) {
createGroupToggle = (
<div className="mv7">
<input
type="checkbox"
style={{ WebkitAppearance: 'none', width: 28 }}
className={createGroupClasses}
onChange={this.createGroupChange}
/>
<span className="dib f9 white-d inter ml3">Create Group</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Participants will share this group across applications
</p>
</div>
);
}
return (
<div
className={
@ -231,9 +146,9 @@ export class NewScreen extends Component {
<div className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8">
<Link to="/~chat/">{'⟵ All Chats'}</Link>
</div>
<h2 className="mb3 f8">New Chat</h2>
<h2 className="mb4 f8">New Group Chat</h2>
<div className="w-100">
<p className="f8 mt3 lh-copy db">Name</p>
<p className="f8 mt4 db">Name</p>
<textarea
className={idClasses}
placeholder="Secret Chat"
@ -244,7 +159,7 @@ export class NewScreen extends Component {
onChange={this.titleChange}
/>
{idErrElem}
<p className="f8 mt3 lh-copy db">
<p className="f8 mt4 db">
Description
<span className="gray3"> (Optional)</span>
</p>
@ -257,38 +172,27 @@ export class NewScreen extends Component {
}}
onChange={this.descriptionChange}
/>
<p className="f8 mt4 lh-copy db">
Invite
<span className="gray3"> (Optional)</span>
<div className="mt4 db relative">
<p className="f8">
Select Group
</p>
<p className="f9 gray2 db mb2 pt1">
Selected groups or ships will be able to post to chat
<Link className="green2 absolute right-0 bottom-0 f9" to="/~groups/new">+New</Link>
<p className="f9 gray2 db mv1">
Chat will be added to selected group
</p>
</div>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
associations={props.associations}
groupResults={true}
shipResults={true}
shipResults={false}
invites={{
groups: state.groups,
ships: state.ships
ships: []
}}
setInvite={this.setInvite}
/>
{createGroupToggle}
<div className="mv7">
<input
type="checkbox"
style={{ WebkitAppearance: 'none', width: 28 }}
className={privacySwitchClasses}
onChange={this.privacyChange}
/>
<span className="dib f9 white-d inter ml3">Private</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Users will have to be invited to join
</p>
</div>
<button
onClick={this.onClickCreate.bind(this)}
className={createClasses}

View File

@ -1,40 +1,17 @@
import React, { Component } from 'react';
import _ from 'lodash';
import Welcome from './lib/welcome';
import { alphabetiseAssociations } from '../../../lib/util';
import { SidebarInvite } from './lib/sidebar-invite';
import { GroupItem } from './lib/group-item';
import { ShipSearchInput } from './lib/ship-search';
export class Sidebar extends Component {
constructor() {
super();
this.state = {
dmOverlay: false
};
}
onClickNew() {
this.props.history.push('/~chat/new');
}
onClickDm() {
this.setState(({ dmOverlay }) => ({ dmOverlay: !dmOverlay }) );
}
onClickJoin() {
this.props.history.push('/~chat/join');
}
goDm(ship) {
this.setState({ dmOverlay: false }, () => {
this.props.history.push(`/~chat/new/dm/~${ship}`);
});
}
render() {
const { props, state } = this;
const { props } = this;
const selectedGroups = props.selectedGroups ? props.selectedGroups : [];
@ -60,12 +37,12 @@ export class Sidebar extends Component {
groupedChannels[path] = [box];
}
} else {
if (groupedChannels['/~/']) {
const array = groupedChannels['/~/'];
if (groupedChannels['dm']) {
const array = groupedChannels['dm'];
array.push(box);
groupedChannels['/~/'] = array;
groupedChannels['dm'] = array;
} else {
groupedChannels['/~/'] = [box];
groupedChannels['dm'] = [box];
}
}
});
@ -109,29 +86,21 @@ export class Sidebar extends Component {
/>
);
});
if (groupedChannels['/~/'] && groupedChannels['/~/'].length !== 0) {
if (groupedChannels['dm'] && groupedChannels['dm'].length !== 0) {
groupedItems.push(
<GroupItem
association={'/~/'}
association={'dm'}
chatMetadata={chatAssoc}
channels={groupedChannels['/~/']}
channels={groupedChannels['dm']}
inbox={props.inbox}
station={props.station}
unreads={props.unreads}
index={'/~/'}
key={'/~/'}
index={'dm'}
key={'dm'}
{...props}
/>
);
}
const candidates = state.dmOverlay
? _.chain(this.props.contacts)
.values()
.map(_.keys)
.flatten()
.uniq()
.value()
: [];
return (
<div
@ -143,33 +112,7 @@ export class Sidebar extends Component {
className="dib f9 pointer green2 gray4-d mr4"
onClick={this.onClickNew.bind(this)}
>
New Chat
</a>
<div className="dib relative mr4">
{ state.dmOverlay && (
<ShipSearchInput
className="absolute"
contacts={{}}
candidates={candidates}
onSelect={this.goDm.bind(this)}
onClear={this.onClickDm.bind(this)}
/>
)}
<a
className="f9 pointer green2 gray4-d"
onClick={this.onClickDm.bind(this)}
>
DM
</a>
</div>
<a
className="dib f9 pointer gray4-d"
onClick={this.onClickJoin.bind(this)}
>
Join Chat
New Group Chat
</a>
</div>
<div className="overflow-y-auto h-100">