mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user