feat(desktop): add right-click context menu to workspace rail (#1552)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-06 14:23:44 -07:00
committed by GitHub
co-authored by Brain
parent e790c9828f
commit 0e87998f8f
7 changed files with 591 additions and 86 deletions
+2
View File
@@ -624,7 +624,9 @@ export function AppShell() {
workspacesHook.activeWorkspace?.id ?? null
}
onAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={workspacesHook.switchWorkspace}
onUpdateWorkspace={workspacesHook.updateWorkspace}
workspaces={workspacesHook.workspaces}
/>
) : null}
+122 -45
View File
@@ -1,11 +1,21 @@
import { Plus } from "lucide-react";
import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react";
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import { EditWorkspaceDialog } from "@/features/workspaces/ui/EditWorkspaceDialog";
import { useWorkspaceIcons } from "@/features/workspaces/useWorkspaceIcons";
import {
useWorkspaceUnread,
type WorkspaceUnreadState,
} from "@/features/workspaces/useWorkspaceUnread";
import { useAppShell } from "@/app/AppShellContext";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/shared/ui/context-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/lib/cn";
import { getInitials } from "@/shared/lib/initials";
@@ -17,6 +27,11 @@ type WorkspaceRailProps = {
activeWorkspaceId: string | null;
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
) => void;
onRemoveWorkspace: (id: string) => void;
};
const MAX_BADGE = 99;
@@ -57,12 +72,14 @@ function WorkspaceButton({
unread,
iconUrl,
onSwitch,
menu,
}: {
workspace: Workspace;
isActive: boolean;
unread: WorkspaceUnreadState;
iconUrl: string | null;
onSwitch: () => void;
menu: React.ReactNode;
}) {
const { mentionCount, showBadge, pending, badgeLabel } =
workspaceRailIndicators(unread);
@@ -72,56 +89,64 @@ function WorkspaceButton({
: workspace.name;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-current={isActive ? "true" : undefined}
aria-label={tooltipLabel}
className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
data-testid={`workspace-rail-button-${workspace.id}`}
onClick={onSwitch}
type="button"
>
<span
className={cn(
"flex h-9 w-9 items-center justify-center overflow-hidden rounded-2xl text-xs font-semibold transition-all",
isActive
? "rounded-xl bg-primary text-primary-foreground"
: "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground",
pending && "opacity-60",
)}
>
{iconUrl ? (
<img
alt=""
className="h-full w-full object-cover"
data-testid={`workspace-rail-icon-${workspace.id}`}
draggable={false}
src={iconUrl}
/>
) : (
workspaceInitials(workspace.name) || "🐝"
)}
</span>
{showBadge ? (
<span
className="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold text-primary-foreground ring-2 ring-sidebar"
data-testid={`workspace-rail-mentions-${workspace.id}`}
<ContextMenu>
<Tooltip>
<TooltipTrigger asChild>
<ContextMenuTrigger asChild>
<button
aria-current={isActive ? "true" : undefined}
aria-label={tooltipLabel}
className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
data-testid={`workspace-rail-button-${workspace.id}`}
onClick={onSwitch}
type="button"
>
{badgeLabel}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right">{tooltipLabel}</TooltipContent>
</Tooltip>
<span
className={cn(
"flex h-9 w-9 items-center justify-center overflow-hidden rounded-2xl text-xs font-semibold transition-all",
isActive
? "rounded-xl bg-primary text-primary-foreground"
: "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground",
pending && "opacity-60",
)}
>
{iconUrl ? (
<img
alt=""
className="h-full w-full object-cover"
data-testid={`workspace-rail-icon-${workspace.id}`}
draggable={false}
src={iconUrl}
/>
) : (
workspaceInitials(workspace.name) || "🐝"
)}
</span>
{showBadge ? (
<span
className="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold text-primary-foreground ring-2 ring-sidebar"
data-testid={`workspace-rail-mentions-${workspace.id}`}
>
{badgeLabel}
</span>
) : null}
</button>
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{tooltipLabel}</TooltipContent>
</Tooltip>
<ContextMenuContent data-testid={`workspace-rail-menu-${workspace.id}`}>
{menu}
</ContextMenuContent>
</ContextMenu>
);
}
/**
* Discord/Slack-style vertical rail of workspaces on the far left of the app.
* Shows a mention-count badge for inactive workspaces (observed via
* `useWorkspaceUnread`) and switches relays on click.
* `useWorkspaceUnread`) and switches relays on click. Right-click opens a
* per-workspace menu: mark all as read, copy relay URL, workspace settings.
*
* Hidden entirely with a single workspace a rail of one adds no value.
*/
@@ -130,14 +155,35 @@ export function WorkspaceRail({
activeWorkspaceId,
onSwitchWorkspace,
onAddWorkspace,
onUpdateWorkspace,
onRemoveWorkspace,
}: WorkspaceRailProps) {
const unreadByWorkspace = useWorkspaceUnread(workspaces, activeWorkspaceId);
const { unreadByWorkspace, markWorkspaceRead } = useWorkspaceUnread(
workspaces,
activeWorkspaceId,
);
const iconsByWorkspace = useWorkspaceIcons(workspaces);
const isFullscreen = useIsFullscreen();
const { markAllChannelsRead } = useAppShell();
const [editingWorkspace, setEditingWorkspace] =
React.useState<Workspace | null>(null);
if (workspaces.length <= 1) {
return null;
}
const handleMarkAllRead = (workspace: Workspace) => {
if (workspace.id === activeWorkspaceId) {
markAllChannelsRead();
return;
}
markWorkspaceRead(workspace.id).catch((error) => {
console.warn(
`[WorkspaceRail] mark all read failed workspace=${workspace.id}:`,
error,
);
});
};
// macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen).
const topPaddingClass =
isMacPlatform() && !isFullscreen
@@ -158,6 +204,27 @@ export function WorkspaceRail({
key={workspace.id}
iconUrl={iconsByWorkspace[workspace.id] ?? null}
isActive={workspace.id === activeWorkspaceId}
menu={
<>
<ContextMenuItem onClick={() => handleMarkAllRead(workspace)}>
<CheckCheck className="h-4 w-4" />
Mark all as read
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
void navigator.clipboard.writeText(workspace.relayUrl);
}}
>
<Link2 className="h-4 w-4" />
Copy relay URL
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => setEditingWorkspace(workspace)}>
<Settings2 className="h-4 w-4" />
Workspace settings
</ContextMenuItem>
</>
}
onSwitch={() => onSwitchWorkspace(workspace.id)}
unread={
unreadByWorkspace[workspace.id] ?? {
@@ -182,6 +249,16 @@ export function WorkspaceRail({
</TooltipTrigger>
<TooltipContent side="right">Add workspace</TooltipContent>
</Tooltip>
<EditWorkspaceDialog
canRemove={workspaces.length > 1}
onOpenChange={(open) => {
if (!open) setEditingWorkspace(null);
}}
onRemove={onRemoveWorkspace}
onSave={onUpdateWorkspace}
open={editingWorkspace !== null}
workspace={editingWorkspace}
/>
</nav>
);
}
@@ -1,6 +1,7 @@
import * as React from "react";
import { getIdentity } from "@/shared/api/tauri";
import { markWorkspaceRead } from "@/features/workspaces/workspaceMarkRead";
import { pollWorkspaceUnread } from "@/features/workspaces/workspaceUnreadObserver";
import type { Workspace } from "./types";
@@ -49,7 +50,10 @@ function seedWorkspaceStates(
export function useWorkspaceUnread(
workspaces: Workspace[],
activeWorkspaceId: string | null,
): Record<string, WorkspaceUnreadState> {
): {
unreadByWorkspace: Record<string, WorkspaceUnreadState>;
markWorkspaceRead: (workspaceId: string) => Promise<void>;
} {
const [unreadByWorkspace, setUnreadByWorkspace] = React.useState<
Record<string, WorkspaceUnreadState>
>(() => seedWorkspaceStates(workspaces, {}));
@@ -153,5 +157,26 @@ export function useWorkspaceUnread(
};
}, [activeWorkspaceId, workspaces]);
return unreadByWorkspace;
const workspacesRef = React.useRef(workspaces);
workspacesRef.current = workspaces;
const markRead = React.useCallback(
async (workspaceId: string) => {
const workspace = workspacesRef.current.find(
(candidate) => candidate.id === workspaceId,
);
if (!workspace || workspaceId === activeWorkspaceId) return;
const { pubkey } = await getIdentity();
await markWorkspaceRead(workspace, pubkey);
// Optimistic clear — the next poll re-verifies against the relay.
setUnreadByWorkspace((previous) => ({
...previous,
[workspaceId]: { hasUnread: false, state: "ready" },
}));
},
[activeWorkspaceId],
);
return { unreadByWorkspace, markWorkspaceRead: markRead };
}
@@ -0,0 +1,192 @@
import assert from "node:assert/strict";
import test, { beforeEach } from "node:test";
import {
chunkChannelContexts,
publishWorkspaceReadState,
} from "./workspaceMarkRead.ts";
const PUBKEY = "a".repeat(64);
const READ_AT = 1_700_000_000;
function makeLocalStorage() {
const store = new Map();
return {
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
};
}
beforeEach(() => {
globalThis.localStorage = makeLocalStorage();
globalThis.window = { localStorage: globalThis.localStorage };
});
test("chunkChannelContexts puts all channels in one blob when they fit", () => {
const chunks = chunkChannelContexts(["chan-1", "chan-2"], READ_AT, "client");
assert.equal(chunks.length, 1);
assert.deepEqual(chunks[0], { "chan-1": READ_AT, "chan-2": READ_AT });
});
test("chunkChannelContexts splits when the blob exceeds the byte budget", () => {
const ids = Array.from({ length: 40 }, (_, i) => `channel-${i}`);
const chunks = chunkChannelContexts(ids, READ_AT, "client", 512);
assert.ok(chunks.length > 1);
const merged = Object.assign({}, ...chunks);
assert.equal(Object.keys(merged).length, ids.length);
const encoder = new TextEncoder();
for (const contexts of chunks) {
const bytes = encoder.encode(
JSON.stringify({ v: 1, client_id: "client", contexts }),
).length;
assert.ok(bytes <= 512, `chunk exceeds budget: ${bytes}`);
}
});
test("chunkChannelContexts drops overflow beyond maxSlots instead of flooding", () => {
const ids = Array.from({ length: 100 }, (_, i) => `channel-${i}`);
const chunks = chunkChannelContexts(ids, READ_AT, "client", 128, 2);
assert.equal(chunks.length, 2);
});
function membersEvent(channelIds) {
return {
id: "e".repeat(64),
pubkey: "relay",
created_at: 1,
kind: 39002,
tags: channelIds.map((id) => ["d", id]),
content: "",
sig: "sig",
};
}
function metadataEvent(channelId, extraTags = []) {
return {
id: "f".repeat(64),
pubkey: "relay",
created_at: 1,
kind: 39000,
tags: [["d", channelId], ...extraTags],
content: "",
sig: "sig",
};
}
function makeClient({ channelIds, metadata }) {
const published = [];
return {
published,
async fetchEvents(filter) {
if (filter.kinds?.includes(39002)) return [membersEvent(channelIds)];
if (filter.kinds?.includes(39000)) return metadata;
return [];
},
async publishEvent(event) {
published.push(event);
},
};
}
test("publishWorkspaceReadState publishes one read-state event covering observed channels", async () => {
const client = makeClient({
channelIds: ["chan-1", "chan-2"],
metadata: [metadataEvent("chan-1"), metadataEvent("chan-2")],
});
const encrypted = [];
await publishWorkspaceReadState({
client,
pubkey: PUBKEY,
relayUrl: "wss://relay.example",
nowSeconds: READ_AT,
encrypt: async (plaintext) => {
encrypted.push(plaintext);
return `cipher:${encrypted.length}`;
},
sign: async (input) => ({
id: `signed-${encrypted.length}`,
pubkey: PUBKEY,
created_at: input.createdAt,
kind: input.kind,
tags: input.tags,
content: input.content,
sig: "sig",
}),
});
assert.equal(client.published.length, 1);
const event = client.published[0];
assert.equal(event.kind, 30078);
assert.equal(event.created_at, READ_AT);
assert.ok(
event.tags.some(
(tag) => tag[0] === "d" && tag[1].startsWith("read-state:"),
),
);
assert.ok(
event.tags.some((tag) => tag[0] === "t" && tag[1] === "read-state"),
);
const blob = JSON.parse(encrypted[0]);
assert.equal(blob.v, 1);
assert.deepEqual(blob.contexts, { "chan-1": READ_AT, "chan-2": READ_AT });
});
test("publishWorkspaceReadState skips archived channels and publishes nothing when none remain", async () => {
const client = makeClient({
channelIds: ["chan-1"],
metadata: [metadataEvent("chan-1", [["archived", "true"]])],
});
await publishWorkspaceReadState({
client,
pubkey: PUBKEY,
relayUrl: "wss://relay.example",
nowSeconds: READ_AT,
encrypt: async (plaintext) => plaintext,
sign: async () => {
throw new Error("must not sign when there is nothing to publish");
},
});
assert.equal(client.published.length, 0);
});
test("publishWorkspaceReadState reuses stable slot ids so blobs are replaceable", async () => {
const makeArgs = (client) => ({
client,
pubkey: PUBKEY,
relayUrl: "wss://relay.example",
nowSeconds: READ_AT,
encrypt: async (plaintext) => plaintext,
sign: async (input) => ({
id: Math.random().toString(),
pubkey: PUBKEY,
created_at: input.createdAt,
kind: input.kind,
tags: input.tags,
content: input.content,
sig: "sig",
}),
});
const clientA = makeClient({
channelIds: ["chan-1"],
metadata: [metadataEvent("chan-1")],
});
await publishWorkspaceReadState(makeArgs(clientA));
const clientB = makeClient({
channelIds: ["chan-1"],
metadata: [metadataEvent("chan-1")],
});
await publishWorkspaceReadState(makeArgs(clientB));
const dTag = (event) => event.tags.find((tag) => tag[0] === "d")[1];
assert.equal(dTag(clientA.published[0]), dTag(clientB.published[0]));
});
@@ -0,0 +1,141 @@
import { READ_STATE_MAX_PLAINTEXT_BYTES } from "@/features/channels/readState/readStateFormat";
import type { Workspace } from "@/features/workspaces/types";
import { fetchObservedChannels } from "@/features/workspaces/workspaceUnreadObserver";
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
import { nip44EncryptToSelf, signRelayEvent } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_READ_STATE } from "@/shared/constants/kinds";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
// Slot ceiling mirrors READ_STATE_MAX_SLOTS; beyond it we drop the remainder
// rather than flood the relay — the next regular client publish catches up.
const OBSERVER_MAX_SLOTS = 8;
const OBSERVER_CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.observer-client-id";
const OBSERVER_SLOT_ID_KEY_PREFIX = "buzz.nip-rs.observer-slot-id";
type MarkReadRelay = {
fetchEvents(filter: RelaySubscriptionFilter): Promise<RelayEvent[]>;
publishEvent(event: RelayEvent): Promise<void>;
};
type SignEvent = (input: {
kind: number;
content: string;
createdAt?: number;
tags: string[][];
}) => Promise<RelayEvent>;
/**
* Split channel read markers into NIP-RS blobs that each fit the single-slot
* plaintext budget. Greedy fill, order-preserving. Returns at most `maxSlots`
* chunks overflow channels are dropped (grow-only semantics make this safe;
* they simply stay unread until read normally).
*/
export function chunkChannelContexts(
channelIds: string[],
readAt: number,
clientId: string,
maxBytes: number = READ_STATE_MAX_PLAINTEXT_BYTES,
maxSlots: number = OBSERVER_MAX_SLOTS,
): Array<Record<string, number>> {
const encoder = new TextEncoder();
const blobBytes = (contexts: Record<string, number>) =>
encoder.encode(JSON.stringify({ v: 1, client_id: clientId, contexts }))
.length;
const chunks: Array<Record<string, number>> = [];
let current: Record<string, number> = {};
for (const channelId of channelIds) {
const candidate = { ...current, [channelId]: readAt };
if (Object.keys(current).length > 0 && blobBytes(candidate) > maxBytes) {
chunks.push(current);
if (chunks.length >= maxSlots) {
return chunks;
}
current = { [channelId]: readAt };
continue;
}
current = candidate;
}
if (Object.keys(current).length > 0) {
chunks.push(current);
}
return chunks;
}
/**
* Publish read-state blobs marking every observed channel on an INACTIVE
* workspace's relay as read-now. Grow-only NIP-RS semantics: other devices
* max-merge these markers, so publishing "everything read at `nowSeconds`"
* can never regress a marker that is already further ahead.
*/
export async function publishWorkspaceReadState(args: {
client: MarkReadRelay;
pubkey: string;
relayUrl: string;
nowSeconds?: number;
encrypt?: (plaintext: string) => Promise<string>;
sign?: SignEvent;
}): Promise<void> {
const { client, pubkey, relayUrl } = args;
const encrypt = args.encrypt ?? nip44EncryptToSelf;
const sign = args.sign ?? signRelayEvent;
const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
const channels = await fetchObservedChannels(client, pubkey);
if (channels.length === 0) return;
const clientId = persistedId(`${OBSERVER_CLIENT_ID_KEY_PREFIX}:${pubkey}`);
const chunks = chunkChannelContexts(
channels.map((channel) => channel.id),
nowSeconds,
clientId,
);
for (let index = 0; index < chunks.length; index++) {
const slotId = persistedId(
`${OBSERVER_SLOT_ID_KEY_PREFIX}:${pubkey}:${relayUrl}:${index}`,
);
const ciphertext = await encrypt(
JSON.stringify({ v: 1, client_id: clientId, contexts: chunks[index] }),
);
const event = await sign({
kind: KIND_READ_STATE,
content: ciphertext,
createdAt: nowSeconds,
tags: [
["d", `read-state:${slotId}`],
["t", "read-state"],
],
});
await client.publishEvent(event);
}
}
export async function markWorkspaceRead(
workspace: Workspace,
pubkey: string,
): Promise<void> {
await withReadOnlyRelayClient(workspace.relayUrl, (client) =>
publishWorkspaceReadState({
client,
pubkey,
relayUrl: workspace.relayUrl,
}),
);
}
function persistedId(key: string): string {
let value = localStorage.getItem(key);
if (!value) {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
value = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
setLocalStorageItemWithRecovery(key, value);
}
return value;
}
@@ -44,6 +44,46 @@ type ObservedChannel = {
archived: boolean;
};
/**
* List the channels this pubkey is a member of on the observed relay,
* excluding archived channels and hidden DMs the same visibility set the
* unread poll and "mark all as read" must agree on.
*/
export async function fetchObservedChannels(
client: WorkspaceUnreadRelay,
pubkey: string,
): Promise<ObservedChannel[]> {
const memberEvents = await client.fetchEvents({
kinds: [KIND_NIP29_GROUP_MEMBERS],
"#p": [pubkey],
limit: MEMBER_CHANNEL_LIMIT,
});
const channelIds = extractMemberChannelIds(memberEvents);
if (channelIds.length === 0) {
return [];
}
const [metadataEvents, visibilityEvents] = await Promise.all([
client.fetchEvents({
kinds: [KIND_NIP29_GROUP_METADATA],
"#d": channelIds,
limit: METADATA_LIMIT,
}),
client.fetchEvents({
kinds: [KIND_DM_VISIBILITY],
"#p": [pubkey],
limit: 1,
}),
]);
const hiddenDmIds = extractHiddenDmIds(visibilityEvents);
return resolveObservedChannels(channelIds, metadataEvents).filter(
(channel) =>
!channel.archived &&
(channel.channelType !== "dm" || !hiddenDmIds.has(channel.id)),
);
}
export async function pollWorkspaceUnread(
workspace: Workspace,
pubkey: string,
@@ -63,44 +103,17 @@ export async function fetchWorkspaceUnread(args: {
const normalizedPubkey = pubkey.toLowerCase();
const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
const memberEvents = await client.fetchEvents({
kinds: [KIND_NIP29_GROUP_MEMBERS],
"#p": [pubkey],
limit: MEMBER_CHANNEL_LIMIT,
});
const channelIds = extractMemberChannelIds(memberEvents);
if (channelIds.length === 0) {
const channels = await fetchObservedChannels(client, pubkey);
if (channels.length === 0) {
return { hasUnread: false, mentionCount: 0 };
}
const [metadataEvents, visibilityEvents, readStateEvents] = await Promise.all(
[
client.fetchEvents({
kinds: [KIND_NIP29_GROUP_METADATA],
"#d": channelIds,
limit: METADATA_LIMIT,
}),
client.fetchEvents({
kinds: [KIND_DM_VISIBILITY],
"#p": [pubkey],
limit: 1,
}),
client.fetchEvents({
kinds: [KIND_READ_STATE],
authors: [pubkey],
"#t": ["read-state"],
since: nowSeconds - READ_STATE_HORIZON_SECONDS,
limit: READ_STATE_FETCH_LIMIT,
}),
],
);
const hiddenDmIds = extractHiddenDmIds(visibilityEvents);
const channels = resolveObservedChannels(channelIds, metadataEvents).filter(
(channel) =>
!channel.archived &&
(channel.channelType !== "dm" || !hiddenDmIds.has(channel.id)),
);
const readStateEvents = await client.fetchEvents({
kinds: [KIND_READ_STATE],
authors: [pubkey],
"#t": ["read-state"],
since: nowSeconds - READ_STATE_HORIZON_SECONDS,
limit: READ_STATE_FETCH_LIMIT,
});
const readState = await mergeReadStateEvents(
readStateEvents,
pubkey,
+58 -3
View File
@@ -11,6 +11,7 @@ import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
const AUTH_TIMEOUT_MS = 8_000;
const HISTORY_TIMEOUT_MS = 8_000;
const PUBLISH_TIMEOUT_MS = 8_000;
type PendingHistory = {
events: RelayEvent[];
@@ -19,10 +20,17 @@ type PendingHistory = {
timeout: number;
};
type PendingPublish = {
resolve: () => void;
reject: (error: Error) => void;
timeout: number;
};
/**
* Minimal read-only relay session for inactive-workspace observation.
* It never reads or mutates the active workspace backend relay URL; callers pass
* an explicit URL and should disconnect as soon as their polling batch finishes.
* Minimal relay session for inactive-workspace observation (and the rail's
* cross-relay "mark all as read" publish). It never reads or mutates the
* active workspace backend relay URL; callers pass an explicit URL and should
* disconnect as soon as their polling batch finishes.
*/
export class ReadOnlyRelayClient {
private wsId: number | null = null;
@@ -35,6 +43,7 @@ export class ReadOnlyRelayClient {
timeout: number;
} | null = null;
private histories = new Map<string, PendingHistory>();
private publishes = new Map<string, PendingPublish>();
private generation = 0;
private readonly relayUrl: string;
@@ -79,6 +88,12 @@ export class ReadOnlyRelayClient {
this.histories.delete(subId);
}
for (const [eventId, pending] of this.publishes) {
window.clearTimeout(pending.timeout);
pending.reject(error);
this.publishes.delete(eventId);
}
this.onMessageChannel = null;
this.connectPromise = null;
}
@@ -88,6 +103,32 @@ export class ReadOnlyRelayClient {
return this.requestHistory(filter);
}
async publishEvent(event: RelayEvent): Promise<void> {
await this.connect();
if (this.wsId === null) {
throw new Error("Read-only relay socket is not connected.");
}
return new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.publishes.delete(event.id);
reject(new Error("Timed out publishing to observer relay."));
}, PUBLISH_TIMEOUT_MS);
this.publishes.set(event.id, { resolve, reject, timeout });
void this.sendRaw(["EVENT", event]).catch((error) => {
window.clearTimeout(timeout);
this.publishes.delete(event.id);
reject(
error instanceof Error
? error
: new Error("Failed to publish to observer relay."),
);
});
});
}
private async openConnection(): Promise<void> {
const generation = ++this.generation;
this.onMessageChannel = new Channel<unknown>((message) => {
@@ -229,6 +270,20 @@ export class ReadOnlyRelayClient {
}
private handleOk(eventId: string, success: boolean, message: string): void {
const publish = this.publishes.get(eventId);
if (publish) {
window.clearTimeout(publish.timeout);
this.publishes.delete(eventId);
if (success) {
publish.resolve();
} else {
publish.reject(
new Error(message || "Observer relay rejected the event."),
);
}
return;
}
if (!this.authRequest || this.authRequest.pendingEventId !== eventId) {
return;
}