mirror of
https://github.com/urbit/shrub.git
synced 2024-12-19 16:51:42 +03:00
Invite: fix stale props in inviteAccept callback
Now that the store is immutable, callbacks that close over props will see stale props because the reference is captured at instantiation. This was not an issue previously, because the references would be directly mutated. To remedy this, useRunIO has been added, which takes an async function to run for the actual network processing, and a second callback, which is able to see fresh props because it is not instantiated until the async callback has resolved successfully. Importantly, useRunIO does not resolve until the second callback has finished, preserving the semantics of the callback for Formik handlers and the like. Fixes urbit/landscape#691
This commit is contained in:
parent
264fce214b
commit
6da3877430
52
pkg/interface/src/logic/lib/useRunIO.ts
Normal file
52
pkg/interface/src/logic/lib/useRunIO.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useWaitForProps } from "./useWaitForProps";
|
||||
import {unstable_batchedUpdates} from "react-dom";
|
||||
|
||||
export type IOInstance<I, P, O> = (
|
||||
input: I
|
||||
) => (props: P) => Promise<[(p: P) => boolean, O]>;
|
||||
|
||||
export function useRunIO<I, O>(
|
||||
io: (i: I) => Promise<O>,
|
||||
after: (o: O) => void,
|
||||
key: string
|
||||
) {
|
||||
const [resolve, setResolve] = useState<() => void>(() => () => {});
|
||||
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
|
||||
const [output, setOutput] = useState<O | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const run = (i: I) =>
|
||||
new Promise((res, rej) => {
|
||||
setResolve(() => res);
|
||||
setReject(() => rej);
|
||||
io(i)
|
||||
.then((o) => {
|
||||
unstable_batchedUpdates(() => {
|
||||
setOutput(o);
|
||||
setDone(true);
|
||||
});
|
||||
})
|
||||
.catch(rej);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reject(new Error("useRunIO: key changed"));
|
||||
setDone(false);
|
||||
setOutput(null);
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
after(output!);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}, [done]);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
@ -1,27 +1,28 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import {
|
||||
MetadataUpdatePreview,
|
||||
Contacts,
|
||||
JoinRequests,
|
||||
Groups,
|
||||
Associations
|
||||
} from '@urbit/api';
|
||||
import { Invite } from '@urbit/api/invite';
|
||||
import { Text, Icon, Row } from '@tlon/indigo-react';
|
||||
Associations,
|
||||
} from "@urbit/api";
|
||||
import { Invite } from "@urbit/api/invite";
|
||||
import { Text, Icon, Row } from "@tlon/indigo-react";
|
||||
|
||||
import { cite, useShowNickname } from '~/logic/lib/util';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
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 useGroupState from '~/logic/state/group';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import useGraphState from '~/logic/state/graph';
|
||||
import { cite, useShowNickname } from "~/logic/lib/util";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
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 useGroupState from "~/logic/state/group";
|
||||
import useContactState from "~/logic/state/contact";
|
||||
import useMetadataState from "~/logic/state/metadata";
|
||||
import useGraphState from "~/logic/state/graph";
|
||||
import { useRunIO } from "~/logic/lib/useRunIO";
|
||||
|
||||
interface InviteItemProps {
|
||||
invite?: Invite;
|
||||
@ -32,62 +33,81 @@ interface InviteItemProps {
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function useInviteAccept(
|
||||
resource: string,
|
||||
api: GlobalApi,
|
||||
app?: string,
|
||||
uid?: string,
|
||||
invite?: Invite,
|
||||
) {
|
||||
const { ship, name } = resourceFromPath(resource);
|
||||
const history = useHistory();
|
||||
const associations = useMetadataState((s) => s.associations);
|
||||
const groups = useGroupState((s) => s.groups);
|
||||
const graphKeys = useGraphState((s) => s.graphKeys);
|
||||
|
||||
const waiter = useWaitForProps({ associations, graphKeys, groups });
|
||||
return useRunIO<void, boolean>(
|
||||
async () => {
|
||||
if (!(app && invite && uid)) {
|
||||
return false;
|
||||
}
|
||||
if (resource in groups) {
|
||||
await api.invite.decline(app, uid);
|
||||
return false;
|
||||
}
|
||||
|
||||
await api.groups.join(ship, name);
|
||||
await api.invite.accept(app, uid);
|
||||
await waiter((p) => {
|
||||
return (
|
||||
(resource in p.groups &&
|
||||
resource in (p.associations?.graph ?? {}) &&
|
||||
p.graphKeys.has(resource.slice(7))) ||
|
||||
resource in (p.associations?.groups ?? {})
|
||||
);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
(success: boolean) => {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
if (groups?.[resource]?.hidden) {
|
||||
const { metadata } = associations.graph[resource];
|
||||
if (metadata && "graph" in metadata.config) {
|
||||
if (metadata.config.graph === "chat") {
|
||||
history.push(
|
||||
`/~landscape/messages/resource/${metadata.config.graph}${resource}`
|
||||
);
|
||||
} else {
|
||||
history.push(
|
||||
`/~landscape/home/resource/${metadata.config.graph}${resource}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("unknown metadata: ", metadata);
|
||||
}
|
||||
} else {
|
||||
history.push(`/~landscape${resource}`);
|
||||
}
|
||||
},
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteItem(props: InviteItemProps) {
|
||||
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
|
||||
const { pendingJoin, invite, resource, uid, app, api } = props;
|
||||
const { ship, name } = resourceFromPath(resource);
|
||||
const groups = useGroupState(state => state.groups);
|
||||
const graphKeys = useGraphState(s => s.graphKeys);
|
||||
const associations = useMetadataState(state => state.associations);
|
||||
const contacts = useContactState(state => state.contacts);
|
||||
const { name } = resourceFromPath(resource);
|
||||
const contacts = useContactState((state) => state.contacts);
|
||||
const contact = contacts?.[`~${invite?.ship}`] ?? {};
|
||||
const showNickname = useShowNickname(contact);
|
||||
const waiter = useWaitForProps(
|
||||
{ associations, groups, pendingJoin, graphKeys: Array.from(graphKeys) },
|
||||
50000
|
||||
);
|
||||
|
||||
const history = useHistory();
|
||||
const inviteAccept = useCallback(async () => {
|
||||
if (!(app && invite && uid)) {
|
||||
return;
|
||||
}
|
||||
if(resource in groups) {
|
||||
await api.invite.decline(app, uid);
|
||||
return;
|
||||
}
|
||||
|
||||
api.groups.join(ship, name);
|
||||
await waiter(p => !!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 (groups?.[resource]?.hidden) {
|
||||
await waiter(p => p.graphKeys.includes(resource.slice(7)));
|
||||
const { metadata } = associations.graph[resource];
|
||||
if (metadata && 'graph' in metadata.config) {
|
||||
if (metadata.config.graph === 'chat') {
|
||||
history.push(`/~landscape/messages/resource/${metadata.config.graph}${resource}`);
|
||||
} else {
|
||||
history.push(`/~landscape/home/resource/${metadata.config.graph}${resource}`);
|
||||
}
|
||||
} else {
|
||||
console.error('unknown metadata: ', metadata);
|
||||
}
|
||||
} else {
|
||||
history.push(`/~landscape${resource}`);
|
||||
}
|
||||
}, [app, history, waiter, invite, uid, resource, groups, associations]);
|
||||
const inviteAccept = useInviteAccept(resource, api, app, uid, invite);
|
||||
|
||||
const inviteDecline = useCallback(async () => {
|
||||
if(!(app && uid)) {
|
||||
if (!(app && uid)) {
|
||||
return;
|
||||
}
|
||||
await api.invite.decline(app, uid);
|
||||
@ -96,7 +116,7 @@ export function InviteItem(props: InviteItemProps) {
|
||||
const handlers = { onAccept: inviteAccept, onDecline: inviteDecline };
|
||||
|
||||
useEffect(() => {
|
||||
if (!app || app === 'groups') {
|
||||
if (!app || app === "groups") {
|
||||
(async () => {
|
||||
setPreview(await api.metadata.preview(resource));
|
||||
})();
|
||||
@ -108,7 +128,7 @@ export function InviteItem(props: InviteItemProps) {
|
||||
}
|
||||
}, [invite]);
|
||||
|
||||
if(pendingJoin?.hidden) {
|
||||
if (pendingJoin?.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -123,7 +143,7 @@ export function InviteItem(props: InviteItemProps) {
|
||||
{...handlers}
|
||||
/>
|
||||
);
|
||||
} else if (invite && name.startsWith('dm--')) {
|
||||
} else if (invite && name.startsWith("dm--")) {
|
||||
return (
|
||||
<InviteSkeleton
|
||||
gapY="3"
|
||||
@ -133,16 +153,18 @@ export function InviteItem(props: InviteItemProps) {
|
||||
>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text mr="1"
|
||||
<Text
|
||||
mr="1"
|
||||
mono={!showNickname}
|
||||
fontWeight={showNickname ? '500' : '400'}>
|
||||
fontWeight={showNickname ? "500" : "400"}
|
||||
>
|
||||
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
|
||||
</Text>
|
||||
<Text mr="1">invited you to a DM</Text>
|
||||
</Row>
|
||||
</InviteSkeleton>
|
||||
);
|
||||
} else if (status && name.startsWith('dm--')) {
|
||||
} else if (status && name.startsWith("dm--")) {
|
||||
return (
|
||||
<JoinSkeleton api={api} resource={resource} status={status} gapY="3">
|
||||
<Row py="1" alignItems="center">
|
||||
@ -162,9 +184,11 @@ export function InviteItem(props: InviteItemProps) {
|
||||
>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text mr="1"
|
||||
<Text
|
||||
mr="1"
|
||||
mono={!showNickname}
|
||||
fontWeight={showNickname ? '500' : '400'}>
|
||||
fontWeight={showNickname ? "500" : "400"}
|
||||
>
|
||||
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
|
||||
</Text>
|
||||
<Text mr="1">
|
||||
@ -174,14 +198,12 @@ export function InviteItem(props: InviteItemProps) {
|
||||
</InviteSkeleton>
|
||||
);
|
||||
} else if (pendingJoin) {
|
||||
const [, , ship, name] = resource.split('/');
|
||||
const [, , ship, name] = resource.split("/");
|
||||
return (
|
||||
<JoinSkeleton api={api} resource={resource} status={pendingJoin}>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text mr="1">
|
||||
You are joining
|
||||
</Text>
|
||||
<Text mr="1">You are joining</Text>
|
||||
<Text mono>
|
||||
{cite(ship)}/{name}
|
||||
</Text>
|
||||
|
Loading…
Reference in New Issue
Block a user