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
35 changes: 22 additions & 13 deletions browser-host/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,9 @@ async function relayAuthority(fetch, relay) {
channelCreation:
Array.isArray(nip11.supported_nips) && nip11.supported_nips.includes(29),
// NIP-CL: the relay keeps channel labels and answers label lookups.
scheduledMessages:
Array.isArray(nip11.supported_extensions) &&
nip11.supported_extensions.includes("buzz-scheduled-messages-v1"),
channelIdentity:
Array.isArray(nip11.supported_extensions) &&
nip11.supported_extensions.includes("nip-cl"),
Expand Down Expand Up @@ -1685,6 +1688,7 @@ export function relayBrokerPlugin({
memberAdministration: true,
identityArchives: true,
workflowReads: true,
scheduledMessages: (await getAuthority(relay)).scheduledMessages,
projectGit: true,
attachmentUploads: true,
sidebarPreferences: true,
Expand Down Expand Up @@ -2467,6 +2471,7 @@ export function relayBrokerPlugin({
"/api/relay/gifs",
"/api/relay/profile-search",
"/api/relay/workflow-runs",
"/api/relay/scheduled-messages",
"/api/relay/project-git",
].includes(route) ||
req.method !== "POST"
Expand Down Expand Up @@ -2744,6 +2749,7 @@ export function relayBrokerPlugin({
}
if (presence && !presenceFilter(filters))
return json(res, 400, { error: "Invalid presence filter" });
const scheduled = route === "/api/relay/scheduled-messages";
let workflowPath;
if (route === "/api/relay/workflow-runs") {
try {
Expand Down Expand Up @@ -3143,6 +3149,7 @@ export function relayBrokerPlugin({
!gifs &&
!profileSearch &&
!workflowPath &&
!scheduled &&
!readPublishing &&
!snapshot &&
!channelActivity &&
Expand Down Expand Up @@ -3221,19 +3228,21 @@ export function relayBrokerPlugin({
return json(res, 404, { error: "GIF search is unavailable" });
const upstreamPath =
workflowPath ??
(gifs
? gifSearchPath
: profileSearch
? "/profiles/search"
: profile || directMessage || member || leave
? "/events"
: claim
? "/api/invites/claim"
: policy
? "/api/invites/accept-policy"
: invite
? "/api/invites"
: "/query");
(scheduled
? "/scheduled-messages"
: gifs
? gifSearchPath
: profileSearch
? "/profiles/search"
: profile || directMessage || member || leave
? "/events"
: claim
? "/api/invites/claim"
: policy
? "/api/invites/accept-policy"
: invite
? "/api/invites"
: "/query");
const method = workflowPath ? "GET" : "POST";
const lane = admissions(relay, viewer).api;
let releasePresence;
Expand Down
117 changes: 117 additions & 0 deletions browser-host/scheduled-messages.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { getPublicKey, verifyEvent } from "nostr-tools";
import { brokerSocket } from "../tests/broker-socket.mjs";
import { relayBrokerPlugin } from "./relay-broker.mjs";
import { connectBrokerTransport } from "../src/features/relay/transport.ts";
import { createScheduledMessages } from "../src/features/relay/scheduled-messages.ts";

it("uses advertised scheduling through the authenticated fixed broker route", async () => {
const key = new Uint8Array(32);
key[31] = 19;
const viewer = getPublicKey(key),
calls = [];
const socket = brokerSocket(() => "scheduled-result");
let handler, queued;
const server = createServer((req, res) => {
if (!req.headers.origin) req.headers.origin = `http://${req.headers.host}`;
void handler(req, res);
});
await relayBrokerPlugin({
archiveFile: ":memory:",
relayUrl: "https://relay.example",
identity: () => key,
socketFactory: socket.factory,
upstreamFetch: async (url, init) => {
if (init.headers.Accept === "application/nostr+json")
return Response.json({
self: viewer,
supported_extensions: ["buzz-scheduled-messages-v1"],
});
const auth = JSON.parse(
Buffer.from(init.headers.Authorization.slice(6), "base64").toString(),
);
expect(verifyEvent(auth)).toBe(true);
expect(auth.pubkey).toBe(viewer);
expect(auth.tags).toContainEqual([
"u",
"https://relay.example/scheduled-messages",
]);
expect(auth.tags).toContainEqual(["method", "POST"]);
expect(auth.tags).toContainEqual([
"payload",
createHash("sha256").update(init.body).digest("hex"),
]);
expect(String(url)).toBe("https://relay.example/scheduled-messages");
expect(init.redirect).toBe("error");
const body = JSON.parse(init.body);
calls.push(body);
if (body.action === "create") {
queued = body;
return Response.json({ accepted: true });
}
if (body.action === "list")
return Response.json({
messages: [
{
...queued,
revision: 1,
send_at: queued.event.created_at,
status: "scheduled",
},
],
});
return Response.json({ error: "Changed" }, { status: 409 });
},
}).configureServer({
httpServer: server,
config: { logger: { info() {}, error() {} } },
middlewares: {
use(fn) {
handler = fn;
},
},
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const transport = await connectBrokerTransport(
`http://127.0.0.1:${server.address().port}`,
);
expect(transport.scheduledMessages).toBeDefined();
const service = createScheduledMessages(transport.scheduledMessages, {
viewer,
signal: new AbortController().signal,
prepare: (input) => ({
kind: 9,
content: input.content,
tags: [["h", input.destination.channelId]],
}),
name: () => "Launch",
});
await service.create({
destination: {
channelId: "11111111-1111-4111-8111-111111111111",
name: "Launch",
},
content: "Tomorrow",
sendAt: Date.now() + 86400000,
});
expect(verifyEvent(queued.event)).toBe(true);
expect(queued.event.pubkey).toBe(viewer);
const [row] = await service.list(new AbortController().signal);
expect(row.content).toBe("Tomorrow");
await expect(service.cancel(row.id, row.revision)).rejects.toThrow(
"changed",
);
expect(calls.map((call) => call.action)).toEqual([
"create",
"list",
"cancel",
]);
expect(socket.publications).toHaveLength(0);
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
});
11 changes: 8 additions & 3 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,11 @@ revokes all of them, and a pairing unused for 30 days expires.
**Settings → Plugins**, and it appears in the Messages sidebar. Opening it keeps
the conversation sidebar available, so you can switch straight back to a chat.
Saved links to it open the plugin, or offer to turn it on. Use the **Drafts**
and **Sent** tabs to switch lists; Buzz remembers your choice for this community.
and **Sent** tabs to switch lists; a relay that supports scheduling also adds
**Scheduled**. Buzz remembers your choice for this community. A clock and count
beside **Drafts & sent** show messages waiting for scheduled delivery.
Drafts save automatically as you type—leave a message unfinished, open
**Drafts & sent → Drafts**, then select it to continue writing.
Rows lead with the destination and message text. It lists
the messages you started but did not send, newest
change first, with the number of drafts shown beside it in the Messages
Expand All @@ -730,8 +734,9 @@ revokes all of them, and a pairing unused for 30 days expires.
Sent destinations show the conversation without a composer, with an archived
or read-only notice and Join when available. The popout icon
opens the original conversation.
Search above the list matches the conversation name and what you wrote. It reads only this
device's saved drafts; a draft's trash button clears that draft and its
Search filters only the selected tab by conversation name and what you wrote.
Each tab keeps its own search text while the page is open. The Drafts tab reads
only this device's saved drafts; a draft's trash button clears that draft and its
attached files without opening the conversation. A thread draft also shows
where you left it: **Draft** on a message's reply line, or **Draft reply** when
no one has replied yet (hidden while that thread is open).
Expand Down
47 changes: 47 additions & 0 deletions docs/scheduled-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Scheduled messages

When the relay advertises `buzz-scheduled-messages-v1`, the composer shows an
arrow beside Send. Choose tomorrow at 9 AM, Monday at 9 AM or a custom local time
within 120 days. Confirm the timezone and full date before scheduling. The draft
clears only after relay acceptance; a concurrently edited draft stays intact.
Attachments finish uploading before the queue accepts the message.

Enable the bundled **Drafts & sent** plugin to manage messages in one place.
Its **Drafts** tab lists unfinished messages saved automatically by the composer;
**Scheduled** lists queued messages and **Sent** shows recent sent messages.
The sidebar shows a draft count and a clock with the scheduled count. No separate
Scheduled messages sidebar entry is needed. Scheduled rows support editing,
rescheduling, sending now, cancelling to drafts and deleting. The main composer
preserves formatting, mentions, attachments and thread destinations. Editing a
queued message retains its original destination and attachment tags.

Draft conversion currently supports text, formatting, mentions and root-thread
replies. It refuses attachments, nested reply parents, media targets, explicit
model/effort settings and unaddressed slash commands, preserving the scheduled
message instead of silently changing its delivery. Ordinary drafts remain local
to this device; cross-device draft sync is not part of this change.

The session's `scheduledMessages` service prepares ordinary kind:9 messages
through the existing message builder. A native or browser-host signer signs the
exact approved event with its future `created_at`; the plugin receives no keys.
The native URL allowlist and broker use the fixed `/scheduled-messages` POST
route, authenticated with ordinary NIP-98 admission. Signing and requests are
revoked when the session closes. Scheduling continues on the relay after that.

The relay owns revisions, timestamps and delivery state. Lost create responses
retry the same UUID/event; stale revisions fail visibly. Refresh the queue after
uncertain responses before making a different change. A failed delivery stays
visible for review. Queue support is absent on older relays, so the composer
keeps its ordinary sending behavior there.

The relay implementation and mandatory two-phase enablement procedure are in
[block/buzz's scheduled-message protocol](https://github.com/block/buzz/blob/main/docs/scheduled-messages.md).
Do not enable during a mixed-version relay rollout. Pending messages retain their
selected event timestamps if an outage delays delivery.

For isolated end-to-end verification, start a local relay with scheduling enabled
and run `BUZZ_SCHEDULED_TEST_RELAY=http://127.0.0.1:3000 pnpm exec vitest run
src/features/relay/scheduled-messages.live.test.ts`. This test refuses remote
hosts and uses fictional random identities. Browser fixtures additionally cover
the date dialog, invalid time, refusal, retry and draft preservation in Chromium
and WebKit. Native runtime behavior still requires human testing before release.
1 change: 1 addition & 0 deletions src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fn request_url(community: &str, path: &str, method: &str) -> Result<Url> {
"POST" => matches!(
path,
"/query"
| "/scheduled-messages"
| "/events"
| "/api/invites"
| "/api/invites/claim"
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/relay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ fn routes_cannot_retarget_credentials_or_expand_http_access() {
] {
assert!(request_url("https://relay.test", path, "POST").is_err());
}
for path in ["/api/invites", "/gifs/search", "/profiles/search"] {
for path in [
"/api/invites",
"/gifs/search",
"/profiles/search",
"/scheduled-messages",
] {
assert!(request_url("https://relay.test", path, "POST").is_ok());
assert!(request_url("https://relay.test", path, "GET").is_err());
}
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/drafts-sent/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDraftsSentRoute } from "../../features/drafts-sent/route";
import type { PluginModule } from "../../plugins/api";
import { DraftsSentBadge } from "../../features/drafts-sent/DraftsSentBadge";
import { DraftsSentPage } from "../../features/drafts-sent/DraftsSentPage";
Expand All @@ -20,6 +21,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
primary: true,
companion: true,
handlesNavigation: true,
route: { version: 1, validate: isDraftsSentRoute },
badge: () => <DraftsSentBadge relay={relay} />,
component: ({ companion, navigation }) => (
<DraftsSentPage
Expand Down
6 changes: 6 additions & 0 deletions src/bundled/mentions/team-mentions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { SelectedMentionContext } from "../../features/messages/selected-mention
import type { RelaySession } from "../../features/relay/session";
import type { Contribution } from "../../plugins/contributions";
import * as personMatch from "../../features/search/person-match";
import { SEARCH_DEBOUNCE_MS } from "../../shared/search-debounce";

// jsdom lacks scrollIntoView; the search highlight keeps its row in view.
beforeEach(() => {
Expand Down Expand Up @@ -997,10 +998,15 @@ it.each(["loading", "error", "unavailable"] as const)(
<MentionCompletion {...props} query={query} />
</AgentsContext.Provider>
);
vi.useFakeTimers();
const view = render(content());
const choice = () =>
completion(publish)?.items.find((item) => item.label === "Review Crew");
try {
// The legacy status is visible only after the independent directory search settles.
await act(async () => vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS));
expect(h.session.directMessages.people).toHaveBeenCalled();
vi.useRealTimers();
await waitFor(() => expect(choice()?.canSelect?.("Enter")).toBe(true));
await waitFor(() =>
expect(completion(publish).status).toBe(
Expand Down
17 changes: 15 additions & 2 deletions src/features/drafts-sent/DraftsSent.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,24 @@
}
}

.badge {
display: inline-grid;
.badge,
.scheduledBadge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}

.channelNotice {
position: relative;
flex: none;
}

.scheduledSection {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.scheduledSection[hidden] {
display: none;
}
19 changes: 17 additions & 2 deletions src/features/drafts-sent/DraftsSentBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ClockIcon } from "../../shared/design-system/icons";
import { useScheduledMessages } from "../messages/useScheduledMessages";
import type { RelayData } from "../relay/service";
import { useChannelList, useRelayConnection } from "../relay/react";
import { CountBadge } from "../../shared/design-system/ui/CountBadge";
import styles from "./DraftsSent.module.css";
import { useDrafts } from "./drafts";
import { listed } from "./scope";

/** Sidebar count of unsent drafts in the current community. */
/** Unfinished drafts and queued delivery in the current community. */
export function DraftsSentBadge({ relay }: { relay: RelayData }) {
const connection = useRelayConnection(relay);
const session =
Expand All @@ -17,8 +19,21 @@ export function DraftsSentBadge({ relay }: { relay: RelayData }) {
listed(channels.find((channel) => channel.id === entry.channelId)),
).length
: 0;
return count ? (
const scheduled = useScheduledMessages(session?.scheduledMessages);
const queued = scheduled.items.filter(
(item) => item.status !== "sent",
).length;
return count || queued ? (
<span className={styles.badge}>
{queued > 0 && (
<span className={styles.scheduledBadge}>
<ClockIcon size={14} aria-hidden="true" />
<CountBadge
count={queued}
label={`${queued} scheduled message${queued === 1 ? "" : "s"}`}
/>
</span>
)}
<CountBadge
count={count}
label={`${count} draft${count === 1 ? "" : "s"}`}
Expand Down
Loading