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
68 changes: 67 additions & 1 deletion dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,34 @@ test("edit capability signs and publishes canonical replacements, rejecting malf
});
expect((await h.post("publish", event)).status).toBe(200);
expect(h.publications).toEqual([JSON.parse(JSON.stringify(event))]);
// Preserve older clients' metadata as well as current upload descriptors.
for (const imeta of [
[
"imeta",
"url https://example.com/image.png",
"m image/png",
"dim 320x200",
],
[
"imeta",
`url ${fixtureRelayUrl}/media/${"a".repeat(64)}.pdf`,
"m application/pdf",
"size 3",
`x ${"a".repeat(64)}`,
"filename report.pdf",
],
]) {
const caption = { ...template, tags: [...template.tags, imeta] };
const signed = await h.post("sign", caption);
expect(signed.status).toBe(200);
const replacement = await signed.json();
expect(verifyEvent(replacement)).toBe(true);
expect(replacement.tags).toEqual(caption.tags);
expect((await h.post("publish", replacement)).status).toBe(200);
expect(h.publications.at(-1)).toEqual(
JSON.parse(JSON.stringify(replacement)),
);
}
for (const route of ["sign", "publish"]) {
for (const tags of [
[["h", "c"]],
Expand All @@ -1481,6 +1509,8 @@ test("edit capability signs and publishes canonical replacements, rejecting malf
["e", h.event.id, "", "reply"],
],
[...event.tags, ["e", "a".repeat(64)]],
[...event.tags, ["p", "a".repeat(64)]],
[...event.tags, ["imeta", 42]],
])
expect((await h.post(route, { ...event, tags })).status).toBe(400);
expect((await h.post(route, { ...event, content: " " })).status).toBe(
Expand All @@ -1490,7 +1520,7 @@ test("edit capability signs and publishes canonical replacements, rejecting malf
(await h.post(route, { ...event, content: "x".repeat(32001) })).status,
).toBe(400);
}
expect(h.publications).toHaveLength(1);
expect(h.publications).toHaveLength(3);
} finally {
await h.close();
}
Expand Down Expand Up @@ -1851,6 +1881,42 @@ test("memory reads use captured relay and owner, not submitted identity/filter a
}
});

test("broker advertises, signs and publishes bounded edits through the real HTTP contract", async () => {
const h = await harness(() => new Response("[]"));
try {
await h.start();
const session = await (await h.get("session")).json();
expect(session.writeKinds).toContain(40003);
const template = {
kind: 40003,
content: "corrected text",
created_at: 1700000000,
tags: [
["h", "c"],
["e", h.event.id],
["client-id", "edit-test"],
],
};
const signed = await h.post("sign", template);
expect(signed.status).toBe(200);
const event = await signed.json();
expect(verifyEvent(event)).toBe(true);
expect(event.pubkey).toBe(h.event.pubkey);
const published = await h.post("publish", event);
expect(published.status).toBe(200);
expect(h.publications.some((item) => item.id === event.id)).toBe(true);
for (const route of ["sign", "publish"]) {
const rejected = await h.post(route, {
...event,
tags: [...event.tags, ["p", "b".repeat(64)]],
});
expect(rejected.status).toBe(400);
}
} finally {
await h.close();
}
});

// Owner/admin community commands: community-bound, bounded before upstream I/O.
async function communityAdmin(respond) {
const h = await harness(respond);
Expand Down
14 changes: 12 additions & 2 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,20 @@ export function validMessageTemplate(event) {
).length === 1 &&
(() => {
const references = event.tags.filter((tag) => tag[0] === "e");
if (event.kind === 7 || event.kind === 40003)
if (event.kind === 40003)
return (
event.content === event.content.trim() &&
(event.kind === 40003 || validReactionContent(event.content)) &&
references.length === 1 &&
references[0].length === 2 &&
/^[0-9a-f]{64}$/.test(references[0][1]) &&
event.tags.every(([name]) =>
["h", "e", "emoji", "client-id", "imeta"].includes(name),
)
);
if (event.kind === 7)
return (
event.content === event.content.trim() &&
validReactionContent(event.content) &&
references.length === 1 &&
references[0].length === 2 &&
/^[0-9a-f]{64}$/.test(references[0][1])
Expand Down
42 changes: 42 additions & 0 deletions dev/relay-broker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,48 @@ test("broker permits bounded replacement edits with exactly one canonical target
assert.equal(validMessageTemplate(invalid), false);
});

test("edits admit one exact target without new notification recipients or arbitrary references", () => {
const edit = {
kind: 40003,
content: "corrected",
created_at: 1788810000,
tags: [
["h", "channel"],
["e", "a".repeat(64)],
["client-id", "unique"],
],
};
assert.equal(validMessageTemplate(edit), true);
assert.equal(
validMessageTemplate({
...edit,
tags: [
...edit.tags,
[
"imeta",
"url https://example.com/image.png",
"m image/png",
"dim 320x200",
],
],
}),
true,
);
for (const tags of [
[["h", "channel"]],
[...edit.tags, ["e", "b".repeat(64)]],
[
["h", "channel"],
["e", "a".repeat(64), "", "reply"],
],
[...edit.tags, ["p", "b".repeat(64)]],
[...edit.tags, ["imeta", 42]],
[...edit.tags, ["unknown", "value"]],
])
assert.equal(validMessageTemplate({ ...edit, tags }), false);
assert.equal(validMessageTemplate({ ...edit, content: " " }), false);
});

test("workflow list batches alone may exceed four filters, with 128 unique channels at most", () => {
const batch = Array.from({ length: 128 }, (_, i) => ({
kinds: [30620],
Expand Down
12 changes: 11 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ import { RelayTimings } from "./RelayTimings";
import { LiveStatus } from "./LiveStatus";
import { rejectUnhandledFileDrop } from "../../features/messages/use-file-drop";
import { MessageComposer } from "../../features/messages/MessageComposer";
import {
MessageManagement,
MessageManagementStatus,
} from "../../features/messages/MessageManagement";
import { ChannelTimeline } from "../../features/messages/ChannelTimeline";
import { ThreadPanel } from "../../features/messages/ThreadPanel";
import { MediaReviewViewer } from "../../features/messages/MediaReviewViewer";
Expand Down Expand Up @@ -859,7 +863,7 @@ function ChannelWorkspace({
const showingPanel =
!composingMessage &&
(showingSettings || panel || showingThread || companion || drawer.side);
return (
const workspace = (
<div className={`${styles.board} ${showingPanel ? styles.withPanel : ""}`}>
{current && !current.readOnly && canvasOpen && (
<ChannelCanvasDialog
Expand Down Expand Up @@ -977,6 +981,7 @@ function ChannelWorkspace({
/>
)}
<SessionColumn enabled={flatSession}>
<MessageManagementStatus />
{!cached && (
<LiveStatus
live={queries.live}
Expand Down Expand Up @@ -1253,6 +1258,11 @@ function ChannelWorkspace({
)}
</div>
);
return (
<MessageManagement session={queries} channelId={currentId}>
{workspace}
</MessageManagement>
);
}

export function mediaReviewForDestination<
Expand Down
55 changes: 30 additions & 25 deletions src/bundled/mentions/MentionCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,32 +125,37 @@ export function MentionCompletion({
? { status: "Loading recipients…" }
: model.directory.error
? { status: model.directory.error }
: model.archives.status === "error"
? { status: "Archive information unavailable. Retry to refresh." }
: agents.status === "error" || agents.error
? { status: "Could not load agents. Retry to refresh." }
: admitted && membershipMissing
? { status: "Channel membership unavailable." }
: admitted && membershipError
? { status: "Could not refresh channel membership." }
: error || missing
? {
status:
"Some names unavailable. Exact public keys still identify recipients.",
}
: model.directory.loading
? { status: "Searching community…" }
: model.directory.more || model.truncated
? { status: "Narrow your search to see more members." }
: {}),
: !admitted
? {}
: model.archives.status === "error"
? { status: "Archive information unavailable. Retry to refresh." }
: agents.status === "error" || agents.error
? { status: "Could not load agents. Retry to refresh." }
: admitted && membershipMissing
? { status: "Channel membership unavailable." }
: admitted && membershipError
? { status: "Could not refresh channel membership." }
: error || missing
? {
status:
"Some names unavailable. Exact public keys still identify recipients.",
}
: model.directory.loading
? { status: "Searching community…" }
: model.directory.more || model.truncated
? {
status: "Narrow your search to see more members.",
}
: {}),
...(model.directory.error ||
model.archives.status === "error" ||
agents.status === "error" ||
agents.error ||
membershipMissing ||
membershipError ||
error ||
missing
(admitted &&
(model.archives.status === "error" ||
agents.status === "error" ||
agents.error ||
membershipMissing ||
membershipError ||
error ||
missing))
? {
retry: () => {
model.directory.retry();
Expand Down
27 changes: 27 additions & 0 deletions src/bundled/mentions/mention-query.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { profileTarget } from "../../features/profiles/target";
import { expect, it } from "vitest";
import { mentionQuery, matchesMentionQuery } from "./mention-query";
import { mentionConformance } from "./mention-rules.conformance";
Expand All @@ -24,6 +25,32 @@ it("uses UTF-16 ranges at the actual caret and respects the scan bound", () => {
expect(mentionQuery(`a@${"x".repeat(159)}`, 161)).toBeNull();
expect(mentionQuery("@name", -1)).toBeNull();
});
it("rejects a completed identity-link source at the caret, but not a live single-token search", () => {
const link = `[@Honey](${profileTarget("a".repeat(64))})`;
expect(mentionQuery(`Thanks ${link}`, `Thanks ${link}`.length)).toBeNull();
expect(mentionQuery(link, link.length)).toBeNull();
expect(mentionQuery(`${link} @Ho`, `${link} @Ho`.length)).toEqual({
start: link.length + 1,
end: link.length + 4,
query: "Ho",
});
expect(mentionQuery("Thanks @Ho", 10)).toEqual({
start: 7,
end: 10,
query: "Ho",
});
const literal = `@Honey](${profileTarget("a".repeat(64))})`;
expect(mentionQuery(literal, literal.length)).toEqual({
start: 0,
end: literal.length,
query: literal.slice(1),
});
expect(mentionQuery("Thanks [@Ho", 11)).toEqual({
start: 8,
end: 11,
query: "Ho",
});
});
it("only continues spaces for known multi-word names; completion does not bind identity", () => {
expect(matchesMentionQuery("Princess D", ["Princess Donut"])).toBe(true);
expect(matchesMentionQuery("Honey prose", ["Honey"])).toBe(false);
Expand Down
7 changes: 7 additions & 0 deletions src/bundled/mentions/mention-query.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { profileKey } from "../../features/profiles/target";

/** Syntax only. Multi-word queries are admitted separately against current names. */
export function mentionQuery(text: string, caret: number) {
if (!Number.isInteger(caret) || caret < 0 || caret > text.length) return null;
Expand All @@ -6,6 +8,11 @@ export function mentionQuery(text: string, caret: number) {
if (!match) return null;
const query = match[1] ?? "";
const start = caret - query.length - 1;
// A completed identity link is source markup, not an active name search.
const completed =
/\]\((nostr:npub1[023456789acdefghjklmnpqrstuvwxyz]+)\)$/u.exec(query);
if (text[start - 1] === "[" && completed && profileKey(completed[1] ?? ""))
return null;
// A bounded slice beginning in the middle of a word is not a boundary.
if (start && !/[\s([{]/u.test(text[start - 1] ?? "")) return null;
return { start, end: caret, query };
Expand Down
64 changes: 64 additions & 0 deletions src/bundled/mentions/session-agents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,70 @@ it.each([true, false, null])(
},
);

it.each([
"Member prose",
"Member](nostr:npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex) whats your name",
])(
"hides recovery UI for completed mention query %s while keeping valid-query recovery",
async (staleQuery) => {
const test = setup();
const choices = {
...test.session.agentChoices.snapshot(),
status: "error" as const,
error: "Agent directory unavailable",
pending: false,
};
const session = {
...test.session,
agentChoices: {
...test.session.agentChoices,
snapshot: () => choices,
},
};
let result: CompletionResult | undefined;
const props = {
session,
scope: "test",
channelId: "parent",
observation: {
revision: 1,
text: `@${staleQuery}`,
start: staleQuery.length + 1,
end: staleQuery.length + 1,
},
query: { start: 0, end: staleQuery.length + 1, query: staleQuery },
publish: (next: CompletionResult) => {
result = next;
return () => {};
},
};
const view = render(<MentionCompletion {...props} />);
try {
// Community lookup now owns an asynchronous loading state. Only its
// completed empty result can refute prose and retire unrelated recovery.
await waitFor(() => expect(result?.status).toBeUndefined());
expect(result?.items).toHaveLength(0);
expect(result?.retry).toBeUndefined();

view.rerender(
<MentionCompletion
{...props}
observation={{ revision: 2, text: "@Mem", start: 4, end: 4 }}
query={{ start: 0, end: 4, query: "Mem" }}
/>,
);
await waitFor(() =>
expect(result?.status).toBe("Could not load agents. Retry to refresh."),
);
expect(result?.items).toHaveLength(1);
expect(result?.retry).toEqual(expect.any(Function));
} finally {
view.unmount();
test.library.dispose();
}
},
);

// Exercise selection, wire text/p tags, and sent rendering, not an already-bound @name.
it.each(["picker", "completion"] as const)(
"%s keeps native display labels out of serialized mentions",
Expand Down
Loading
Loading