Skip to content
Merged
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
48 changes: 48 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,54 @@ test("edit capability signs and publishes canonical replacements, rejecting malf
}
});

test("report capability signs and publishes NIP-56 message reports, rejecting other shapes locally", async () => {
const h = await harness(success);
try {
await h.start();
expect((await (await h.get("session")).json()).writeKinds).toContain(1984);
const template = {
kind: 1984,
content: "",
created_at: h.event.created_at,
tags: [
["p", h.event.pubkey],
["e", h.event.id, "spam"],
],
};
const response = await h.post("sign", template);
expect(response.status).toBe(200);
const event = await response.json();
expect(verifyEvent(event)).toBe(true);
expect(event).toMatchObject({ kind: 1984, tags: template.tags });
expect((await h.post("publish", event)).status).toBe(200);
expect(h.publications).toEqual([JSON.parse(JSON.stringify(event))]);
for (const route of ["sign", "publish"]) {
for (const tags of [
[["e", h.event.id, "spam"]],
[
["p", h.event.pubkey],
["e", h.event.id, "rude"],
],
[
["p", h.event.pubkey],
["e", "bad", "spam"],
],
[...template.tags, ["h", "c"]],
])
expect((await h.post(route, { ...event, tags })).status).toBe(400);
expect(
(await h.post(route, { ...event, content: " padded " })).status,
).toBe(400);
expect(
(await h.post(route, { ...event, content: "x".repeat(32001) })).status,
).toBe(400);
}
expect(h.publications).toHaveLength(1);
} finally {
await h.close();
}
});

test("message/reaction deletions pass real signing and publication without admitting workflow or arbitrary deletion shapes", async () => {
const h = await harness((call) =>
Response.json({ accepted: true, event_id: call.body.id }),
Expand Down
41 changes: 41 additions & 0 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,40 @@ export function validMessageTemplate(event) {
})()
);
}
/** NIP-56 message report: exactly one author and one typed message target. */
export function validReport(event) {
if (
event?.kind !== 1984 ||
typeof event.content !== "string" ||
event.content !== event.content.trim() ||
Buffer.byteLength(event.content) > 32000 ||
!Number.isSafeInteger(event.created_at) ||
event.created_at < 0 ||
!Array.isArray(event.tags) ||
event.tags.length !== 2
)
return false;
const [author, target] = event.tags;
return (
Array.isArray(author) &&
author.length === 2 &&
author[0] === "p" &&
/^[0-9a-f]{64}$/.test(author[1]) &&
Array.isArray(target) &&
target.length === 3 &&
target[0] === "e" &&
/^[0-9a-f]{64}$/.test(target[1]) &&
[
"spam",
"profanity",
"nudity",
"impersonation",
"malware",
"illegal",
"other",
].includes(target[2])
);
}
/** Channel-local NIP-09 removal; the relay enforces authorship of each target. */
export function validMessageDeletion(event) {
if (
Expand Down Expand Up @@ -1062,6 +1096,7 @@ export function relayBrokerPlugin({
9000,
30078,
40100,
1984,
...WORKFLOW_KINDS,
...((await getAuthority(relay)).channelCreation ? [9007] : []),
],
Expand Down Expand Up @@ -1846,6 +1881,12 @@ export function relayBrokerPlugin({
sent: false,
});
}
} else if (filters?.kind === 1984) {
if (!validReport(filters))
return json(res, 400, {
error: "Report rejected",
sent: false,
});
} else if (
![7, 9, 40003].includes(filters?.kind) &&
!validMessageDeletion(filters)
Expand Down
4 changes: 2 additions & 2 deletions dev/workflow-broker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ it("real broker scoped history signs exact GET path/cursor and captured principa
const other = await connectBrokerTransport(h.base, undefined, "secondary");
expect(h.calls).toHaveLength(0);
expect(first.writer.kinds).toEqual([
30315, 7, 9, 40003, 9000, 30078, 40100, 30620, 46020, 5,
30315, 7, 9, 40003, 9000, 30078, 40100, 1984, 30620, 46020, 5,
]);
await first.workflows.runs(id, cursor, signal());
await other.workflows.runs(id, undefined, signal());
Expand Down Expand Up @@ -314,7 +314,7 @@ it("existing backend signs only canonical workflow sign/publish with exact own e
const t = await connectBrokerTransport(h.base);
live = await openBrokerSocket(t);
expect(t.writer.kinds).toEqual([
30315, 7, 9, 40003, 9000, 30078, 40100, 30620, 46020, 5,
30315, 7, 9, 40003, 9000, 30078, 40100, 1984, 30620, 46020, 5,
]);
for (const input of [
template(),
Expand Down
7 changes: 7 additions & 0 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,13 @@ owned by the viewer and a transport that supports kind 40003. The dev broker
advertises edits and validates one canonical target reference before signing or
publishing. Generic plugins can use `outbox.send` directly.

`session.messages.report(messageId, type, note?)` is the one exception: the relay
queues NIP-56 reports (kind 1984) for moderators and never stores or echoes them,
so the session signs and publishes directly and resolves on the relay's accepted
receipt, as Buzz desktop does. Nothing is persisted or restored; a rejection or
10-second timeout rejects the call and the dialog keeps its input for retry. The
method is undefined unless the writer supports kind 1984.

In an empty composer, unmodified Up arrow opens the latest eligible own message
from that channel or thread in the same editor. Enter/the send arrow saves;
Escape or × cancels. Edits retain raw attachment Markdown and leave original
Expand Down
219 changes: 219 additions & 0 deletions src/features/messages/ChannelTimeline.report.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
act,
cleanup,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode, type ReactElement, type ReactNode } from "react";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import { createRelaySession } from "../relay/session";
import type { LiveCallbacks } from "../relay/live";
import type { RelayEvent } from "../relay/events";
import {
keypair,
message,
roster,
scriptedTransport,
signed,
} from "../relay/testing";
import { ChannelTimeline } from "./ChannelTimeline";

// Virtualizer boundary: render only "visible" indices plus `keepMounted`,
// as Virtua does for rows scrolled out of its buffer. Layout is browser-tested.
const view = vi.hoisted(() => ({
visible: undefined as ReadonlySet<number> | undefined,
}));
vi.mock("virtua", async () => {
const { Children, forwardRef, useImperativeHandle } = await import("react");
return {
Virtualizer: forwardRef(function Virtualizer(
{
children,
keepMounted = [],
}: { children: ReactNode; keepMounted?: readonly number[] },
ref,
) {
useImperativeHandle(ref, () => ({
cache: undefined,
scrollOffset: 0,
scrollSize: 0,
viewportSize: 0,
scrollTo() {},
scrollToIndex() {},
}));
return (
<ol>
{Children.toArray(children).map((child, index) =>
!view.visible ||
view.visible.has(index) ||
keepMounted.includes(index) ? (
<li key={(child as ReactElement).key}>{child}</li>
) : null,
)}
</ol>
);
}),
};
});

const viewer = keypair(),
other = keypair(),
relay = keypair();
const target = message(other, "c", "Report me", 1);
const neighbor = message(other, "c", "Neighbor", 2);
const owners: { dispose(): void }[] = [];
beforeEach(() => {
view.visible = undefined;
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
cleanup();
for (const owner of owners.splice(0)) owner.dispose();
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
});

function mount(publish: (event: RelayEvent) => Promise<void>) {
let live: LiveCallbacks | undefined;
const owner = createRelaySession(
{
...scriptedTransport(viewer.pubkey, relay.pubkey).transport,
writer: { sign: async (template) => signed(viewer, template), publish },
subscribe(callbacks) {
live = callbacks;
return { update() {}, retry() {}, dispose() {} };
},
},
{ outboxStorage: { load: () => [], save() {} } },
);
owners.push(owner);
owner.session.channels.ensure("c");
live?.receive([roster(relay, "c", [viewer.pubkey], 1), target, neighbor]);
const tree = () => (
<StrictMode>
<ToastProvider>
<ChannelTimeline
channelId="c"
scope="viewer"
queries={owner.session}
window={owner.session.channels.window("c")}
onOpenLink={() => false}
/>
</ToastProvider>
</StrictMode>
);
const result = render(tree());
const row = () =>
document.querySelector<HTMLElement>(`[data-message-id="${target.id}"]`);
const neighborRow = () =>
document.querySelector(`[data-message-id="${neighbor.id}"]`);
return {
row,
neighborRow,
// Scroll the target out of the virtualizer buffer.
evict() {
const index = owner.session.channels
.window("c")
.rows.findIndex((row) => row.id === neighbor.id);
view.visible = new Set([index]);
result.rerender(tree());
},
show() {
view.visible = undefined;
result.rerender(tree());
},
unmount: result.unmount,
};
}

async function openReport(row: HTMLElement) {
const user = userEvent.setup();
const trigger = within(row).getByRole("button", {
name: "More message actions",
});
await user.click(trigger);
await user.click(
await screen.findByRole("menuitem", { name: "Report message" }),
);
await screen.findByRole("dialog", { name: "Report message" });
return { user, trigger };
}

it("keeps the reporting row mounted through a held submission and its notice, then releases it", async () => {
let settle!: () => void;
const publish = vi.fn(
() => new Promise<void>((resolve) => (settle = resolve)),
);
const h = mount(publish);
const row = h.row();
if (!row) throw new Error("Missing target row");
const { user } = await openReport(row);
h.evict();
expect(h.neighborRow()).not.toBeNull();
expect(h.row()).toBe(row);
await user.click(screen.getByRole("radio", { name: "Spam" }));
await user.type(
screen.getByRole("textbox", { name: "Additional context (optional)" }),
"repeated links",
);
await user.click(screen.getByRole("button", { name: "Submit report" }));
await waitFor(() => expect(publish).toHaveBeenCalledOnce());
h.evict();
expect(h.row()).toBe(row);
act(() => settle());
expect(
await screen.findByText("Report submitted to community moderators"),
).toBeTruthy();
h.evict();
expect(h.row()).toBe(row);
await user.click(screen.getByRole("button", { name: /dismiss/i }));
// Once the notice and focus are gone, the virtualizer may evict the row.
await waitFor(() => expect(h.row()).toBeNull());
expect(h.neighborRow()).not.toBeNull();
});

it("releases on cancel, pins again on reopen, and unmounts cleanly while open", async () => {
const error = vi.spyOn(console, "error");
const h = mount(async () => {});
const row = h.row();
if (!row) throw new Error("Missing target row");
const { user, trigger } = await openReport(row);
await user.click(screen.getByRole("radio", { name: "Other" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
await openReport(row);
expect(
screen.getByRole("radio", { name: "Other" }).getAttribute("aria-checked"),
).toBe("false");
h.evict();
expect(h.row()).toBe(row);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
// Restored focus keeps the row; once focus leaves, the pin must be gone.
act(() => trigger.blur());
h.evict();
await waitFor(() => expect(h.row()).toBeNull());

h.show();
const remounted = h.row();
if (!remounted) throw new Error("Missing target row");
await openReport(remounted);
h.unmount();
await act(() => new Promise((resolve) => setTimeout(resolve)));
expect(screen.queryByRole("dialog", { name: "Report message" })).toBeNull();
expect(error).not.toHaveBeenCalled();
});
Loading
Loading