Join the call as soon as a request to join is accepted

An invite that replaced a request to join was read as somebody else being
mid-join. Nobody always is: a host without auto-join, a join that failed, or
a fresh load already at that membership left the user waiting with nothing to
press but "Cancel request". The lobby now takes such an invite up itself,
showing a joining state, and offers a join of the user's own if that fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Quentin Gliech
2026-09-09 22:44:59 +02:00
co-authored by Claude Fable 5.1
parent d78c204ef4
commit 1c8930daa0
6 changed files with 265 additions and 37 deletions
+2
View File
@@ -156,7 +156,9 @@
"invite_only_body": "You need an invite to join this call.",
"join_as_guest": "Join as guest",
"join_button": "Join call",
"joining": "Joining…",
"leave_button": "Back to recents",
"request_accepted": "Your request to join was accepted.",
"request_sent": "Request to join sent",
"request_sent_body": "You will receive an invite to join the call if your request is accepted."
},
+7 -2
View File
@@ -10,8 +10,11 @@ Please see LICENSE in the repository root for full details.
* the lobby offers them. Everything the lobby needs to know about membership.
*/
export type LobbyJoinState =
/** The user may enter the call right away. */
| { kind: "can-join"; join: () => void }
/**
* The user may enter the call right away. `notice` is set when they got here
* by having a request accepted, but the join it entitles them to failed.
*/
| { kind: "can-join"; join: () => void; notice?: "request_accepted" }
/**
* The room only takes knocks. `error` is set when a previous request failed
* to send.
@@ -23,6 +26,8 @@ export type LobbyJoinState =
}
/** The request is on its way to the server. */
| { kind: "sending-request" }
/** The user's join is on its way. */
| { kind: "joining" }
/**
* The request is with the room's moderators. `cancelRequest` is absent while
* a withdrawal is on its way, and where withdrawing is not supported.
+26
View File
@@ -203,6 +203,22 @@ describe("LobbyView", () => {
disabled: true,
message: null,
},
{
joinState: { kind: "joining" },
button: "Joining",
disabled: true,
message: null,
},
{
joinState: {
kind: "can-join",
join: () => {},
notice: "request_accepted",
},
button: "Join call",
disabled: false,
message: "Your request to join was accepted.",
},
{
joinState: { kind: "waiting-for-approval" },
button: "Request to join sent",
@@ -270,6 +286,16 @@ describe("LobbyView", () => {
);
});
it("does nothing while the join is on its way", async () => {
const { getByTestId } = renderLobbyView({
joinState: { kind: "joining" },
});
const button = getByTestId("lobby_joinCall");
expect(button).toHaveAttribute("aria-busy", "true");
await userEvent.click(button);
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("joins when the join button is pressed", async () => {
const join = vi.fn();
const { getByTestId } = renderLobbyView({
+11 -2
View File
@@ -236,6 +236,7 @@ export const LobbyView: FC<Props> = ({
</Button>
);
case "sending-request":
case "joining":
return (
<Button
className={classNames(styles.join, buttonStyles.rotate)}
@@ -245,7 +246,11 @@ export const LobbyView: FC<Props> = ({
aria-busy
data-testid="lobby_joinCall"
>
{t("lobby.ask_to_join")}
{t(
joinState.kind === "joining"
? "lobby.joining"
: "lobby.ask_to_join",
)}
</Button>
);
case "waiting-for-approval":
@@ -269,6 +274,10 @@ export const LobbyView: FC<Props> = ({
const joinMessage = ((): ReactNode => {
switch (joinState.kind) {
case "can-join":
return joinState.notice === undefined ? null : (
<Text size="sm">{t("lobby.request_accepted")}</Text>
);
case "can-ask-to-join":
return joinState.error === undefined ? null : (
<Text size="sm">{t("error.generic")}</Text>
@@ -317,8 +326,8 @@ export const LobbyView: FC<Props> = ({
);
case "not-allowed":
return <Text size="sm">{t("lobby.invite_only_body")}</Text>;
case "can-join":
case "sending-request":
case "joining":
return null;
}
})();
+138 -2
View File
@@ -236,6 +236,53 @@ describe("useLoadGroupCall in the standalone app", () => {
expect(client.waitUntilRoomReadyForGroupCalls).toHaveBeenCalledWith(roomId);
});
it("joins straight away when a request was accepted before this load", async () => {
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitForJoinState(result, "joining");
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.getRoomSummary).not.toHaveBeenCalled();
});
it("offers a join of our own when an accepted request cannot be taken up", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const room = mockRoom({
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
});
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockRejectedValue(new Error("offline")),
});
const { result } = renderLoad(client);
expect(await waitForJoinState(result, "can-join")).toMatchObject({
notice: "request_accepted",
});
expect(error).toHaveBeenCalled();
});
it("joins an invite that answers no request without a lobby", async () => {
const room = mockRoom({ membership: KnownMembership.Invite });
const client = mockClient({
getRoom: vi.fn().mockReturnValue(room),
joinRoom: vi.fn().mockResolvedValue(room),
});
const { result } = renderLoad(client);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
expect(client.joinRoom).toHaveBeenCalledWith(roomId, { viaServers });
});
it("shows a declined request in the lobby", async () => {
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
@@ -366,7 +413,7 @@ describe("useLoadGroupCall as a widget", () => {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
},
"waiting-for-approval",
"joining",
],
["a plain invite", { membership: KnownMembership.Invite }, "can-join"],
[
@@ -416,7 +463,12 @@ describe("useLoadGroupCall as a widget", () => {
const client = mockClient({
getRoom: vi.fn().mockReturnValue(mockRoom(spec)),
});
const { result } = renderLoad(client, host());
// A request the host never answers, so that the state a lobby opens on is
// the one under test.
const { result } = renderLoad(
client,
host(vi.fn().mockReturnValue(new Promise(() => {}))),
);
await waitForJoinState(result, expected as LobbyJoinState["kind"]);
});
@@ -493,6 +545,90 @@ describe("useLoadGroupCall as a widget", () => {
await waitForJoinState(result, "not-allowed");
});
it("asks the host to join us as soon as a request is accepted", async () => {
const changeMembership = vi.fn().mockReturnValue(new Promise(() => {}));
const spec: RoomSpec = { membership: KnownMembership.Knock };
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(client, host(changeMembership));
await waitForJoinState(result, "waiting-for-approval");
spec.membership = KnownMembership.Invite;
emitMembership(client, room, KnownMembership.Invite, KnownMembership.Knock);
await waitForJoinState(result, "joining");
expect(changeMembership).toHaveBeenCalledWith({ action: "join" });
});
it("enters the call when the host reports us joined", async () => {
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
// The host joins us as it answers, so no membership event of our own
// follows the reply.
const joinAsHost = vi.fn(async (): Promise<Membership> => {
spec.membership = KnownMembership.Join;
return await Promise.resolve(KnownMembership.Join);
});
const { result } = renderLoad(client, host(joinAsHost));
await waitFor(() => expect(result.current.kind).toBe("loaded"));
});
it("stays in the call when the host answers our join late", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
let refuse!: (error: Error) => void;
const spec: RoomSpec = {
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
};
const room = mockRoom(spec);
const client = mockClient({ getRoom: vi.fn().mockReturnValue(room) });
const { result } = renderLoad(
client,
host(
vi.fn().mockReturnValue(
new Promise((_resolve, reject) => {
refuse = reject;
}),
),
),
);
await waitForJoinState(result, "joining");
spec.membership = KnownMembership.Join;
emitMembership(client, room, KnownMembership.Join, KnownMembership.Invite);
await waitFor(() => expect(result.current.kind).toBe("loaded"));
const loaded = result.current;
refuse(new Error("Request timed out"));
await waitFor(() => expect(error).toHaveBeenCalled());
expect(result.current).toBe(loaded);
});
it("offers a join of our own when the host cannot answer", async () => {
const error = vi.spyOn(logger, "error").mockImplementation(() => {});
const changeMembership = vi
.fn()
.mockRejectedValue(new Error("Request timed out"));
const client = mockClient({
getRoom: vi.fn().mockReturnValue(
mockRoom({
membership: KnownMembership.Invite,
prevMembership: KnownMembership.Knock,
}),
),
});
const { result } = renderLoad(client, host(changeMembership));
const canJoin = await waitForJoinState(result, "can-join");
expect(canJoin).toMatchObject({ notice: "request_accepted" });
expect(error).toHaveBeenCalled();
act(() => canJoin.join());
await waitForJoinState(result, "joining");
expect(await waitForJoinState(result, "can-join")).toMatchObject({
notice: "request_accepted",
});
expect(changeMembership).toHaveBeenCalledTimes(2);
});
it("resolves when the host joined us before we could listen", async () => {
const room = mockRoom({ membership: KnownMembership.Knock });
(room as { getMyMembership: () => Membership }).getMyMembership = vi
+81 -31
View File
@@ -97,10 +97,18 @@ export class CallTerminatedMessage extends Error {
}
}
/** The membership the local user's current one replaced, if any. */
function previousMembership(room: Room): Membership | undefined {
return room.currentState
.getStateEvents(EventType.RoomMember, room.myUserId)
?.getPrevContent().membership as Membership | undefined;
}
/** The join state a lobby opens on, before the user acts on it. */
type LobbyEntry =
| "can-join"
| "can-ask-to-join"
| "joining"
| "waiting-for-approval"
| "denied"
| "banned"
@@ -196,22 +204,31 @@ export const useLoadGroupCall = (
};
/**
* Resolve once the local user has joined the room, including when they
* already have: the host may have joined us before we could listen.
* Watch for the local user joining the room. `check` re-tests the room as
* it stands, for a join that happened without an event we saw, such as one
* the host had already applied.
*/
const waitForJoin = async (roomId: string): Promise<Room> =>
await new Promise<Room>((resolve) => {
const reached = (room: Room | null): boolean => {
const watchForJoin = (
roomId: string,
): { reached: Promise<Room>; check: () => void } => {
let check = (): void => {};
const reached = new Promise<Room>((resolve) => {
const test = (room: Room | null): boolean => {
if (room?.getMyMembership() !== KnownMembership.Join) return false;
activeRoom.current = room;
resolve(room);
return true;
};
const off = onMyMembership((room) => {
if (room.roomId === roomId && reached(room)) off();
if (room.roomId === roomId && test(room)) off();
});
if (reached(client.getRoom(roomId))) off();
check = (): void => {
if (test(client.getRoom(roomId))) off();
};
check();
});
return { reached, check };
};
/**
* Show the lobby and resolve once the local user has joined. Denial, ban
@@ -232,9 +249,16 @@ export const useLoadGroupCall = (
changeMembershipWithClient(client, roomId, viaServers);
let requestInFlight = false;
let withdrawing = false;
let entered = false;
// Listening before anything is sent, so that a join landing while a
// request is in flight is not missed.
const { reached, check } = watchForJoin(roomId);
/** Sets the lobby state, unless the user is already through the lobby. */
const setLobby = (joinState: LobbyJoinState): void => {
if (!signal.aborted) setState({ kind: "lobby", room, joinState });
if (!entered && !signal.aborted)
setState({ kind: "lobby", room, joinState });
};
const waitForApproval = (): void =>
@@ -245,15 +269,24 @@ export const useLoadGroupCall = (
const canJoin = (): void => setLobby({ kind: "can-join", join });
/** The request was accepted, but the join it entitles us to failed. */
const acceptedButNotJoined = (): void =>
setLobby({
kind: "can-join",
join: joinOnceAccepted,
notice: "request_accepted",
});
const onRequestResult = (
membership: Membership,
operation: string,
onFailure: () => void,
): void => {
requestInFlight = false;
// A join resolves the promise this lobby is parked on, so there is
// nothing left to show.
if (membership === KnownMembership.Join) return;
// A membership the room already holds reaches us in the reply rather
// than in an event of its own, so the join this lobby is parked on is
// re-tested here.
if (membership === KnownMembership.Join) return check();
if (membership === KnownMembership.Knock) return waitForApproval();
logger.error(
`${operation} on ${roomId} left us with membership ${membership}`,
@@ -284,12 +317,34 @@ export const useLoadGroupCall = (
const join = (): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "joining" });
changeMembership({ action: "join" }).then(
(membership) => onRequestResult(membership, "Joining", canJoin),
(error) => onRequestError(error, "Joining", canJoin),
);
};
/** Takes up the invite an accepted request has earned us. */
const joinOnceAccepted = (): void => {
if (requestInFlight) return;
requestInFlight = true;
setLobby({ kind: "joining" });
changeMembership({ action: "join" }).then(
(membership) =>
onRequestResult(
membership,
"Joining once accepted",
acceptedButNotJoined,
),
(error) =>
onRequestError(
error,
"Joining once accepted",
acceptedButNotJoined,
),
);
};
const askToJoin = (reason?: string): void => {
if (requestInFlight) return;
requestInFlight = true;
@@ -320,18 +375,10 @@ export const useLoadGroupCall = (
activeRoom.current = changed;
switch (membership) {
case KnownMembership.Invite:
// A host that changes memberships on our behalf also performs the
// join an accepted request entitles us to.
if (prevMembership !== KnownMembership.Knock) canJoin();
else if (hostBridge.changeMembership === undefined)
changeMembership({ action: "join" }).then(
() => logger.info(`Joined ${roomId} once accepted`),
(error: unknown) =>
logger.error(
`Joining ${roomId} once accepted failed`,
error,
),
);
// An invite that replaced a request is ours to take up: nothing
// else is going to turn it into a join.
if (prevMembership === KnownMembership.Knock) joinOnceAccepted();
else canJoin();
break;
case KnownMembership.Ban:
setLobby({ kind: "banned", reason: leaveReason() });
@@ -357,6 +404,9 @@ export const useLoadGroupCall = (
case "can-ask-to-join":
canAskToJoin();
break;
case "joining":
joinOnceAccepted();
break;
case "waiting-for-approval":
waitForApproval();
break;
@@ -371,7 +421,8 @@ export const useLoadGroupCall = (
break;
}
const joined = await waitForJoin(roomId);
const joined = await reached;
entered = true;
offTransitions();
return joined;
};
@@ -384,16 +435,13 @@ export const useLoadGroupCall = (
room: Room,
membership: Membership | undefined,
): LobbyEntry => {
const prevMembership = room.currentState
.getStateEvents(EventType.RoomMember, room.myUserId)
?.getPrevContent().membership as Membership | undefined;
const prevMembership = previousMembership(room);
if (membership === KnownMembership.Ban) return "banned";
if (membership === KnownMembership.Knock) return "waiting-for-approval";
if (membership === KnownMembership.Invite)
// An invite that replaced a request means the host is mid-join.
return prevMembership === KnownMembership.Knock
? "waiting-for-approval"
? "joining"
: "can-join";
if (prevMembership === KnownMembership.Knock) return "denied";
@@ -457,9 +505,11 @@ export const useLoadGroupCall = (
if (room && membership === KnownMembership.Ban)
return await enterFromLobby(preJoinRoomInfoFromRoom(room), "banned");
if (membership === KnownMembership.Invite)
if (room && membership === KnownMembership.Invite)
return await readyForGroupCalls(
await client.joinRoom(roomId, { viaServers }),
previousMembership(room) === KnownMembership.Knock
? await enterFromLobby(preJoinRoomInfoFromRoom(room), "joining")
: await client.joinRoom(roomId, { viaServers }),
);
// If the room does not exist we first search for it with viaServers