Invites, JoinStatus: refactor for smoothness

This commit is contained in:
Liam Fitzgerald 2021-02-10 15:12:16 +10:00
parent b4e5430bfc
commit afb0424efd
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
9 changed files with 414 additions and 127 deletions

View File

@ -1,8 +1,9 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import _ from 'lodash';
import { Box, Row, Col } from "@tlon/indigo-react"; import { Box, Row, Col } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { Invites as IInvites, Associations, Invite, JoinRequests, Groups } from "~/types"; import { Invites as IInvites, Associations, Invite, JoinRequests, Groups, Contacts, AppInvites, JoinProgress } from "~/types";
import { resourceAsPath } from "~/logic/lib/util"; import { resourceAsPath, alphabeticalOrder } from "~/logic/lib/util";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useWaitForProps } from "~/logic/lib/useWaitForProps"; import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import InviteItem from "~/views/components/Invite"; import InviteItem from "~/views/components/Invite";
@ -14,10 +15,17 @@ interface InvitesProps {
api: GlobalApi; api: GlobalApi;
invites: IInvites; invites: IInvites;
groups: Groups; groups: Groups;
contacts: Contacts;
associations: Associations; associations: Associations;
pendingJoin: JoinRequests; pendingJoin: JoinRequests;
} }
interface InviteRef {
uid: string;
app: string
invite: Invite;
}
export function Invites(props: InvitesProps) { export function Invites(props: InvitesProps) {
const { api, invites, pendingJoin } = props; const { api, invites, pendingJoin } = props;
const [selected, setSelected] = useState<[string, string, Invite] | undefined>() const [selected, setSelected] = useState<[string, string, Invite] | undefined>()
@ -50,6 +58,17 @@ export function Invites(props: InvitesProps) {
/> />
)}}); )}});
const inviteArr: InviteRef[] = _.reduce(invites, (acc: InviteRef[], val: AppInvites, app: string) => {
const appInvites = _.reduce(val, (invs: InviteRef[], invite: Invite, uid: string) => {
return [...invs, { invite, uid, app }];
}, []);
return [...acc, ...appInvites];
}, []);
const invitesAndStatus: { [rid: string]: JoinProgress | InviteRef } =
{..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)), ...props.pendingJoin };
return ( return (
@ -61,33 +80,42 @@ export function Invites(props: InvitesProps) {
position="sticky" position="sticky"
flexShrink={0} flexShrink={0}
> >
{modal}
{ Object { Object
.keys(props.pendingJoin) .keys(invitesAndStatus)
.map(resource => ( .sort(alphabeticalOrder)
<JoiningStatus .map(resource => {
key={resource} const inviteOrStatus = invitesAndStatus[resource];
resource={resource} if(typeof inviteOrStatus === 'string') {
status={pendingJoin[resource]}
api={api} />
))
}
{Object.keys(invites).reduce((items, appKey) => {
const app = invites[appKey];
let appItems = Object.keys(app).map((uid) => {
const invite = app[uid];
return ( return (
<InviteItem <InviteItem
key={uid} key={resource}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
resource={resource}
pendingJoin={pendingJoin}
api={api} />
)
} else {
const { app, uid, invite } = inviteOrStatus;
console.log(inviteOrStatus);
return (
<InviteItem
key={resource}
api={api}
invite={invite} invite={invite}
onAccept={acceptInvite(appKey, uid, invite)} app={app}
onDecline={declineInvite(appKey, uid)} uid={uid}
pendingJoin={pendingJoin}
resource={resource}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
/> />
); )
}); }
return [...items, ...appItems]; })}
}, [] as JSX.Element[])}
</Col> </Col>
); );
} }

View File

@ -1,49 +1,45 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Col, Text, SegmentedProgressBar } from "@tlon/indigo-react"; import { Col, Row, Text, SegmentedProgressBar, Box } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import { JoinProgress, joinProgress, MetadataUpdatePreview, joinError } from "~/types"; import {
JoinProgress,
joinProgress,
MetadataUpdatePreview,
joinError,
} from "~/types";
import { clamp } from "~/logic/lib/util"; import { clamp } from "~/logic/lib/util";
interface JoiningStatusProps { interface JoiningStatusProps {
resource: string;
api: GlobalApi;
status: JoinProgress; status: JoinProgress;
} }
const description: string[] = const description: string[] = [
["Attempting to contact group host", "Attempting to contact host",
"Retrieving group data", "Retrieving data",
"Finished join", "Finished join",
"Unable to join group, you do not have the correct permissions", "Unable to join, you do not have the correct permissions",
"Internal error, please file an issue" "Internal error, please file an issue",
]; ];
export function JoiningStatus(props: JoiningStatusProps) { export function JoiningStatus(props: JoiningStatusProps) {
const { resource, status, api } = props; const { status } = props;
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
useEffect(() => {
(async () => {
const prev = await api.metadata.preview(resource);
setPreview(prev)
})();
return () => {
setPreview(null);
}
}, [resource])
const current = joinProgress.indexOf(status); const current = joinProgress.indexOf(status);
const desc = description?.[current] || ""; const desc = description?.[current] || "";
const title = preview?.metadata?.title ?? resource;
const isError = joinError.indexOf(status as any) !== -1; const isError = joinError.indexOf(status as any) !== -1;
return ( return (
<Col py="3" mx="5" gapY="2"> <Row
<Text fontSize="1">{isError ? "Failed to join " : "Joining "} {title}</Text> display={["flex-column", "flex"]}
<Text color={isError ? "red" : "gray"}>{desc}</Text> alignItems="center"
px="4"
gapX="4"
>
<Box flexGrow={1} maxWidth="400px">
<SegmentedProgressBar current={current + 1} segments={3} /> <SegmentedProgressBar current={current + 1} segments={3} />
</Col> </Box>
<Text display="block" flexShrink={0} color={isError ? "red" : "gray"}>
{desc}
</Text>
</Row>
); );
} }

View File

@ -1,52 +0,0 @@
import React, { Component } from 'react';
import { Invite } from '~/types/invite-update';
import { Text, Box, Button, Row, Rule } from '@tlon/indigo-react';
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { cite } from '~/logic/lib/util';
export class InviteItem extends Component<{invite: Invite, onAccept: (i: any) => Promise<any>, onDecline: (i: any) => Promise<any>}, {}> {
render() {
const { props } = this;
return (
<>
<Box width='100%' p='4'>
<Box width='100%' verticalAlign='middle'>
<Text display='block' pb='2' gray>
<Text mono>{cite(props.invite.resource.ship)}</Text>
{" "}invited you to{" "}
<Text fontWeight='500'>{props.invite.resource.name}</Text></Text>
</Box>
{props.invite.text && (
<Box pb="2">
<Text gray>{props.invite.text}</Text>
</Box>
)}
<Row>
<StatelessAsyncAction
name="accept"
bg="transparent"
onClick={() => props.onAccept(props.invite)}
color='blue'
mr='2'
>
Accept
</StatelessAsyncAction>
<StatelessAsyncAction
name="decline"
bg="transparent"
color='red'
onClick={() => props.onDecline(props.invite)}
>
Reject
</StatelessAsyncAction>
</Row>
</Box>
<Rule />
</>
);
}
}
export default InviteItem;

View File

@ -0,0 +1,76 @@
import React, { ReactNode } from "react";
import { Text, Box, Button, Icon, Row, Rule, Col } from "@tlon/indigo-react";
import { cite } from "~/logic/lib/util";
import { MetadataUpdatePreview, JoinProgress, Invite } from "~/types";
import { GroupSummary } from "~/views/landscape/components/GroupSummary";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
interface GroupInviteProps {
preview: MetadataUpdatePreview;
status?: JoinProgress;
invite?: Invite;
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
}
export function GroupInvite(props: GroupInviteProps) {
const { preview, invite, status, onAccept, onDecline } = props;
const { metadata, members } = props.preview;
let inner: ReactNode = null;
let Outer: (p: { children: ReactNode }) => JSX.Element = (p) => (
<>{p.children}</>
);
if (status) {
inner = (
<Text mr="1">
You are joining <Text fontWeight="medium">{metadata.title}</Text>
</Text>
);
Outer = ({ children }) => (
<JoinSkeleton gapY="3" status={status}>
{children}
</JoinSkeleton>
);
} else if (invite) {
Outer = ({ children }) => (
<InviteSkeleton
onDecline={onDecline}
onAccept={onAccept}
acceptDesc="Join Group"
declineDesc="Decline Invitation"
gapY="3"
>
{children}
</InviteSkeleton>
);
inner = (
<>
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">invited you to </Text>
<Text fontWeight="medium">{metadata.title}</Text>
</>
);
}
return (
<Outer>
<Row py="1" alignItems="center">
<Icon display="block" mr={2} icon="Bullet" color="blue" />
{inner}
</Row>
<Box px="4">
<GroupSummary
gray
metadata={metadata}
memberCount={members}
channelCount={preview?.["channel-count"]}
/>
</Box>
</Outer>
);
}

View File

@ -0,0 +1,53 @@
import React, { ReactNode } from "react";
import { Text, Box, Button, Icon, Row, Rule, Col } from "@tlon/indigo-react";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { PropFunc } from "~/types";
export interface InviteSkeletonProps {
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
acceptDesc: string;
declineDesc: string;
children: ReactNode;
}
export function InviteSkeleton(
props: InviteSkeletonProps & PropFunc<typeof Col>
) {
const {
children,
acceptDesc,
declineDesc,
onAccept,
onDecline,
...rest
} = props;
return (
<>
<Col width="100%" p="1" {...rest}>
{children}
<Row px="4" gapX="4">
<StatelessAsyncAction
name="accept"
bg="transparent"
onClick={onAccept}
color="blue"
mr="2"
>
{acceptDesc}
</StatelessAsyncAction>
<StatelessAsyncAction
name="decline"
bg="transparent"
color="red"
onClick={onDecline}
>
{declineDesc}
</StatelessAsyncAction>
</Row>
</Col>
<Rule />
</>
);
}

View File

@ -0,0 +1,22 @@
import React, { ReactNode } from "react";
import { Col, Row, SegmentedProgressBar, Text, Rule } from "@tlon/indigo-react";
import { JoiningStatus } from "~/views/apps/notifications/joining";
import { JoinProgress, PropFunc } from "~/types";
type JoinSkeletonProps = {
children: ReactNode;
status: JoinProgress;
} & PropFunc<typeof Col>;
export function JoinSkeleton(props: JoinSkeletonProps) {
const { children, status, ...rest } = props;
return (
<>
<Col p="1" {...rest}>
{children}
<JoiningStatus status={status} />
</Col>
<Rule />
</>
);
}

View File

@ -0,0 +1,172 @@
import React, { Component, useState, useEffect, useCallback, useMemo } from "react";
import { Invite } from "~/types/invite-update";
import { Text, Box, Button, Icon, Row, Rule, Col } from "@tlon/indigo-react";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { cite } from "~/logic/lib/util";
import {
MetadataUpdatePreview,
Contacts,
JoinRequests,
JoinProgress,
Groups,
Associations,
} from "~/types";
import GlobalApi from "~/logic/api/global";
import { GroupSummary } from "~/views/landscape/components/GroupSummary";
import { JoiningStatus } from "~/views/apps/notifications/joining";
import { resourceFromPath } from "~/logic/lib/group";
import { GroupInvite } from "./Group";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { useHistory } from "react-router-dom";
interface InviteItemProps {
invite?: Invite;
resource: string;
groups: Groups;
associations: Associations;
pendingJoin: JoinRequests;
app?: string;
uid?: string;
api: GlobalApi;
contacts: Contacts;
}
export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { associations, pendingJoin, invite, resource, uid, app, api } = props;
const { ship, name } = resourceFromPath(resource);
const waiter = useWaitForProps(props, 50000);
const status = pendingJoin[resource];
const history = useHistory();
const inviteAccept = useCallback(async () => {
if (!(app && invite && uid)) {
return;
}
api.groups.join(ship, name);
await waiter(p => resource in p.pendingJoin);
api.invite.accept(app, uid);
await waiter((p) => {
return (
resource in p.groups &&
(resource in (p.associations?.graph ?? {}) ||
resource in (p.associations?.groups ?? {}))
);
});
if (props.groups?.[resource]?.hidden) {
const { metadata } = associations.graph[resource];
if (name.startsWith("dm--")) {
history.push(`/~landscape/messages/resource/${metadata.module}${resource}`);
} else {
history.push(`/~landscape/home/resource/${metadata.module}${resource}`);
}
} else {
history.push(`/~landscape${resource}`);
}
}, [app, invite, uid, resource, props.groups, associations]);
const inviteDecline = useCallback(async () => {
if(!(app && uid)) {
return;
}
await api.invite.decline(app, uid);
}, [app, uid]);
const handlers = { onAccept: inviteAccept, onDecline: inviteDecline }
useEffect(() => {
if (!app || app === "groups") {
(async () => {
setPreview(await api.metadata.preview(resource));
})();
return () => {
setPreview(null);
};
} else {
return () => {};
}
}, [invite]);
if (preview) {
return (
<GroupInvite
preview={preview}
invite={invite}
status={status}
{...handlers}
/>
);
} else if (invite && name.startsWith("dm--")) {
return (
<InviteSkeleton
gapY="3"
{...handlers}
acceptDesc="Join DM"
declineDesc="Decline DM"
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">invited you to a DM</Text>
</Row>
</InviteSkeleton>
);
} else if (status && name.startsWith("dm--")) {
return (
<JoinSkeleton status={status} gapY="3">
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">You are joining a DM with</Text>
<Text mr="1" mono>
{cite("~hastuc-dibtux")}
</Text>
</Row>
</JoinSkeleton>
);
} else if (invite) {
return (
<InviteSkeleton
acceptDesc="Accept Invite"
declineDesc="Decline Invite"
{...handlers}
gapY="3"
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">
invited you to ~{invite.resource.ship}/{invite.resource.name}
</Text>
</Row>
</InviteSkeleton>
);
} else if (status) {
const [, , ship, name] = resource.split("/");
return (
<JoinSkeleton status={status}>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">
You are joining
</Text>
<Text mono>
{cite(ship)}/{name}
</Text>
</Row>
</JoinSkeleton>
);
}
return null;
}
export default InviteItem;

View File

@ -1,5 +1,5 @@
import React, { ReactNode, useRef } from "react"; import React, { ReactNode, useRef } from "react";
import { Metadata } from "~/types"; import { Metadata, PropFunc } from "~/types";
import { Col, Row, Text } from "@tlon/indigo-react"; import { Col, Row, Text } from "@tlon/indigo-react";
import { MetadataIcon } from "./MetadataIcon"; import { MetadataIcon } from "./MetadataIcon";
import { useTutorialModal } from "~/views/components/useTutorialModal"; import { useTutorialModal } from "~/views/components/useTutorialModal";
@ -11,10 +11,11 @@ interface GroupSummaryProps {
channelCount: number; channelCount: number;
resource?: string; resource?: string;
children?: ReactNode; children?: ReactNode;
gray?: boolean;
} }
export function GroupSummary(props: GroupSummaryProps) { export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>) {
const { channelCount, memberCount, metadata, resource, children } = props; const { channelCount, memberCount, metadata, resource, children, ...rest } = props;
const anchorRef = useRef<HTMLElement | null>(null); const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal( useTutorialModal(
"group-desc", "group-desc",
@ -22,7 +23,7 @@ export function GroupSummary(props: GroupSummaryProps) {
anchorRef.current anchorRef.current
); );
return ( return (
<Col ref={anchorRef} maxWidth="300px" gapY="4"> <Col {...rest} ref={anchorRef} gapY="4">
<Row gapX="2" width="100%"> <Row gapX="2" width="100%">
<MetadataIcon <MetadataIcon
borderRadius="1" borderRadius="1"
@ -39,7 +40,7 @@ export function GroupSummary(props: GroupSummaryProps) {
textOverflow="ellipsis" textOverflow="ellipsis"
whiteSpace="nowrap" whiteSpace="nowrap"
overflow="hidden">{metadata.title}</Text> overflow="hidden">{metadata.title}</Text>
<Row gapX="2" justifyContent="space-between"> <Row gapX="4" >
<Text fontSize="1" gray> <Text fontSize="1" gray>
{memberCount} participants {memberCount} participants
</Text> </Text>
@ -52,6 +53,7 @@ export function GroupSummary(props: GroupSummaryProps) {
<Row width="100%"> <Row width="100%">
{metadata.description && {metadata.description &&
<Text <Text
gray
width="100%" width="100%"
fontSize="1" fontSize="1"
textOverflow="ellipsis" textOverflow="ellipsis"

View File

@ -45,9 +45,6 @@ interface JoinGroupProps {
groups: Groups; groups: Groups;
associations: Associations; associations: Associations;
api: GlobalApi; api: GlobalApi;
autojoin?: string;
inviteUid?: string;
inviteApp?: string;
} }
function Autojoin(props: { autojoin: string | null }) { function Autojoin(props: { autojoin: string | null }) {
@ -78,9 +75,6 @@ export function JoinGroup(props: JoinGroupProps) {
const onConfirm = useCallback(async (group: string) => { const onConfirm = useCallback(async (group: string) => {
const [,,ship,name] = group.split('/'); const [,,ship,name] = group.split('/');
await api.groups.join(ship, name); await api.groups.join(ship, name);
if (props.inviteUid && props.inviteApp) {
api.invite.accept(props.inviteApp, props.inviteUid);
}
try { try {
await waiter((p: JoinGroupProps) => { await waiter((p: JoinGroupProps) => {
return group in p.groups && return group in p.groups &&
@ -99,17 +93,13 @@ export function JoinGroup(props: JoinGroupProps) {
// drop them into inbox to show join request still pending // drop them into inbox to show join request still pending
history.push('/~notifications'); history.push('/~notifications');
} }
}, [api, props.inviteApp, props.inviteUid, waiter, history, associations, groups]); }, [api, waiter, history, associations, groups]);
const onSubmit = useCallback( const onSubmit = useCallback(
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => { async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
const [ship, name] = values.group.split("/"); const [ship, name] = values.group.split("/");
const path = `/ship/${ship}/${name}`; const path = `/ship/${ship}/${name}`;
// skip if it's unmanaged // skip if it's unmanaged
if(!!autojoin && props.inviteApp !== 'groups') {
await onConfirm(path);
return;
}
try { try {
const prev = await api.metadata.preview(path); const prev = await api.metadata.preview(path);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
@ -127,7 +117,7 @@ export function JoinGroup(props: JoinGroupProps) {
} }
} }
}, },
[api, waiter, history, onConfirm, props.inviteApp] [api, waiter, history, onConfirm]
); );
return ( return (
@ -152,7 +142,7 @@ export function JoinGroup(props: JoinGroupProps) {
<GroupSummary <GroupSummary
metadata={preview.metadata} metadata={preview.metadata}
memberCount={preview?.members} memberCount={preview?.members}
channelCount={preview?.channels?.length} channelCount={preview?.['channel-count']}
> >
{ Object.keys(preview.channels).length > 0 && ( { Object.keys(preview.channels).length > 0 && (
<Col <Col