Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,10 +607,20 @@ confirmed `41010` open, including New message Send, unhides the DM, and its row
returns when visibility next refreshes.

A definitive rejection offers retry without optimistic removal. If publication or
confirmation has an uncertain outcome, the dialog warns that the command may have
taken effect, disables blind resubmission and asks the user to close and refresh
channels. Cancellation/cache clear/session replacement fence late results but cannot
retract a request already sent. Cancellation returns focus to the originating row;
confirmation has an uncertain outcome, the failure toast warns that the command may
have taken effect and disables blind resubmission. That toast, and the "no longer
permitted" toast shown when signed channel state no longer allows the action, offer
**Refresh channels**: a writer-consistent channel list read that closes the toast.
The permission check behind "no longer permitted", before signing and again before
publishing, reads the writer, so a lagging replica cannot refuse an action Settings
offers after a refresh. Settings' channel actions follow the refreshed channel state.
Refresh channels returns focus to the failed action's control. If the refreshed
permissions remove that Settings action, focus moves to the Settings-tab close control,
unless the person has already moved focus elsewhere.
After a failed Hide, Refresh channels also rereads DM visibility from the writer:
whether a conversation is hidden is not part of the channel list.
Other failures offer only Dismiss. Cancellation/cache clear/session replacement
fence late results but cannot retract a request already sent. Cancellation returns focus to the originating row;
confirmed Delete, Leave or Hide moves an active conversation to another available destination
(or the neutral Messages page) with a visible sidebar-row focus fallback. Last-row
Delete/Leave/Hide completion uses the explicit version-1 Channels route `"empty"`, which bypasses
Expand Down
31 changes: 23 additions & 8 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ must not let them manufacture relay-authored authority.

`fresh: true` prevents sharing an older in-flight request; it does not select
the writer. Channel creation/admission and recovery, DM opening, channel edits,
member administration, lifecycle confirmations, and mention preflights request
`consistency: "strong"` on their authoritative filters. Signed evidence remains
required; an accepted command is not membership, and writer routing does not
wait for asynchronous relay side effects. Existing cancellation and bounded
confirmation retries are unchanged.
member administration, lifecycle permission preflights and confirmations, and
mention preflights request `consistency: "strong"` on their authoritative
filters. Signed evidence remains required; an accepted command is not
membership, and writer routing does not wait for asynchronous relay side
effects. Existing cancellation and bounded confirmation retries are unchanged.

Channel discovery accepts an explicit consistency option for post-write exact
reads and the full-roster fallback. A queued writer-backed refresh survives an
Expand All @@ -87,8 +87,15 @@ confirmations queues a writer-backed pass to settle those grants. If list failur
disconnect or cache clear retires queued or in-flight hint confirmations, the store
retains their writer requirement for the next deliberate refresh, Retry or
establishment, without starting an automatic recovery pass or bypassing cooldown.
Ordinary startup, browsing, reconnect, and DM visibility refresh stay replica-
eligible. The details editor and member-administration capability use writer-backed
A lifecycle failure toast's Refresh channels requests a writer-backed discovery
pass. The Archive/Unarchive/Delete/Leave/Hide preflight always reads
`39000`/`39001`/`39002` and owner-agent kind-0 profiles from the writer. After a failed
Hide, the same control also rereads DM visibility (`30622`) from the writer; that
read never joins a replica-backed visibility refresh in flight, though an
ordinary refresh started during it joins it. Ordinary startup, browsing,
reconnect, the row and header Channel actions menus, and ordinary DM visibility
refreshes stay replica-eligible.
The details editor and member-administration capability use writer-backed
state for their shared load/preflight/confirmation reads; the member dialog's
separate display-roster load remains replica-eligible. Channel-write membership
preflights (including session sends and canvas saves) also use the writer, so a
Expand Down Expand Up @@ -159,7 +166,15 @@ shortcode, and republishes the whole set with a strictly newer `created_at`.
Adds within a session run one at a time, so a stale read cannot drop an earlier
add. The signed echo must match the template before publishing, the accepted set
joins the palette immediately, and failures surface as the reference
`Failed to add emoji.` / `Timed out while adding emoji.` copy. The development
`Failed to add emoji.` / `Timed out while adding emoji.` copy. On the native
host, a relay refusal appends the relay's reason, clipped to 256 characters,
such as `Failed to add emoji. You are banned from this community`. A timeout
refusal (`restricted: you are timed out until <unix seconds>`) shows its expiry
as a local date and time, such as `You are timed out until Tue, Nov 10 at
2:19 PM`; other refusal text is shown as the relay sent it. The browser
host's broker reports a socket `forbidden:`, `blocked:` or `restricted:` refusal
as not sent, so that host shows `Failed to add emoji.` for those and appends
only `invalid:` reasons; this host difference is unresolved. The development
broker admits kind 30030 only for the canonical own-set template
(`validEmojiSetTemplate`).

Expand Down
13 changes: 11 additions & 2 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,17 @@ See the [capability contract](../src/features/workflows/types.ts).

The outbox journals intent/signature before publication. A verified echo does
not replace the result-bearing receipt. Restored commands never run automatically;
result text stays ephemeral, outside the journal. Workflow intents cannot be replayed
through generic Outbox Retry either; inspection and dismissal remain available.
result text stays ephemeral, outside the journal. A command the relay refuses
with its own reason shows that reason in this session only, with a timeout's
unix-seconds expiry shown as a local date and time; the journal keeps fixed
copy, because relay text can quote event content. Other failures keep the
fixed copy, and a refusal after a verified echo leaves the result unknown. On the
browser host, the broker reports a socket `forbidden:`, `blocked:` or
`restricted:` refusal as not sent, so those keep the fixed copy there, including
the owner-or-admin refusal for a Call Webhook step; this host difference is
unresolved.
Workflow intents cannot be replayed through generic Outbox Retry either;
inspection and dismissal remain available.

**Check saved configuration** resolves an unknown save only when a fresh verified
head matches its owner, channel, UUID and exact signed revision. Missing/different
Expand Down
29 changes: 29 additions & 0 deletions src/bundled/channels/ChannelLifecycleActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,35 @@ it("replaces Archive with Unarchive when the live channel is archived", () => {
screen.getByRole("button", { name: "Unarchive channel" }),
).toBeVisible();
});
it("keeps focus in the pane when a role change retires the focused action, unless it moved", async () => {
const host = session();
const pane = (role: ChannelSummary["viewerRole"]) => (
<>
<aside>
<button type="button">Settings tab</button>
<ChannelLifecycleActions
channel={channel({ viewerRole: role })}
session={host}
choose={vi.fn()}
/>
</aside>
<input aria-label="Elsewhere" />
</>
);
const view = render(pane("admin"));
screen.getByRole("button", { name: "Archive channel" }).focus();
view.rerender(pane("member"));
expect(screen.queryByRole("button", { name: "Archive channel" })).toBeNull();
await vi.waitFor(() =>
expect(screen.getByRole("button", { name: "Settings tab" })).toHaveFocus(),
);
view.rerender(pane("admin"));
screen.getByRole("button", { name: "Archive channel" }).focus();
screen.getByRole("textbox", { name: "Elsewhere" }).focus();
view.rerender(pane("member"));
await Promise.resolve();
expect(screen.getByRole("textbox", { name: "Elsewhere" })).toHaveFocus();
});
it("explains an unsupported host without a lookup or write", () => {
const host = session(false);
render(
Expand Down
28 changes: 26 additions & 2 deletions src/bundled/channels/ChannelLifecycleActions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useCallback } from "react";
import type { ChannelSummary } from "../../features/relay/contracts";
import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol";
import { Button } from "../../shared/design-system/ui/Button";
Expand All @@ -6,7 +7,10 @@ import {
SignOutIcon,
TrashIcon,
} from "../../shared/design-system/icons";
import type { LifecycleNoun } from "./ChannelLifecycleDialog";
import {
lifecycleFocusFallback,
type LifecycleNoun,
} from "./ChannelLifecycleDialog";
import { useLifecycleOffer, type LifecycleSession } from "./useLifecycleOffer";

/** Settings offers live-state actions; the sidebar owns confirmation. */
Expand All @@ -22,18 +26,37 @@ export function ChannelLifecycleActions({
noun?: LifecycleNoun;
}) {
const permissions = useLifecycleOffer(session, channel);
// A refreshed or live role can retire the focused action. Ref cleanup runs
// before its button detaches, while it still owns focus; keep focus in the
// pane unless something else took it meanwhile.
const retire = useCallback((node: HTMLButtonElement | null) => {
if (!node) return;
return () => {
const document = node.ownerDocument;
if (document.activeElement !== node) return;
const fallback = lifecycleFocusFallback(node);
queueMicrotask(() => {
if (!node.isConnected && document.activeElement === document.body)
fallback?.focus({ preventScroll: true });
});
};
}, []);
if (!session.channelLifecycle.available)
return <p>Channel actions unavailable on this connection</p>;
return (
<>
{permissions?.canLeave && (
<Button onClick={(event) => choose("leave", event.currentTarget)}>
<Button
ref={retire}
onClick={(event) => choose("leave", event.currentTarget)}
>
<SignOutIcon size={16} aria-hidden="true" />
Leave {noun}
</Button>
)}
{(permissions?.canArchive || permissions?.canUnarchive) && (
<Button
ref={retire}
onClick={(event) =>
choose(
permissions.canUnarchive ? "unarchive" : "archive",
Expand All @@ -47,6 +70,7 @@ export function ChannelLifecycleActions({
)}
{permissions?.canDelete && (
<Button
ref={retire}
variant="destructive"
onClick={(event) => choose("delete", event.currentTarget)}
>
Expand Down
26 changes: 23 additions & 3 deletions src/bundled/channels/ChannelLifecycleDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Button } from "../../shared/design-system/ui/Button";
import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle";
import {
ChannelLifecycleStale,
type ChannelLifecycleCapability,
} from "../../features/relay/channel-lifecycle";
import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol";
import { Dialog, type DialogProps } from "../../shared/design-system/ui/Dialog";
import {
Expand All @@ -15,6 +18,18 @@ export const lifecycleNoun = (
channel: Readonly<{ channelType?: string | undefined }> | undefined,
): LifecycleNoun => (channel?.channelType === "solo" ? "chat" : "channel");

/** Where focus goes when a lifecycle trigger is retired. Settings actions can
* remount after an archive-state or role change; the tab header lives outside
* the settings aside. */
export const lifecycleFocusFallback = (trigger: HTMLElement) =>
trigger
.closest("[data-panel-workspace]")
?.querySelector<HTMLElement>(
'button[aria-label="Close Channel settings tab"]',
) ??
trigger.closest("aside")?.querySelector<HTMLElement>("button") ??
undefined;

const details = {
channel: {
archive:
Expand Down Expand Up @@ -88,7 +103,8 @@ export function ChannelLifecycleDialog({
completed(): void;
onSubmit?(): void;
finalFocus?: DialogProps["finalFocus"];
failed?(title: string, message: string): void;
/** `refresh`: refreshing channels is the recovery. */
failed?(title: string, message: string, refresh: boolean): void;
/** Already chosen without a dialog, as Undo is: run it on mount. */
confirmed?: boolean;
/** Offered from an immediate archive's result toast. */
Expand Down Expand Up @@ -160,7 +176,11 @@ export function ChannelLifecycleDialog({
if (!controller.signal.aborted) {
const message = error instanceof Error ? error.message : String(error);
if (callbacks.current.failed)
callbacks.current.failed(`${copy.title} failed`, message);
callbacks.current.failed(
`${copy.title} failed`,
message,
error instanceof ChannelLifecycleStale,
);
else setError(message);
}
} finally {
Expand Down
43 changes: 41 additions & 2 deletions src/bundled/channels/ChannelLifecycleMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import userEvent from "@testing-library/user-event";
import { ContextMenuRoot, MenuPopup } from "../../shared/design-system/ui/Menu";
import {
ChannelLifecycleStale,
ChannelLifecycleUnconfirmed,
type ChannelLifecycleCapability,
} from "../../features/relay/channel-lifecycle";
Expand Down Expand Up @@ -499,6 +500,42 @@ it("uncertain delivery reports refresh guidance in a toast without offering blin
expect(lifecycle.run).toHaveBeenCalledOnce();
});

it.each([
[
"a permission that changed",
new ChannelLifecycleStale(
"This action is no longer permitted. Refresh channels to see what you can do now.",
),
],
["an unconfirmed result", new ChannelLifecycleUnconfirmed("connection lost")],
])("flags %s for a channel refresh", async (_, error) => {
const lifecycle = capability();
lifecycle.run.mockRejectedValueOnce(error);
const failed = vi.fn();
render(
<ToastProvider>
<ChannelLifecycleDialog
channelId="id"
channelName="Fixture"
action="archive"
lifecycle={lifecycle}
close={() => {}}
completed={() => {}}
failed={failed}
/>
</ToastProvider>,
);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Archive channel" }));
await waitFor(() =>
expect(failed).toHaveBeenCalledExactlyOnceWith(
"Archive channel failed",
error.message,
true,
),
);
});

it.each(
([{ action: "delete", label: "Delete channel" }] as const).flatMap(
({ action, label }) =>
Expand Down Expand Up @@ -683,8 +720,8 @@ it("chat archive runs without confirmation, then Undo unarchives from its result
completed();
setOpen(false);
}}
failed={(title, message) => {
failed(title, message);
failed={(title, message, refresh) => {
failed(title, message, refresh);
setOpen(false);
}}
undo={() => {
Expand Down Expand Up @@ -735,6 +772,8 @@ it("chat archive runs without confirmation, then Undo unarchives from its result
expect(failed).toHaveBeenCalledExactlyOnceWith(
"Unarchive chat failed",
"Relay rejected the request.",
// A refusal is not stale state; refreshing channels would not help.
false,
),
);
await waitFor(() =>
Expand Down
44 changes: 41 additions & 3 deletions src/bundled/channels/ChannelSettingsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,22 +517,60 @@ it("runs the matching command, stays pending until it settles, then follows the
});

it.each([
// As the transport shows a refusal: the relay's words without their prefix.
[
"the relay's own reason",
new PublishRejected("rejected", "invalid: actor not authorized"),
"invalid: actor not authorized",
new PublishRejected(
"Actor not authorized",
"invalid: actor not authorized",
),
"Actor not authorized",
],
[
"the relay's forbidden reason",
new PublishRejected(
"Only owners and admins can change auto-join",
"forbidden: only owners and admins can change auto-join",
),
"Only owners and admins can change auto-join",
],
[
"a long relay reason, clipped,",
new PublishRejected(
`Actor not authorized ${"x".repeat(1000)}`,
`invalid: actor not authorized ${"x".repeat(1000)}`,
),
`Actor not authorized ${"x".repeat(1000)}`.slice(0, 256),
],
[
"the default reason",
new PublishRejected("rejected"),
"Only community owners and admins can change this.",
],
[
"the default reason for text that is not the relay's",
new PublishRejected(
"Relay rejected the message (403)",
"Forbidden by proxy",
),
"Only community owners and admins can change this.",
],
// `auth-required:` stays prefixed for writers to match on; it refuses the
// sign-in, so an owner is not told they lack the role.
[
"the relay's sign-in refusal",
new PublishRejected(
"auth-required: authentication required",
"auth-required: authentication required",
),
"Authentication required",
],
[
"a retry for an unconfirmed outcome",
// As the lifecycle owner throws it when readback never shows the flag.
new ChannelLifecycleUnconfirmed(
new Error(
"The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.",
"The relay accepted the request, but the change is not confirmed.",
),
),
"Saved, but not confirmed yet. Try again to check.",
Expand Down
Loading
Loading