chat: create dm route, restore participants option

This commit is contained in:
Matilde Park 2020-10-21 15:55:11 -04:00
parent a580f1fba4
commit f01fdf9efa
8 changed files with 164 additions and 186 deletions

View File

@ -114,7 +114,6 @@ export function ChatResource(props: ChatResourceProps) {
group={group}
ship={owner}
station={station}
allStations={Object.keys(props.inbox)}
api={props.api}
hideNicknames={props.hideNicknames}
hideAvatars={props.hideAvatars}

View File

@ -46,7 +46,6 @@ interface ChatMessageProps {
className?: string;
isPending: boolean;
style?: any;
allStations: any;
scrollWindow: HTMLDivElement;
isLastMessage?: boolean;
unreadMarkerRef: React.RefObject<HTMLDivElement>;
@ -87,7 +86,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
scrollWindow,
isLastMessage,
unreadMarkerRef,
allStations,
history,
api
} = this.props;
@ -118,7 +116,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
style,
containerClass,
isPending,
allStations,
history,
api,
scrollWindow
@ -165,7 +162,6 @@ interface MessageProps {
containerClass: string;
isPending: boolean;
style: any;
allStations: any;
measure(element): void;
scrollWindow: HTMLDivElement;
};
@ -182,7 +178,6 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
hideAvatars,
remoteContentPolicy,
measure,
allStations,
history,
api,
scrollWindow
@ -218,7 +213,6 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
hideAvatars={hideAvatars}
hideNicknames={hideNicknames}
scrollWindow={scrollWindow}
allStations={allStations}
history={history}
api={api}
className="fl pr3 v-top bg-white bg-gray0-d pt1"

View File

@ -39,7 +39,6 @@ type ChatWindowProps = RouteComponentProps<{
group: Group;
ship: Patp;
station: any;
allStations: any;
api: GlobalApi;
hideNicknames: boolean;
hideAvatars: boolean;
@ -56,7 +55,7 @@ interface ChatWindowState {
export default class ChatWindow extends Component<ChatWindowProps, ChatWindowState> {
private virtualList: VirtualScroller | null;
private unreadMarkerRef: React.RefObject<HTMLDivElement>;
INITIALIZATION_MAX_TIME = 1500;
constructor(props) {
@ -68,14 +67,14 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
initialized: false,
lastRead: props.unreadCount ? props.mailboxSize - props.unreadCount : Infinity,
};
this.dismissUnread = this.dismissUnread.bind(this);
this.scrollToUnread = this.scrollToUnread.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.dismissIfLineVisible = this.dismissIfLineVisible.bind(this);
this.virtualList = null;
this.unreadMarkerRef = React.createRef();
}
@ -88,7 +87,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.setState({ initialized: true });
}, this.INITIALIZATION_MAX_TIME);
}
componentWillUnmount() {
window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('focus', this.handleWindowFocus);
@ -192,10 +191,10 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
}
this.setState({ fetchPending: true });
start = Math.min(mailboxSize - start, mailboxSize);
end = Math.max(mailboxSize - end, 0, start - MAX_BACKLOG_SIZE);
return api.chat
.fetchMessages(end, start, station)
.finally(() => {
@ -224,7 +223,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.dismissUnread();
}
}
render() {
const {
envelopes,
@ -243,7 +242,6 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
hideAvatars,
hideNicknames,
remoteContentPolicy,
allStations,
history
} = this.props;
@ -251,7 +249,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
const messages = new Map();
let lastMessage = 0;
[...envelopes]
.sort((a, b) => a.number - b.number)
.forEach(message => {
@ -267,8 +265,8 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
lastMessage = mailboxSize + index;
});
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef, allStations, history, api };
const messageProps = { association, group, contacts, hideAvatars, hideNicknames, remoteContentPolicy, unreadMarkerRef, history, api };
return (
<>
<UnreadNotice

View File

@ -55,7 +55,7 @@ export class OverlaySigil extends PureComponent {
render() {
const { props, state } = this;
const { hideAvatars, allStations } = props;
const { hideAvatars } = props;
const img = (props.contact && (props.contact.avatar !== null) && !hideAvatars)
? <img src={props.contact.avatar} height={16} width={16} className="dib" />
@ -84,7 +84,6 @@ export class OverlaySigil extends PureComponent {
association={props.association}
group={props.group}
onDismiss={this.profileHide}
allStations={allStations}
hideAvatars={hideAvatars}
hideNicknames={props.hideNicknames}
history={props.history}

View File

@ -12,7 +12,6 @@ export class ProfileOverlay extends PureComponent {
this.popoverRef = React.createRef();
this.onDocumentClick = this.onDocumentClick.bind(this);
this.createAndRedirectToDM = this.createAndRedirectToDM.bind(this);
}
componentDidMount() {
@ -25,42 +24,6 @@ export class ProfileOverlay extends PureComponent {
document.removeEventListener('touchstart', this.onDocumentClick);
}
createAndRedirectToDM() {
const { api, ship, history, allStations } = this.props;
const station = `/~${window.ship}/dm--${ship}`;
const theirStation = `/~${ship}/dm--${window.ship}`;
if (allStations.indexOf(station) !== -1) {
history.push(`/~landscape/home/resource/chat${station}`);
return;
}
if (allStations.indexOf(theirStation) !== -1) {
history.push(`/~landscape/home/resource/chat${theirStation}`);
return;
}
const groupPath = `/ship/~${window.ship}/dm--${ship}`;
const aud = ship !== window.ship ? [`~${ship}`] : [];
const title = `${cite(window.ship)} <-> ${cite(ship)}`;
api.chat.create(
title,
'',
station,
groupPath,
{ invite: { pending: aud } },
aud,
true,
false
);
// TODO: make a pretty loading state
setTimeout(() => {
history.push(`/~landscape/home/resource/chat${station}`);
}, 5000);
}
onDocumentClick(event) {
const { popoverRef } = this;
// Do nothing if clicking ref's element or descendent elements
@ -72,7 +35,7 @@ export class ProfileOverlay extends PureComponent {
}
render() {
const { contact, ship, color, topSpace, bottomSpace, group, association, hideNicknames, hideAvatars, history } = this.props;
const { contact, ship, color, topSpace, bottomSpace, group, hideNicknames, hideAvatars, history } = this.props;
let top, bottom;
if (topSpace < OVERLAY_HEIGHT / 2) {
@ -132,7 +95,7 @@ export class ProfileOverlay extends PureComponent {
)}
<Text mono gray>{cite(`~${ship}`)}</Text>
{!isOwn && (
<Button mt={2} width="100%" style={{ cursor: 'pointer' }} onClick={this.createAndRedirectToDM}>
<Button mt={2} width="100%" style={{ cursor: 'pointer' }} onClick={() => history.push(`/~landscape/dm/${ship}`)}>
Send Message
</Button>
)}

View File

@ -1,38 +1,36 @@
import React, { useCallback } from "react";
import React, { useCallback } from 'react';
import {
Box,
ManagedTextInputField as Input,
Col,
ManagedRadioButtonField as Radio,
Text,
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import GlobalApi from "~/logic/api/global";
import { AsyncButton } from "~/views/components/AsyncButton";
import { FormError } from "~/views/components/FormError";
import { RouteComponentProps } from "react-router-dom";
import { stringToSymbol } from "~/logic/lib/util";
import GroupSearch from "~/views/components/GroupSearch";
import { Associations } from "~/types/metadata-update";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { Notebooks } from "~/types/publish-update";
import { Groups } from "~/types/group-update";
import { ShipSearch } from "~/views/components/ShipSearch";
import { Rolodex } from "~/types";
Text
} from '@tlon/indigo-react';
import { Formik, Form } from 'formik';
import * as Yup from 'yup';
import GlobalApi from '~/logic/api/global';
import { AsyncButton } from '~/views/components/AsyncButton';
import { FormError } from '~/views/components/FormError';
import { RouteComponentProps } from 'react-router-dom';
import { stringToSymbol } from '~/logic/lib/util';
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 { Rolodex } from '~/types';
interface FormSchema {
name: string;
description: string;
ships: string[];
type: "chat" | "publish" | "links";
type: 'chat' | 'publish' | 'links';
}
const formSchema = Yup.object({
name: Yup.string().required("Channel must have a name"),
name: Yup.string().required('Channel must have a name'),
description: Yup.string(),
ships: Yup.array(Yup.string()),
type: Yup.string().required("Must choose channel type"),
type: Yup.string().required('Must choose channel type')
});
interface NewChannelProps {
@ -43,7 +41,6 @@ interface NewChannelProps {
group?: string;
}
export function NewChannel(props: NewChannelProps & RouteComponentProps) {
const { history, api, group, workspace } = props;
@ -54,7 +51,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
try {
const { name, description, type, ships } = values;
switch (type) {
case "chat":
case 'chat':
const appPath = `/~${window.ship}/${resId}`;
const groupPath = group || `/ship${appPath}`;
@ -63,46 +60,46 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
description,
appPath,
groupPath,
{ invite: { pending: ships.map((s) => `~${s}`) } },
ships.map((s) => `~${s}`),
{ invite: { pending: ships.map(s => `~${s}`) } },
ships.map(s => `~${s}`),
true,
false
);
break;
case "publish":
case 'publish':
await props.api.publish.newBook(resId, name, description, group);
break;
case "links":
case 'links':
if (group) {
await api.graph.createManagedGraph(
resId,
name,
description,
group,
"link"
'link'
);
} else {
await api.graph.createUnmanagedGraph(
resId,
name,
description,
{ invite: { pending: ships.map((s) => `~${s}`) } },
"link"
{ invite: { pending: ships.map(s => `~${s}`) } },
'link'
);
}
break;
default:
console.log("fallthrough");
console.log('fallthrough');
}
if (!group) {
await waiter((p) => !!p?.groups?.[`/ship/~${window.ship}/${resId}`]);
await waiter(p => Boolean(p?.groups?.[`/ship/~${window.ship}/${resId}`]));
}
actions.setStatus({ success: null });
} catch (e) {
console.error(e);
actions.setStatus({ error: "Channel creation failed" });
actions.setStatus({ error: 'Channel creation failed' });
}
};
return (
@ -113,11 +110,11 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
<Formik
validationSchema={formSchema}
initialValues={{
type: "chat",
name: "",
description: "",
group: "",
ships: [],
type: 'chat',
name: '',
description: '',
group: '',
ships: []
}}
onSubmit={onSubmit}
>

View File

@ -3,8 +3,8 @@ import React, {
useMemo,
useCallback,
SyntheticEvent,
ChangeEvent,
} from "react";
ChangeEvent
} from 'react';
import {
Col,
Box,
@ -14,23 +14,23 @@ import {
Center,
Button,
Action,
StatelessTextInput as Input,
} from "@tlon/indigo-react";
import _ from "lodash";
import f from "lodash/fp";
import VisibilitySensor from "react-visibility-sensor";
StatelessTextInput as Input
} from '@tlon/indigo-react';
import _ from 'lodash';
import f from 'lodash/fp';
import VisibilitySensor from 'react-visibility-sensor';
import { Contact, Contacts } from "~/types/contact-update";
import { Sigil } from "~/logic/lib/sigil";
import { cite, uxToHex } from "~/logic/lib/util";
import { Group, RoleTags } from "~/types/group-update";
import { roleForShip, resourceFromPath } from "~/logic/lib/group";
import { Association } from "~/types/metadata-update";
import { useHistory, Link } from "react-router-dom";
import { Dropdown } from "~/views/components/Dropdown";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import styled from "styled-components";
import { Contact, Contacts } from '~/types/contact-update';
import { Sigil } from '~/logic/lib/sigil';
import { cite, uxToHex } from '~/logic/lib/util';
import { Group, RoleTags } from '~/types/group-update';
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import { Association } from '~/types/metadata-update';
import { useHistory, Link } from 'react-router-dom';
import { Dropdown } from '~/views/components/Dropdown';
import GlobalApi from '~/logic/api/global';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import styled from 'styled-components';
const TruncText = styled(Box)`
white-space: nowrap;
@ -39,7 +39,7 @@ const TruncText = styled(Box)`
`;
type Participant = Contact & { patp: string; pending: boolean };
type ParticipantsTabId = "total" | "pending" | "admin";
type ParticipantsTabId = 'total' | 'pending' | 'admin';
const searchParticipant = (search: string) => (p: Participant) => {
if (search.length == 0) {
@ -54,36 +54,36 @@ function getParticipants(cs: Contacts, group: Group) {
const contacts: Participant[] = _.map(cs, (c, patp) => ({
...c,
patp,
pending: false,
pending: false
}));
const members: Participant[] = _.map(Array.from(group.members), (m) =>
const members: Participant[] = _.map(Array.from(group.members), m =>
emptyContact(m, false)
);
const allMembers = _.unionBy(contacts, members, "patp");
const allMembers = _.unionBy(contacts, members, 'patp');
const pending: Participant[] =
"invite" in group.policy
? _.map(Array.from(group.policy.invite.pending), (m) =>
'invite' in group.policy
? _.map(Array.from(group.policy.invite.pending), m =>
emptyContact(m, true)
)
: [];
return [
_.unionBy(allMembers, pending, "patp"),
_.unionBy(allMembers, pending, 'patp'),
pending.length,
allMembers.length,
allMembers.length
] as const;
}
const emptyContact = (patp: string, pending: boolean): Participant => ({
nickname: "",
email: "",
phone: "",
color: "",
nickname: '',
email: '',
phone: '',
color: '',
avatar: null,
notes: "",
website: "",
notes: '',
website: '',
patp,
pending,
pending
});
const Tab = ({ selected, id, label, setSelected }) => (
@ -95,7 +95,7 @@ const Tab = ({ selected, id, label, setSelected }) => (
cursor="pointer"
onClick={() => setSelected(id)}
>
<Text color={selected === id ? "black" : "gray"}>{label}</Text>
<Text color={selected === id ? 'black' : 'gray'}>{label}</Text>
</Box>
);
@ -113,18 +113,18 @@ export function Participants(props: {
(p: Participant) => boolean
> = useMemo(
() => ({
total: (p) => !p.pending,
pending: (p) => p.pending,
admin: (p) => props.group.tags?.role?.admin?.has(p.patp),
total: p => !p.pending,
pending: p => p.pending,
admin: p => props.group.tags?.role?.admin?.has(p.patp)
}),
[props.group]
);
const ourRole = roleForShip(props.group, window.ship);
const [filter, setFilter] = useState<ParticipantsTabId>("total");
const [filter, setFilter] = useState<ParticipantsTabId>('total');
const [search, _setSearch] = useState("");
const [search, _setSearch] = useState('');
const setSearch = useMemo(() => _.debounce(_setSearch, 200), [_setSearch]);
const onSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@ -134,7 +134,7 @@ export function Participants(props: {
);
const adminCount = props.group.tags?.role?.admin?.size || 0;
const isInvite = "invite" in props.group.policy;
const isInvite = 'invite' in props.group.policy;
const [participants, pendingCount, memberCount] = getParticipants(
props.contacts,
@ -156,7 +156,7 @@ export function Participants(props: {
// TODO: remove when resolved
const isSafari = useMemo(() => {
const ua = window.navigator.userAgent;
return ua.includes("Safari") && !ua.includes("Chrome");
return ua.includes('Safari') && !ua.includes('Chrome');
}, []);
return (
@ -166,7 +166,7 @@ export function Participants(props: {
border={1}
borderColor="washedGray"
borderRadius={1}
position={isSafari ? "static" : "sticky"}
position={isSafari ? 'static' : 'sticky'}
top="0px"
mb={2}
px={2}
@ -192,7 +192,7 @@ export function Participants(props: {
selected={filter}
setSelected={setFilter}
id="admin"
label={`${adminCount} Admin${adminCount > 1 ? "s" : ""}`}
label={`${adminCount} Admin${adminCount > 1 ? 's' : ''}`}
/>
</Row>
</Row>
@ -210,8 +210,8 @@ export function Participants(props: {
</Row>
<Box
display="grid"
gridAutoRows={["48px 48px 1px", "48px 1px"]}
gridTemplateColumns={["48px 1fr", "48px 2fr 1fr", "48px 3fr 1fr"]}
gridAutoRows={['48px 48px 1px', '48px 1px']}
gridTemplateColumns={['48px 1fr', '48px 2fr 1fr', '48px 3fr 1fr']}
gridRowGap={2}
alignItems="center"
>
@ -224,7 +224,7 @@ export function Participants(props: {
>
{({ isVisible }) =>
isVisible ? (
cs.map((c) => (
cs.map(c => (
<Participant
api={api}
key={c.patp}
@ -261,37 +261,37 @@ function Participant(props: {
const { title } = association.metadata;
const color = uxToHex(contact.color);
const isInvite = "invite" in group.policy;
const isInvite = 'invite' in group.policy;
const role = useMemo(
() =>
contact.pending
? "pending"
: roleForShip(group, contact.patp) || "member",
? 'pending'
: roleForShip(group, contact.patp) || 'member',
[contact, group]
);
const onPromote = useCallback(async () => {
const resource = resourceFromPath(association["group-path"]);
await api.groups.addTag(resource, { tag: "admin" }, [`~${contact.patp}`]);
const resource = resourceFromPath(association['group-path']);
await api.groups.addTag(resource, { tag: 'admin' }, [`~${contact.patp}`]);
}, [api, association]);
const onDemote = useCallback(async () => {
const resource = resourceFromPath(association["group-path"]);
await api.groups.removeTag(resource, { tag: "admin" }, [
`~${contact.patp}`,
const resource = resourceFromPath(association['group-path']);
await api.groups.removeTag(resource, { tag: 'admin' }, [
`~${contact.patp}`
]);
}, [api, association]);
const onBan = useCallback(async () => {
const resource = resourceFromPath(association["group-path"]);
const resource = resourceFromPath(association['group-path']);
await api.groups.changePolicy(resource, {
open: { banShips: [`~${contact.patp}`] },
open: { banShips: [`~${contact.patp}`] }
});
}, [api, association]);
const onKick = useCallback(async () => {
const resource = resourceFromPath(association["group-path"]);
const resource = resourceFromPath(association['group-path']);
await api.groups.remove(resource, [`~${contact.patp}`]);
}, [api, association]);
@ -319,7 +319,7 @@ function Participant(props: {
</Col>
<Row
justifyContent="space-between"
gridColumn={["1 / 3", "auto"]}
gridColumn={['1 / 3', 'auto']}
alignItems="center"
>
<Col>
@ -340,7 +340,12 @@ function Participant(props: {
gapY={2}
p={2}
>
{props.role === "admin" && (
<Action bg="transparent">
<Link to={`/~landscape/dm/${contact.patp}`}>
<Text color="green">Send Message</Text>
</Link>
</Action>
{props.role === 'admin' && (
<>
{!isInvite && (
<>
@ -352,7 +357,7 @@ function Participant(props: {
</StatelessAsyncAction>
</>
)}
{role === "admin" ? (
{role === 'admin' ? (
<StatelessAsyncAction onClick={onDemote} bg="transparent">
Demote from Admin
</StatelessAsyncAction>
@ -372,7 +377,7 @@ function Participant(props: {
<Box
borderBottom={1}
borderBottomColor="washedGray"
gridColumn={["1 / 3", "1 / 4"]}
gridColumn={['1 / 3', '1 / 4']}
/>
</>
);
@ -382,7 +387,7 @@ function BlankParticipant({ length }) {
return (
<Box
gridRow={[`auto / span ${3 * length}`, `auto / span ${2 * length}`]}
gridColumn={["1 / 3", "1 / 4"]}
gridColumn={['1 / 3', '1 / 4']}
height="100%"
/>
);

View File

@ -1,20 +1,17 @@
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Box, Center } from '@tlon/indigo-react';
import './css/custom.css';
import { PatpNoSig, AppName } from '~/types/noun';
import { PatpNoSig } from '~/types/noun';
import GlobalApi from '~/logic/api/global';
import { StoreState } from '~/logic/store/type';
import GlobalSubscription from '~/logic/subscription/global';
import { Resource } from '~/views/components/Resource';
import { PopoverRoutes } from './components/PopoverRoutes';
import { UnjoinedResource } from '~/views/components/UnjoinedResource';
import { GroupsPane } from './components/GroupsPane';
import { Workspace } from '~/types';
import {NewGroup} from './components/NewGroup';
import {JoinGroup} from './components/JoinGroup';
import { NewGroup } from './components/NewGroup';
import { JoinGroup } from './components/JoinGroup';
import { cite } from '~/logic/lib/util';
type LandscapeProps = StoreState & {
@ -34,25 +31,46 @@ export default class Landscape extends Component<LandscapeProps, {}> {
this.props.subscription.startApp('publish');
this.props.subscription.startApp('graph');
this.props.api.publish.fetchNotebooks();
}
createandRedirectToDM(api, ship, history, allStations) {
const station = `/~${window.ship}/dm--${ship}`;
const theirStation = `/~${ship}/dm--${window.ship}`;
if (allStations.indexOf(station) !== -1) {
history.push(`/~landscape/home/resource/chat${station}`);
return;
}
if (allStations.indexOf(theirStation) !== -1) {
history.push(`/~landscape/home/resource/chat${theirStation}`);
return;
}
const groupPath = `/ship/~${window.ship}/dm--${ship}`;
const aud = ship !== window.ship ? [`~${ship}`] : [];
const title = `${cite(window.ship)} <-> ${cite(ship)}`;
api.chat.create(
title,
'',
station,
groupPath,
{ invite: { pending: aud } },
aud,
true,
false
);
// TODO: make a pretty loading state
setTimeout(() => {
history.push(`/~landscape/home/resource/chat${station}`);
}, 5000);
}
render() {
const { props } = this;
const contacts = props.contacts || {};
const defaultContacts =
(Boolean(props.contacts) && '/~/default' in props.contacts) ?
props.contacts['/~/default'] : {};
const invites =
(Boolean(props.invites) && '/contacts' in props.invites) ?
props.invites['/contacts'] : {};
const s3 = props.s3 ? props.s3 : {};
const groups = props.groups || {};
const associations = props.associations || {};
const { api } = props;
const { api, inbox } = props;
return (
<Switch>
@ -73,7 +91,6 @@ export default class Landscape extends Component<LandscapeProps, {}> {
<Route path="/~landscape/home"
render={routeProps => {
const ws: Workspace = { type: 'home' };
return (
<GroupsPane workspace={ws} baseUrl="/~landscape/home" {...props} />
);
@ -85,21 +102,27 @@ export default class Landscape extends Component<LandscapeProps, {}> {
<NewGroup
groups={props.groups}
contacts={props.contacts}
api={props.api}
api={props.api}
{...routeProps}
/>
);
}}
/>
<Route path='/~landscape/dm/:ship?'
render={routeProps => {
const { ship } = routeProps.match.params;
return this.createandRedirectToDM(api, ship, routeProps.history, Object.keys(inbox));
}}
/>
<Route path="/~landscape/join/:ship?/:name?"
render={routeProps=> {
const { ship, name } = routeProps.match.params;
const autojoin = ship && name ? `${ship}/${name}` : null;
return (
<JoinGroup
<JoinGroup
groups={props.groups}
contacts={props.contacts}
api={props.api}
api={props.api}
autojoin={autojoin}
{...routeProps}
/>