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

View File

@ -1,49 +1,45 @@
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 { JoinProgress, joinProgress, MetadataUpdatePreview, joinError } from "~/types";
import {
JoinProgress,
joinProgress,
MetadataUpdatePreview,
joinError,
} from "~/types";
import { clamp } from "~/logic/lib/util";
interface JoiningStatusProps {
resource: string;
api: GlobalApi;
status: JoinProgress;
}
const description: string[] =
["Attempting to contact group host",
"Retrieving group data",
"Finished join",
"Unable to join group, you do not have the correct permissions",
"Internal error, please file an issue"
];
const description: string[] = [
"Attempting to contact host",
"Retrieving data",
"Finished join",
"Unable to join, you do not have the correct permissions",
"Internal error, please file an issue",
];
export function JoiningStatus(props: JoiningStatusProps) {
const { resource, status, api } = props;
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
useEffect(() => {
(async () => {
const prev = await api.metadata.preview(resource);
setPreview(prev)
})();
return () => {
setPreview(null);
}
}, [resource])
const { status } = props;
const current = joinProgress.indexOf(status);
const desc = description?.[current] || "";
const title = preview?.metadata?.title ?? resource;
const isError = joinError.indexOf(status as any) !== -1;
return (
<Col py="3" mx="5" gapY="2">
<Text fontSize="1">{isError ? "Failed to join " : "Joining "} {title}</Text>
<Text color={isError ? "red" : "gray"}>{desc}</Text>
<SegmentedProgressBar current={current + 1} segments={3} />
</Col>
<Row
display={["flex-column", "flex"]}
alignItems="center"
px="4"
gapX="4"
>
<Box flexGrow={1} maxWidth="400px">
<SegmentedProgressBar current={current + 1} segments={3} />
</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 { Metadata } from "~/types";
import { Metadata, PropFunc } from "~/types";
import { Col, Row, Text } from "@tlon/indigo-react";
import { MetadataIcon } from "./MetadataIcon";
import { useTutorialModal } from "~/views/components/useTutorialModal";
@ -11,10 +11,11 @@ interface GroupSummaryProps {
channelCount: number;
resource?: string;
children?: ReactNode;
gray?: boolean;
}
export function GroupSummary(props: GroupSummaryProps) {
const { channelCount, memberCount, metadata, resource, children } = props;
export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>) {
const { channelCount, memberCount, metadata, resource, children, ...rest } = props;
const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal(
"group-desc",
@ -22,7 +23,7 @@ export function GroupSummary(props: GroupSummaryProps) {
anchorRef.current
);
return (
<Col ref={anchorRef} maxWidth="300px" gapY="4">
<Col {...rest} ref={anchorRef} gapY="4">
<Row gapX="2" width="100%">
<MetadataIcon
borderRadius="1"
@ -39,7 +40,7 @@ export function GroupSummary(props: GroupSummaryProps) {
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden">{metadata.title}</Text>
<Row gapX="2" justifyContent="space-between">
<Row gapX="4" >
<Text fontSize="1" gray>
{memberCount} participants
</Text>
@ -51,7 +52,8 @@ export function GroupSummary(props: GroupSummaryProps) {
</Row>
<Row width="100%">
{metadata.description &&
<Text
<Text
gray
width="100%"
fontSize="1"
textOverflow="ellipsis"

View File

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