From f5514bcd07896de4017d7aa3424b339b072ee670 Mon Sep 17 00:00:00 2001 From: sqnico <111789695+sqnico@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:09:10 -0500 Subject: [PATCH 1/2] feat(messages): add durable scheduling service Expose signed, authenticated relay scheduling through the native and browser host boundaries. Preserve exact message bindings and stable retries within the active session. Co-authored-by: Codex Signed-off-by: sqnico <111789695+sqnico@users.noreply.github.com> --- browser-host/relay-broker.mjs | 35 ++- browser-host/scheduled-messages.test.mjs | 117 ++++++++ src-tauri/src/relay.rs | 1 + src-tauri/src/relay/tests.rs | 7 +- src/features/messages/composer-session.ts | 1 + src/features/relay/messages.ts | 134 ++++----- src/features/relay/native.ts | 26 ++ .../relay/scheduled-messages.live.test.ts | 163 +++++++++++ src/features/relay/scheduled-messages.test.ts | 157 +++++++++++ src/features/relay/scheduled-messages.ts | 254 ++++++++++++++++++ src/features/relay/session.ts | 148 ++++++---- src/features/relay/transport.ts | 32 +++ 12 files changed, 942 insertions(+), 133 deletions(-) create mode 100644 browser-host/scheduled-messages.test.mjs create mode 100644 src/features/relay/scheduled-messages.live.test.ts create mode 100644 src/features/relay/scheduled-messages.test.ts create mode 100644 src/features/relay/scheduled-messages.ts diff --git a/browser-host/relay-broker.mjs b/browser-host/relay-broker.mjs index b49631ad19..858dbde5fb 100644 --- a/browser-host/relay-broker.mjs +++ b/browser-host/relay-broker.mjs @@ -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"), @@ -1685,6 +1688,7 @@ export function relayBrokerPlugin({ memberAdministration: true, identityArchives: true, workflowReads: true, + scheduledMessages: (await getAuthority(relay)).scheduledMessages, projectGit: true, attachmentUploads: true, sidebarPreferences: true, @@ -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" @@ -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 { @@ -3143,6 +3149,7 @@ export function relayBrokerPlugin({ !gifs && !profileSearch && !workflowPath && + !scheduled && !readPublishing && !snapshot && !channelActivity && @@ -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; diff --git a/browser-host/scheduled-messages.test.mjs b/browser-host/scheduled-messages.test.mjs new file mode 100644 index 0000000000..c537684b40 --- /dev/null +++ b/browser-host/scheduled-messages.test.mjs @@ -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)); + } +}); diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index 1ac49d1b0c..06cb6ace33 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -82,6 +82,7 @@ fn request_url(community: &str, path: &str, method: &str) -> Result { "POST" => matches!( path, "/query" + | "/scheduled-messages" | "/events" | "/api/invites" | "/api/invites/claim" diff --git a/src-tauri/src/relay/tests.rs b/src-tauri/src/relay/tests.rs index 3d344cf422..b3f042e2b1 100644 --- a/src-tauri/src/relay/tests.rs +++ b/src-tauri/src/relay/tests.rs @@ -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()); } diff --git a/src/features/messages/composer-session.ts b/src/features/messages/composer-session.ts index 3f16aeb880..89a6f2b806 100644 --- a/src/features/messages/composer-session.ts +++ b/src/features/messages/composer-session.ts @@ -8,6 +8,7 @@ export type ComposerSession = Pick< RelaySession, "viewer" | "media" | "scope" > & { + scheduledMessages?: RelaySession["scheduledMessages"]; names?: import("../identity-names/service").IdentityNameView | undefined; recovery?: RelaySession["recovery"]; repositories: Pick< diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index b98676aa34..b2b9a16f46 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -297,32 +297,77 @@ export function createMessages( ); return operations[0] as string; }; + function prepareSend( + channelId: string, + content: string, + mentions: readonly string[] = [], + attachments: readonly UploadedAttachment[] = [], + _recovery?: OutboxRecovery, + references: readonly string[] = [], + picks: MentionPicks = {}, + ) { + if (!channelId) throw new Error("A channel is required"); + const message = attachmentMessage(content, attachments, relayOrigin); + writer(9, channelId); + return { + kind: 9, + content: text(message.content), + tags: [ + ["h", channelId], + ...mentionTags(channelId, withDmAgents(channelId, mentions)), + ...referenceTags(references), + ...pickTags(channelId, mentions, picks), + ...emojiTags(content), + ...message.tags, + ], + }; + } + function prepareReply( + channelId: string, + rootId: string, + content: string, + mentions: readonly string[] = [], + attachments: readonly UploadedAttachment[] = [], + parentId: string = rootId, + references: readonly string[] = [], + picks: MentionPicks = {}, + mediaTargetUrl?: string, + ) { + if (!channelId) throw new Error("A channel is required"); + if (!/^[0-9a-f]{64}$/.test(rootId)) + throw new Error("A valid thread root is required"); + if (!/^[0-9a-f]{64}$/.test(parentId)) + throw new Error("A valid reply parent is required"); + const mediaTarget = mediaTargetUrl + ? safeMessageUrl(mediaTargetUrl) + : undefined; + if (mediaTargetUrl && !mediaTarget) + throw new Error("A valid media target is required"); + const message = attachmentMessage(content, attachments, relayOrigin); + writer(9, channelId); + return { + kind: 9, + content: text(message.content), + tags: [ + ["h", channelId], + ...(parentId === rootId ? [] : [["e", rootId, "", "root"]]), + ["e", parentId, "", "reply"], + ...mentionTags(channelId, withDmAgents(channelId, mentions)), + ...referenceTags(references), + ...pickTags(channelId, mentions, picks), + ...(mediaTarget ? [[MEDIA_TARGET_TAG, mediaTarget]] : []), + ...emojiTags(content), + ...message.tags, + ], + }; + } return Object.freeze({ - send( - channelId: string, - content: string, - mentions: readonly string[] = [], - attachments: readonly UploadedAttachment[] = [], - recovery?: OutboxRecovery, - references: readonly string[] = [], - picks: MentionPicks = {}, - ) { - if (!channelId) throw new Error("A channel is required"); - const message = attachmentMessage(content, attachments, relayOrigin); - return writer(9, channelId).send( - { - kind: 9, - content: text(message.content), - tags: [ - ["h", channelId], - ...mentionTags(channelId, withDmAgents(channelId, mentions)), - ...referenceTags(references), - ...pickTags(channelId, mentions, picks), - ...emojiTags(content), - ...message.tags, - ], - }, - ...(recovery ? [recovery] : []), + prepareSend, + prepareReply, + send(...args: Parameters) { + return writer(9, args[0]).send( + prepareSend(...args), + ...(args[4] ? [args[4]] : []), ); }, /** Copy an authored reply as a fresh top-level event, preserving its displayed semantics. */ @@ -408,43 +453,8 @@ export function createMessages( ], }); }, - reply( - channelId: string, - rootId: string, - content: string, - mentions: readonly string[] = [], - attachments: readonly UploadedAttachment[] = [], - parentId: string = rootId, - references: readonly string[] = [], - picks: MentionPicks = {}, - mediaTargetUrl?: string, - ) { - if (!channelId) throw new Error("A channel is required"); - if (!/^[0-9a-f]{64}$/.test(rootId)) - throw new Error("A valid thread root is required"); - if (!/^[0-9a-f]{64}$/.test(parentId)) - throw new Error("A valid reply parent is required"); - const mediaTarget = mediaTargetUrl - ? safeMessageUrl(mediaTargetUrl) - : undefined; - if (mediaTargetUrl && !mediaTarget) - throw new Error("A valid media target is required"); - const message = attachmentMessage(content, attachments, relayOrigin); - return writer(9, channelId).send({ - kind: 9, - content: text(message.content), - tags: [ - ["h", channelId], - ...(parentId === rootId ? [] : [["e", rootId, "", "root"]]), - ["e", parentId, "", "reply"], - ...mentionTags(channelId, withDmAgents(channelId, mentions)), - ...referenceTags(references), - ...pickTags(channelId, mentions, picks), - ...(mediaTarget ? [[MEDIA_TARGET_TAG, mediaTarget]] : []), - ...emojiTags(content), - ...message.tags, - ], - }); + reply(...args: Parameters) { + return writer(9, args[0]).send(prepareReply(...args)); }, /** Supply the current folded attachmentSourceId, or the row ID for original media. */ edit: (messageId: string, content: string, attachmentSourceId: string) => diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index 35ed60aba3..f8c00dd4c6 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -515,6 +515,32 @@ export async function connectNativeTransport( ), } : {}), + ...(Array.isArray(info.supported_extensions) && + info.supported_extensions.includes("buzz-scheduled-messages-v1") + ? { + scheduledMessages: { + sign: async (event: EventTemplate, signal: AbortSignal) => { + const writer = transport.writer; + if (!writer) + throw new Error("Scheduled message signing is unavailable"); + return writer.sign(event, signal); + }, + request: (body: unknown, signal: AbortSignal) => + admitSignedRequest( + origin, + transport.viewer, + () => + nativeRelayRequest( + origin, + "/scheduled-messages", + body, + signal, + ), + signal, + ), + }, + } + : {}), workflows: workflowHost(async (route, body, signal) => { if (route !== "workflow-runs") throw new Error("Invalid workflow read"); workflowRunsPath(body); diff --git a/src/features/relay/scheduled-messages.live.test.ts b/src/features/relay/scheduled-messages.live.test.ts new file mode 100644 index 0000000000..34a2034717 --- /dev/null +++ b/src/features/relay/scheduled-messages.live.test.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; +import { createScheduledMessages } from "./scheduled-messages"; + +const relay = process.env.BUZZ_SCHEDULED_TEST_RELAY; +it.skipIf(!relay)( + "delivers through a local relay after the scheduling session closes", + async () => { + if (!relay) throw new Error("Local relay URL is required"); + const base = new URL(relay); + if ( + !["http:", "https:"].includes(base.protocol) || + !["127.0.0.1", "localhost"].includes(base.hostname) + ) + throw new Error( + "Live scheduling tests require an isolated loopback relay", + ); + const key = generateSecretKey(), + viewer = getPublicKey(key), + channel = crypto.randomUUID(); + async function post(path: string, body: unknown, secret = key) { + const text = JSON.stringify(body); + const auth = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["u", `${base.origin}${path}`], + ["method", "POST"], + ["payload", createHash("sha256").update(text).digest("hex")], + ["nonce", crypto.randomUUID()], + ], + }, + secret, + ); + return fetch(`${base.origin}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`, + }, + body: text, + signal: AbortSignal.timeout(10000), + }); + } + const created = await post( + "/events", + finalizeEvent( + { + kind: 9007, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["h", channel], + ["name", `scheduled-${channel.slice(0, 8)}`], + ], + }, + key, + ), + ); + expect(await created.text()).toContain("true"); + const lifetime = new AbortController(); + const service = (signal: AbortSignal) => + createScheduledMessages( + { + sign: async (template) => finalizeEvent(template, key), + request: (body) => post("/scheduled-messages", body), + }, + { + viewer, + signal, + prepare: (input) => ({ + kind: 9, + content: input.content, + tags: [["h", channel]], + }), + name: () => "Scheduled fixture", + }, + ); + const first = service(lifetime.signal); + const input = { + destination: { channelId: channel, name: "Scheduled fixture" }, + content: "Survives a closed session", + sendAt: Date.now() + 6000, + }; + await first.create(input); + const [queued] = await first.list(lifetime.signal); + expect(queued?.status).toBe("scheduled"); + if (!queued) throw new Error("Expected queued message"); + const foreign = await post( + "/scheduled-messages", + { action: "list" }, + generateSecretKey(), + ); + expect((await foreign.json()).messages).toEqual([]); + lifetime.abort(); + await expect(first.create(input)).rejects.toThrow(); + const nextLifetime = new AbortController(), + next = service(nextLifetime.signal); + let delivered = false; + for (let i = 0; i < 40; i++) { + await new Promise((resolve) => setTimeout(resolve, 300)); + const rows = await next.list(nextLifetime.signal); + if (rows.find((row) => row.id === queued.id)?.status === "sent") { + delivered = true; + break; + } + } + expect(delivered).toBe(true); + await next.create({ + ...input, + content: "Cancel this", + sendAt: Date.now() + 86400000, + }); + let rows = await next.list(nextLifetime.signal); + const cancel = rows.find((row) => row.content === "Cancel this"); + if (!cancel) throw new Error("Expected message to cancel"); + await next.cancel(cancel.id, cancel.revision); + rows = await next.list(nextLifetime.signal); + expect(rows.some((row) => row.id === cancel.id)).toBe(false); + await next.create({ + ...input, + content: "Edit this", + sendAt: Date.now() + 86400000, + }); + rows = await next.list(nextLifetime.signal); + const edit = rows.find((row) => row.content === "Edit this"); + if (!edit) throw new Error("Expected message to edit"); + await next.update(edit.id, edit.revision, { + ...input, + content: "Edited and sent now", + sendAt: Date.now() + 86400000, + }); + rows = await next.list(nextLifetime.signal); + const revised = rows.find((row) => row.id === edit.id); + if (!revised) throw new Error("Expected updated message"); + expect(Number(revised.revision)).toBeGreaterThan(Number(edit.revision)); + await expect(next.cancel(edit.id, edit.revision)).rejects.toThrow( + "Refresh", + ); + await next.sendNow(revised.id, revised.revision); + for (let i = 0; i < 30; i++) { + rows = await next.list(nextLifetime.signal); + if (rows.find((row) => row.id === edit.id)?.status === "sent") break; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + expect(rows.find((row) => row.id === edit.id)?.status).toBe("sent"); + const query = await post("/query", [ + { kinds: [9], "#h": [channel], limit: 100 }, + ]); + const events = await query.json(); + expect( + events.map((event: { content: string }) => event.content).sort(), + ).toEqual(["Edited and sent now", "Survives a closed session"]); + expect(new Set(events.map((event: { id: string }) => event.id)).size).toBe( + 2, + ); + nextLifetime.abort(); + }, + 30000, +); diff --git a/src/features/relay/scheduled-messages.test.ts b/src/features/relay/scheduled-messages.test.ts new file mode 100644 index 0000000000..9c6c35c008 --- /dev/null +++ b/src/features/relay/scheduled-messages.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; +import { keypair, signed } from "./testing"; +import { + createScheduledMessages, + type ScheduledInput, +} from "./scheduled-messages"; + +const channel = "11111111-1111-4111-8111-111111111111"; +const id = "22222222-2222-4222-8222-222222222222"; +function fixture() { + const key = keypair(), + lifetime = new AbortController(); + const input: ScheduledInput = { + destination: { channelId: channel, name: "Launch" }, + content: "Ready tomorrow", + sendAt: Date.now() + 86400000, + }; + const event = signed(key, { + kind: 9, + content: input.content, + created_at: Math.floor(input.sendAt / 1000), + tags: [ + ["h", channel], + ["p", "a".repeat(64)], + ["e", "b".repeat(64), "", "reply"], + ["imeta", "url https://example.invalid/file.png"], + ], + }); + const request = vi.fn(async (_body: unknown, _signal: AbortSignal) => + Response.json({ accepted: true }), + ); + const sign = vi.fn(async (template: Parameters[1]) => + signed(key, template), + ); + const service = createScheduledMessages( + { request, sign }, + { + viewer: key.pubkey, + signal: lifetime.signal, + prepare: () => ({ kind: 9, content: input.content, tags: event.tags }), + name: () => "Launch", + }, + ); + const list = () => { + request.mockResolvedValueOnce( + Response.json({ + messages: [ + { + id, + revision: 1, + event, + send_at: event.created_at, + status: "scheduled", + }, + ], + }), + ); + return service.list(new AbortController().signal); + }; + return { key, lifetime, input, event, request, sign, service, list }; +} + +describe("relay-backed scheduled messages", () => { + it("retries an uncertain create with the same operation and signed event", async () => { + const h = fixture(); + h.request.mockRejectedValueOnce(new Error("Response lost")); + await expect(h.service.create(h.input)).rejects.toThrow("Response lost"); + await h.service.create(h.input); + expect(h.sign).toHaveBeenCalledTimes(1); + expect(h.request.mock.calls[0]?.[0]).toEqual(h.request.mock.calls[1]?.[0]); + }); + it("checks the exact approved timestamp and signature before queueing", async () => { + const h = fixture(); + h.sign.mockImplementationOnce(async (template) => + signed(h.key, { + ...template, + created_at: (template.created_at ?? 0) + 60, + }), + ); + await expect(h.service.create(h.input)).rejects.toThrow("approved draft"); + expect(h.request).not.toHaveBeenCalled(); + h.sign.mockImplementationOnce(async (template) => ({ + ...signed(h.key, template), + sig: "0".repeat(128), + })); + await expect(h.service.create(h.input)).rejects.toThrow(); + expect(h.request).not.toHaveBeenCalled(); + }); + it("refuses invalid times before signing or contacting the relay", async () => { + const h = fixture(); + for (const time of [NaN, Date.now() - 1, Date.now() + 121 * 86400000]) + await expect( + h.service.create({ ...h.input, sendAt: time }), + ).rejects.toThrow(); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.request).not.toHaveBeenCalled(); + }); + it("verifies queued signatures and preserves thread and attachment bindings on edit", async () => { + const h = fixture(); + const rows = await h.list(); + expect(rows[0]?.destination.threadRootId).toBe("b".repeat(64)); + await h.service.update(id, "1", { ...h.input, content: "Updated" }); + expect(h.sign.mock.calls[0]?.[0]).toMatchObject({ + content: "Updated", + tags: h.event.tags, + }); + await expect(h.service.cancel(id, "2")).rejects.toThrow("Refresh"); + }); + it("rejects tampered queued content before allowing any mutation", async () => { + const h = fixture(); + h.request.mockResolvedValueOnce( + Response.json({ + messages: [ + { + id, + revision: 1, + event: { ...h.event, content: "tampered" }, + send_at: h.event.created_at, + status: "scheduled", + }, + ], + }), + ); + await expect( + h.service.list(new AbortController().signal), + ).rejects.toThrow(); + await expect(h.service.sendNow(id, "1")).rejects.toThrow("Refresh"); + }); + it("signs send-now with a current timestamp and the exact reviewed message", async () => { + const h = fixture(); + await h.list(); + await h.service.sendNow(id, "1"); + expect(h.sign.mock.calls[0]?.[0]).toMatchObject({ + kind: 9, + content: h.event.content, + tags: h.event.tags, + }); + expect(h.sign.mock.calls[0]?.[0].created_at).toBeLessThanOrEqual( + Math.floor(Date.now() / 1000), + ); + expect(h.request.mock.calls[1]?.[0]).toMatchObject({ + action: "send_now", + id, + revision: 1, + }); + }); + it("revokes signing and relay operations when the account/session closes", async () => { + const h = fixture(); + h.lifetime.abort(); + await expect(h.service.create(h.input)).rejects.toThrow(); + await expect( + h.service.list(new AbortController().signal), + ).rejects.toThrow(); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/relay/scheduled-messages.ts b/src/features/relay/scheduled-messages.ts new file mode 100644 index 0000000000..3000f3da35 --- /dev/null +++ b/src/features/relay/scheduled-messages.ts @@ -0,0 +1,254 @@ +import type { EventTemplate } from "nostr-tools"; +import { createEventVerifier, type RelayEvent } from "./events"; +import { parseEvents } from "./transport"; +import type { UploadedAttachment } from "./attachments"; +import type { MentionPicks } from "./messages"; + +export type ScheduledInput = Readonly<{ + destination: Readonly<{ + channelId: string; + name: string; + threadRootId?: string; + }>; + content: string; + sendAt: number; + mentions?: readonly string[]; + attachments?: readonly UploadedAttachment[]; + parentId?: string; + references?: readonly string[]; + picks?: MentionPicks; + mediaTargetUrl?: string; +}>; +export type ScheduledMessage = Readonly<{ + id: string; + revision: string; + destination: ScheduledInput["destination"]; + content: string; + sendAt: number; + status: "scheduled" | "sent" | "failed"; + error?: string; +}>; +/** Native/broker-owned signing and one authenticated, fixed queue route. */ +export interface ScheduledHost { + sign(template: EventTemplate, signal: AbortSignal): Promise; + request(body: unknown, signal: AbortSignal): Promise; +} +export type ScheduledMessages = ReturnType; +type Template = Pick; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** A session owns authorization/lifetime; the relay owns persistence and clocks. */ +export function createScheduledMessages( + host: ScheduledHost, + options: { + viewer: string; + signal: AbortSignal; + prepare(input: ScheduledInput): Template; + name(channelId: string): string; + }, +) { + const events = new Map(); + const pending = new Map(); + const listeners = new Set<() => void>(); + const changed = () => { + for (const listener of listeners) listener(); + }; + function signal(extra?: AbortSignal) { + const owned = AbortSignal.any([ + options.signal, + AbortSignal.timeout(15_000), + ...(extra ? [extra] : []), + ]); + owned.throwIfAborted(); + return owned; + } + async function request(body: unknown, extra?: AbortSignal) { + const live = signal(extra); + const response = await host.request(body, live); + live.throwIfAborted(); + if (!response.ok) + throw new Error( + response.status === 409 + ? "This message changed or the queue is full. Refresh before trying again." + : "Could not reach scheduled delivery. Refresh to check whether the change was saved.", + ); + const reader = response.body?.getReader(); + if (!reader) throw new Error("Scheduled delivery returned no response."); + const decoder = new TextDecoder(); + let text = "", + bytes = 0; + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + bytes += part.value.length; + if (bytes > 16 * 1024 * 1024) + throw new Error("Scheduled response is too large."); + text += decoder.decode(part.value, { stream: true }); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + live.throwIfAborted(); + return JSON.parse(text + decoder.decode()) as unknown; + } + async function signed( + input: ScheduledInput, + template = options.prepare(input), + ) { + const now = Date.now(); + if ( + !Number.isFinite(input.sendAt) || + input.sendAt <= now || + input.sendAt > now + 120 * 86_400_000 + ) + throw new Error("Choose a future time within 120 days."); + return signApproved({ + ...template, + created_at: Math.floor(input.sendAt / 1000), + }); + } + async function signApproved(template: EventTemplate) { + const live = signal(); + const event = await host.sign(template, live); + live.throwIfAborted(); + if ( + event.pubkey !== options.viewer || + event.kind !== 9 || + event.created_at !== template.created_at || + event.content !== template.content || + JSON.stringify(event.tags) !== JSON.stringify(template.tags) + ) + throw new Error("The signed message did not match the approved draft."); + await parseEvents([event], createEventVerifier(), live); + return event; + } + function current(id: string, revision: string) { + const row = events.get(id); + if (!row || row.revision !== revision) + throw new Error("Refresh before changing this message."); + return row.event; + } + return Object.freeze({ + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + event(id: string, revision: string) { + return structuredClone(current(id, revision)); + }, + async list(extra: AbortSignal): Promise { + const raw = (await request({ action: "list" }, extra)) as { + messages?: unknown; + }; + if (!Array.isArray(raw.messages) || raw.messages.length > 356) + throw new Error("Invalid scheduled-message list."); + await parseEvents( + raw.messages.map((row) => row.event), + createEventVerifier(), + signal(extra), + ); + const next = new Map(); + const rows = raw.messages.map((row): ScheduledMessage => { + const event = row.event as RelayEvent; + if ( + !uuid.test(row.id) || + !Number.isSafeInteger(row.revision) || + row.revision < 1 || + !event || + event.pubkey !== options.viewer || + event.kind !== 9 || + typeof event.content !== "string" || + !Array.isArray(event.tags) || + !Number.isSafeInteger(row.send_at) || + !["scheduled", "sent", "failed"].includes(row.status) || + next.has(row.id) + ) + throw new Error("Invalid scheduled-message row."); + const channelId = event.tags.find(([name]) => name === "h")?.[1]; + if (!channelId || !uuid.test(channelId)) + throw new Error("Invalid scheduled conversation."); + const root = + event.tags.find( + ([name, , , marker]) => name === "e" && marker === "root", + )?.[1] ?? + event.tags.find( + ([name, , , marker]) => name === "e" && marker === "reply", + )?.[1]; + next.set(row.id, { event, revision: String(row.revision) }); + return { + id: row.id, + revision: String(row.revision), + destination: { + channelId, + name: options.name(channelId), + ...(root ? { threadRootId: root } : {}), + }, + content: event.content, + sendAt: row.send_at * 1000, + status: row.status, + ...(typeof row.error === "string" ? { error: row.error } : {}), + }; + }); + events.clear(); + for (const [id, row] of next) events.set(id, row); + return rows; + }, + async create(input: ScheduledInput) { + const template = options.prepare(input); + const key = JSON.stringify([template, Math.floor(input.sendAt / 1000)]); + let operation = pending.get(key); + if (!operation) { + operation = { + id: crypto.randomUUID(), + event: await signed(input, template), + }; + pending.set(key, operation); + } + await request({ action: "create", ...operation }); + pending.delete(key); + changed(); + }, + async update(id: string, revision: string, input: ScheduledInput) { + const previous = current(id, revision); + // Preserve the original attachment, mention and thread bindings. The editor + // changes prose/time; only the main composer creates new bindings. + const event = await signed(input, { + kind: 9, + content: input.content, + tags: previous.tags.map((t) => [...t]), + }); + await request({ + action: "update", + id, + revision: Number(revision), + event, + }); + changed(); + }, + async cancel(id: string, revision: string) { + current(id, revision); + await request({ action: "cancel", id, revision: Number(revision) }); + changed(); + }, + async sendNow(id: string, revision: string) { + const previous = current(id, revision); + const event = await signApproved({ + kind: 9, + content: previous.content, + tags: previous.tags.map((t) => [...t]), + created_at: Math.floor(Date.now() / 1000), + }); + await request({ + action: "send_now", + id, + revision: Number(revision), + event, + }); + changed(); + }, + }); +} diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 0074781ea5..239e632f04 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -1,3 +1,4 @@ +import { createScheduledMessages } from "./scheduled-messages"; import type { LocalAgentSource } from "../agents/local-activity"; // FOUNDATION: One relay session owns reads, local intent, delivery and shared views. import { createSoloPreferences, soloGroups } from "./solo-preferences"; @@ -2410,7 +2411,96 @@ export function createRelaySession( if (connected && !liveWasConnected) recovery?.retryNow(liveRecovery); liveWasConnected = connected; }); + const messageCommands = createMessages( + writes?.outbox, + transport?.viewer, + (id) => + local().find((item) => item.event.id === id)?.event ?? + recent.peek(id)?.event ?? + retainedEvent(id), + emoji.tags, + validateMentions, + (id, owner) => + !closed && + (channels.canWrite(id) || + // editOwnedAgent and messages.remove supply owner evidence; + // validateChannelWrite rechecks it before signing and publication. + (!!owner && channels.canParticipate(id))), + transport?.scope, + writer && (!writer.kinds || writer.kinds.includes(1984)) + ? async (template) => { + if (closed) throw new Error("Relay session closed"); + const signal = AbortSignal.any([ + lifetime.signal, + AbortSignal.timeout(10_000), + ]); + // Race each step so the deadline holds even if the writer ignores `signal`. + const aborted = new Promise((_, reject) => { + const fail = () => reject(signal.reason); + if (signal.aborted) fail(); + else signal.addEventListener("abort", fail, { once: true }); + }); + const signed = await Promise.race([ + writer.sign(template, signal), + aborted, + ]); + signal.throwIfAborted(); + await Promise.race([writer.publish(signed, signal), aborted]); + } + : undefined, + transport?.archiveAuthority, + profiles.queries, + dmPartners, + (id) => [ + ...new Map( + [ + ...local().map((item) => item.event), + ...recent.entries().map(([, item]) => item.event), + ...retainedThreadEvents(), + ...retainedChannelEvents(), + ] + .filter((event) => + event.tags.some( + ([name, value]) => name === "buzz:copy-of" && value === id, + ), + ) + .map((event) => [event.id, event]), + ).values(), + ], + ); + const scheduledMessages = transport?.scheduledMessages + ? createScheduledMessages(transport.scheduledMessages, { + viewer: transport.viewer, + signal: lifetime.signal, + name: (id) => + channels.queries.list().channels.find((channel) => channel.id === id) + ?.name ?? "Conversation", + prepare: (input) => + input.destination.threadRootId + ? messageCommands.prepareReply( + input.destination.channelId, + input.destination.threadRootId, + input.content, + input.mentions, + input.attachments, + input.parentId, + input.references, + input.picks, + input.mediaTargetUrl, + ) + : messageCommands.prepareSend( + input.destination.channelId, + input.content, + input.mentions, + input.attachments, + undefined, + input.references, + input.picks, + ), + }) + : undefined; const session = Object.freeze({ + scheduledMessages, artifactIncremental: transport?.artifactIncremental, memberAdditions, presence, @@ -2699,63 +2789,7 @@ export function createRelaySession( }) : undefined, messages: Object.freeze({ - ...createMessages( - writes?.outbox, - transport?.viewer, - (id) => - local().find((item) => item.event.id === id)?.event ?? - recent.peek(id)?.event ?? - retainedEvent(id), - emoji.tags, - validateMentions, - (id, owner) => - !closed && - (channels.canWrite(id) || - // editOwnedAgent and messages.remove supply owner evidence; - // validateChannelWrite rechecks it before signing and publication. - (!!owner && channels.canParticipate(id))), - transport?.scope, - writer && (!writer.kinds || writer.kinds.includes(1984)) - ? async (template) => { - if (closed) throw new Error("Relay session closed"); - const signal = AbortSignal.any([ - lifetime.signal, - AbortSignal.timeout(10_000), - ]); - // Race each step so the deadline holds even if the writer ignores `signal`. - const aborted = new Promise((_, reject) => { - const fail = () => reject(signal.reason); - if (signal.aborted) fail(); - else signal.addEventListener("abort", fail, { once: true }); - }); - const signed = await Promise.race([ - writer.sign(template, signal), - aborted, - ]); - signal.throwIfAborted(); - await Promise.race([writer.publish(signed, signal), aborted]); - } - : undefined, - transport?.archiveAuthority, - profiles.queries, - dmPartners, - (id) => [ - ...new Map( - [ - ...local().map((item) => item.event), - ...recent.entries().map(([, item]) => item.event), - ...retainedThreadEvents(), - ...retainedChannelEvents(), - ] - .filter((event) => - event.tags.some( - ([name, value]) => name === "buzz:copy-of" && value === id, - ), - ) - .map((event) => [event.id, event]), - ).values(), - ], - ), + ...messageCommands, /** UI eligibility only; signing/publication recheck the same role proof. */ canRetry(event: Pick) { const id = event.tags.find(([key]) => key === "h")?.[1]; diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index c658d52385..ba36f404ed 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -1,3 +1,4 @@ +import type { ScheduledHost } from "./scheduled-messages"; import type { ArtifactIncrementalReader } from "./artifact-incremental-reader.ts"; import type { VerifiedProfileAuthority } from "./verified-profile"; import type { ReminderHost } from "./reminders"; @@ -91,6 +92,9 @@ export interface RelayWriter { ): Promise | Promise; } export interface ReadTransport { + /** Owner-scoped relay queue; available only when advertised by this relay. */ + readonly scheduledMessages?: ScheduledHost; + /** Hear the relay refuse this account for the whole community, on any * request after connecting: its sign-in ended, or it has no access. One * item's refusal (a gated channel, say) stays with whoever asked for it. */ @@ -516,6 +520,7 @@ export async function connectBrokerTransport( archiveAuthority?: unknown; writeKinds?: number[]; workflowReads?: boolean; + scheduledMessages?: boolean; projectGit?: boolean; attachmentUploads?: boolean; directMessages?: boolean; @@ -715,6 +720,33 @@ export async function connectBrokerTransport( ...(typeof session.archiveAuthority === "string" ? { archiveAuthority: session.archiveAuthority } : {}), + ...(session.scheduledMessages === true + ? { + scheduledMessages: { + sign: async (event: EventTemplate, signal: AbortSignal) => { + signal.throwIfAborted(); + const response = await fetch(`${endpoint}/sign`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + signal, + }); + if (!response.ok) + throw new Error("Could not sign scheduled message"); + return eventDto(await response.json()); + }, + request: (body: unknown, signal: AbortSignal) => + fetch(`${endpoint}/scheduled-messages`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }), + }, + } + : {}), ...(session.workflowReads === true ? { workflows: workflowHost((route, body, signal) => From c2f5db29c4f647ca3a686eeb70b19c7f166bcf38 Mon Sep 17 00:00:00 2001 From: sqnico <111789695+sqnico@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:09:11 -0500 Subject: [PATCH 2/2] feat(messages): add Slack-style scheduled delivery Schedule from the composer and manage the queue in Drafts & sent. Keep searches scoped to each tab and preserve local drafts through failures and navigation. Co-authored-by: Codex Signed-off-by: sqnico <111789695+sqnico@users.noreply.github.com> --- docs/buzz-help.md | 11 +- docs/scheduled-messages.md | 47 +++ src/bundled/drafts-sent/index.tsx | 2 + src/bundled/mentions/team-mentions.test.tsx | 6 + .../drafts-sent/DraftsSent.module.css | 17 +- src/features/drafts-sent/DraftsSentBadge.tsx | 19 +- .../drafts-sent/DraftsSentPage.test.tsx | 177 ++++++++- src/features/drafts-sent/DraftsSentPage.tsx | 92 ++++- src/features/drafts-sent/route.ts | 23 ++ .../messages/MessageComposer.test.tsx | 203 +++++++++++ src/features/messages/MessageComposer.tsx | 235 +++++++++--- src/features/messages/ScheduleDateTime.tsx | 112 ++++++ .../messages/ScheduleMessageDialog.tsx | 104 ++++++ src/features/messages/ScheduleMessageMenu.tsx | 84 +++++ .../messages/ScheduledMessages.module.css | 87 +++++ .../messages/ScheduledMessagesList.tsx | 337 ++++++++++++++++++ src/features/messages/schedule-time.test.ts | 34 ++ src/features/messages/schedule-time.ts | 89 +++++ src/features/messages/scheduled-draft.test.ts | 124 +++++++ src/features/messages/scheduled-draft.ts | 110 ++++++ .../messages/useScheduledMessages.test.tsx | 161 +++++++++ src/features/messages/useScheduledMessages.ts | 120 +++++++ tests/browser/drafts-in-progress.spec.mjs | 58 +++ tests/browser/fixture.mjs | 25 ++ tests/browser/scheduled-messages.spec.mjs | 311 ++++++++++++++++ tests/fixtures/relay-composer.tsx | 57 ++- 26 files changed, 2569 insertions(+), 76 deletions(-) create mode 100644 docs/scheduled-messages.md create mode 100644 src/features/drafts-sent/route.ts create mode 100644 src/features/messages/ScheduleDateTime.tsx create mode 100644 src/features/messages/ScheduleMessageDialog.tsx create mode 100644 src/features/messages/ScheduleMessageMenu.tsx create mode 100644 src/features/messages/ScheduledMessages.module.css create mode 100644 src/features/messages/ScheduledMessagesList.tsx create mode 100644 src/features/messages/schedule-time.test.ts create mode 100644 src/features/messages/schedule-time.ts create mode 100644 src/features/messages/scheduled-draft.test.ts create mode 100644 src/features/messages/scheduled-draft.ts create mode 100644 src/features/messages/useScheduledMessages.test.tsx create mode 100644 src/features/messages/useScheduledMessages.ts create mode 100644 tests/browser/drafts-in-progress.spec.mjs create mode 100644 tests/browser/scheduled-messages.spec.mjs diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 9a7372cb23..8f5a4f4be7 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -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 @@ -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). diff --git a/docs/scheduled-messages.md b/docs/scheduled-messages.md new file mode 100644 index 0000000000..5c166eb8cc --- /dev/null +++ b/docs/scheduled-messages.md @@ -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. diff --git a/src/bundled/drafts-sent/index.tsx b/src/bundled/drafts-sent/index.tsx index eb77af90e7..5a5611c9ef 100644 --- a/src/bundled/drafts-sent/index.tsx +++ b/src/bundled/drafts-sent/index.tsx @@ -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"; @@ -20,6 +21,7 @@ export const apply: PluginModule["apply"] = (ctx) => { primary: true, companion: true, handlesNavigation: true, + route: { version: 1, validate: isDraftsSentRoute }, badge: () => , component: ({ companion, navigation }) => ( { @@ -997,10 +998,15 @@ it.each(["loading", "error", "unavailable"] as const)( ); + 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( diff --git a/src/features/drafts-sent/DraftsSent.module.css b/src/features/drafts-sent/DraftsSent.module.css index 55f3a361b4..f59113f7a2 100644 --- a/src/features/drafts-sent/DraftsSent.module.css +++ b/src/features/drafts-sent/DraftsSent.module.css @@ -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; +} diff --git a/src/features/drafts-sent/DraftsSentBadge.tsx b/src/features/drafts-sent/DraftsSentBadge.tsx index 1d8ba468ec..de96abb65d 100644 --- a/src/features/drafts-sent/DraftsSentBadge.tsx +++ b/src/features/drafts-sent/DraftsSentBadge.tsx @@ -1,3 +1,5 @@ +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"; @@ -5,7 +7,7 @@ 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 = @@ -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 ? ( + {queued > 0 && ( + + + )} ; }; -function harness() { +function harness( + scheduledMessages?: import("../relay/scheduled-messages").ScheduledMessages, +) { let list: { status: string; channels: import("../relay/contracts").ChannelSummary[]; @@ -148,6 +150,7 @@ function harness() { return made; }; const session = { + scheduledMessages, scope, relayAuthor: relay, channels: { @@ -1016,3 +1019,175 @@ it("focuses search only after an excluded preview closes", async () => { act(() => h.hideSelected()); await waitFor(() => expect(focus).toHaveBeenCalled()); }); + +it("shows queued delivery beside drafts and shares the list with the Scheduled tab", async () => { + const listeners = new Set<() => void>(); + let rows = [ + { + id: "queued", + revision: "1", + destination: { channelId: "team", name: "launch-room" }, + content: "Later", + sendAt: Date.now() + 60000, + status: "scheduled" as "scheduled" | "sent", + }, + ]; + const service = { + list: vi.fn(async () => rows), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as unknown as import("../relay/scheduled-messages").ScheduledMessages; + saved("draft:team", "Work in progress"); + const h = harness(service); + const Badge = h.page.badge, + Page = h.page.component; + render( + <> + + + , + ); + expect( + await screen.findByLabelText("1 scheduled message"), + ).toBeInTheDocument(); + expect(screen.getByLabelText("1 draft")).toBeInTheDocument(); + expect(service.list).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("tab", { name: /Scheduled/ })); + expect(screen.getByText("Later")).toBeVisible(); + act(() => { + rows = rows.map((item) => ({ ...item, status: "sent" })); + for (const listener of listeners) listener(); + }); + await waitFor(() => + expect(screen.queryByLabelText("1 scheduled message")).toBeNull(), + ); + expect(screen.getByLabelText("1 draft")).toBeInTheDocument(); +}); + +it("searches scheduled text and destinations without changing the total queued count", async () => { + const row = { + revision: "1", + sendAt: Date.now() + 60000, + status: "scheduled" as const, + }; + const rows = [ + { + ...row, + id: "launch", + destination: { channelId: "team", name: "launch-room" }, + content: "Review **launch** notes", + }, + { + ...row, + id: "lunch", + destination: { channelId: "dm", name: "Sam Okafor" }, + content: "Lunch tomorrow?", + }, + { + ...row, + id: "failed", + status: "failed" as const, + destination: { channelId: "team", name: "launch-room" }, + content: "Check delivery", + error: "Try again later", + }, + { + ...row, + id: "sent", + status: "sent" as const, + destination: { channelId: "team", name: "launch-room" }, + content: "Already sent", + }, + ]; + const service = { + list: vi.fn(async () => rows), + subscribe: () => () => {}, + } as unknown as import("../relay/scheduled-messages").ScheduledMessages; + const h = harness(service), + Page = h.page.component; + render(); + const tab = screen.getByRole("tab", { name: /^Scheduled/ }); + await waitFor(() => expect(tab).toHaveTextContent("Scheduled3")); + fireEvent.click(tab); + const search = screen.getByRole("searchbox", { + name: "Search scheduled messages", + }); + const list = screen.getByRole("list", { name: "Scheduled messages" }); + expect(within(list).getAllByRole("listitem")).toHaveLength(3); + fireEvent.change(search, { target: { value: "LAUNCH notes" } }); + expect(within(list).getAllByRole("listitem")).toHaveLength(1); + expect(within(list).getByText("Review **launch** notes")).toBeVisible(); + fireEvent.change(search, { target: { value: "sam" } }); + expect(within(list).getAllByRole("listitem")).toHaveLength(1); + expect(within(list).getByText("Lunch tomorrow?")).toBeVisible(); + fireEvent.change(search, { target: { value: "missing" } }); + expect( + screen.getByText("No scheduled messages match “missing”."), + ).toBeVisible(); + expect(within(list).queryByRole("listitem")).toBeNull(); + expect(tab).toHaveTextContent("Scheduled3"); + fireEvent.click( + screen.getByRole("button", { name: "Clear search scheduled messages" }), + ); + expect(within(list).getAllByRole("listitem")).toHaveLength(3); + expect(service.list).toHaveBeenCalledTimes(1); +}); + +it("keeps search labels, queries and results scoped to their own tab", async () => { + saved("draft:team", "Finish launch notes"); + const service = { + list: vi.fn(async () => [ + { + id: "later", + revision: "1", + destination: { channelId: "team", name: "launch-room" }, + content: "Tomorrow's update", + sendAt: Date.now() + 60000, + status: "scheduled" as const, + }, + ]), + subscribe: () => () => {}, + } as unknown as import("../relay/scheduled-messages").ScheduledMessages; + const h = harness(service), + Page = h.page.component; + render(); + const draftsSearch = screen.getByRole("searchbox", { name: "Search drafts" }); + expect(draftsSearch).toHaveAttribute("placeholder", "Search drafts"); + fireEvent.change(draftsSearch, { target: { value: "missing draft" } }); + expect(screen.getByText("No drafts match “missing draft”.")).toBeVisible(); + fireEvent.click(screen.getByRole("tab", { name: "Sent" })); + const sentSearch = screen.getByRole("searchbox", { + name: "Search sent messages", + }); + expect(sentSearch).toHaveValue(""); + expect( + await screen.findByRole("button", { + name: /View message.*Recently shipped/, + }), + ).toBeVisible(); + fireEvent.change(sentSearch, { target: { value: "missing sent" } }); + fireEvent.click(screen.getByRole("tab", { name: /^Scheduled/ })); + const scheduledSearch = screen.getByRole("searchbox", { + name: "Search scheduled messages", + }); + expect(scheduledSearch).toHaveValue(""); + expect(await screen.findByText("Tomorrow's update")).toBeVisible(); + fireEvent.change(scheduledSearch, { target: { value: "tomorrow" } }); + fireEvent.click(screen.getByRole("tab", { name: /^Drafts/ })); + expect(screen.getByRole("searchbox", { name: "Search drafts" })).toHaveValue( + "missing draft", + ); + fireEvent.click(screen.getByRole("button", { name: "Clear search drafts" })); + expect(screen.getByText("Finish launch notes")).toBeVisible(); + fireEvent.click(screen.getByRole("tab", { name: "Sent" })); + expect( + screen.getByRole("searchbox", { name: "Search sent messages" }), + ).toHaveValue("missing sent"); + fireEvent.click(screen.getByRole("tab", { name: /^Scheduled/ })); + expect( + screen.getByRole("searchbox", { name: "Search scheduled messages" }), + ).toHaveValue("tomorrow"); + expect(screen.getByText("Tomorrow's update")).toBeVisible(); +}); diff --git a/src/features/drafts-sent/DraftsSentPage.tsx b/src/features/drafts-sent/DraftsSentPage.tsx index 025bc324b0..c58788d241 100644 --- a/src/features/drafts-sent/DraftsSentPage.tsx +++ b/src/features/drafts-sent/DraftsSentPage.tsx @@ -1,3 +1,6 @@ +import { ScheduledMessagesList } from "../messages/ScheduledMessagesList"; +import { useScheduledMessages } from "../messages/useScheduledMessages"; +import { draftsSentRoute, type DraftsSentTab } from "./route"; import { DraftPreview } from "./DraftPreview"; import type { ConversationHostServices } from "../conversation/ConversationHost"; import type { ConversationExtensions } from "../conversation/contracts"; @@ -142,6 +145,8 @@ export function DraftsSentPage({ navigator={navigator} host={{ relay, navigation: navigator, panels }} extensions={extensions} + requestedTab={draftsSentRoute(navigation?.target)} + requestKey={navigation?.entryId} /> ) : (
@@ -181,6 +186,8 @@ function ScopedPage({ navigator, host, extensions, + requestedTab, + requestKey, }: { session: RelaySession; viewer: string; @@ -188,6 +195,8 @@ function ScopedPage({ navigator: Navigation; host: ConversationHostServices; extensions?: ConversationExtensions | undefined; + requestedTab?: DraftsSentTab | undefined; + requestKey?: string | undefined; }) { type Visit = { entry: Entry; @@ -225,12 +234,34 @@ function ScopedPage({ ); const resolveName = useIdentityNames(session.names); const now = useNow(); - const [search, setSearch] = useState(""); - const [view, setView] = useState<"drafts" | "sent">(() => - readView(session.scope, "drafts-sent-tab", "drafts") === "sent" + const [searches, setSearches] = useState>({ + drafts: "", + scheduled: "", + sent: "", + }); + const [view, setView] = useState(() => { + const saved = + requestedTab ?? + readView(session.scope, "drafts-sent-tab", "drafts"); + return saved === "sent" ? "sent" - : "drafts", - ); + : saved === "scheduled" && session.scheduledMessages + ? "scheduled" + : "drafts"; + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: A new navigation visit reapplies the requested tab even when its value is unchanged. + useEffect(() => { + if (requestedTab) + setView( + requestedTab === "scheduled" && !session.scheduledMessages + ? "drafts" + : requestedTab, + ); + }, [requestKey, requestedTab, session]); + const search = searches[view]; + const setSearch = (value: string) => + setSearches((current) => ({ ...current, [view]: value })); + const scheduled = useScheduledMessages(session.scheduledMessages); const [notice, setNotice] = useState(); const searchInput = useRef(null); const { sent, refresh: refreshSent } = useSent(session, viewer); @@ -671,6 +702,7 @@ function ScopedPage({ else groups.push({ label, rows: [item] }); } const refresh = () => { + scheduled.refresh(); session.channels.refreshList?.(); refreshSent.current(); context.refresh.current(); @@ -699,14 +731,19 @@ function ScopedPage({ /> ) : null; + const searchLabel = { + drafts: "Search drafts", + scheduled: "Search scheduled messages", + sent: "Search sent messages", + }[view]; const searchField = (
@@ -743,6 +780,26 @@ function ScopedPage({ ) : undefined, }, + ...(session.scheduledMessages + ? [ + { + value: "scheduled" as const, + label: "Scheduled", + panelId: "scheduled-view", + trailing: scheduled.items.filter( + (item) => item.status !== "sent", + ).length ? ( + + { + scheduled.items.filter( + (item) => item.status !== "sent", + ).length + } + + ) : undefined, + }, + ] + : []), { value: "sent", label: "Sent", panelId: "sent-view" }, ]} onValueChange={(next) => { @@ -763,6 +820,27 @@ function ScopedPage({ {announcement}

+
; + return ( + Object.keys(record).length === 1 && + ["drafts", "scheduled", "sent"].includes(String(record.tab)) + ); +} +export function draftsSentRoute( + target: OpenTarget | undefined, +): DraftsSentTab | undefined { + if ( + target?.kind !== "page" || + target.route?.version !== 1 || + !isDraftsSentRoute(target.route.params) + ) + return; + return (target.route.params as { tab: DraftsSentTab }).tab; +} +export const scheduledMessagesLink = + "buzz://plugin?id=buzz.drafts-and-sent&page=drafts-sent&route.tab=scheduled"; diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 09adb4e312..fe6ade04d2 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -16068,3 +16068,206 @@ describe("oversized paste feedback", () => { ).toBeVisible(); }); }); + +it("schedules from the main composer and clears only after relay acceptance", async () => { + const h = mount(); + let accept!: () => void; + const create = vi.fn( + (_input: unknown) => + new Promise((resolve) => { + accept = resolve; + }), + ); + h.retarget({ + session: { + ...h.session, + scheduledMessages: { create } as unknown as NonNullable< + RelaySession["scheduledMessages"] + >, + }, + }); + h.fill("Send tomorrow morning"); + const editor = h.input(); + fireEvent.click(screen.getByRole("button", { name: "Schedule message" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + if (screen.queryByRole("menuitem", { name: "Custom time" })) + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + const dialog = await screen.findByRole("dialog", { + name: "Schedule message", + }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Schedule message" }), + ); + await waitFor(() => expect(create).toHaveBeenCalledOnce()); + expect(create.mock.calls[0]?.[0]).toMatchObject({ + content: "Send tomorrow morning", + destination: { channelId: "channel" }, + }); + expect(editor).toHaveValue("Send tomorrow morning"); + expect(h.messages.send).not.toHaveBeenCalled(); + await act(async () => accept()); + await waitFor(() => expect(editor).toHaveValue("")); + expect(h.onSend).not.toHaveBeenCalled(); +}); + +it("keeps a scheduled draft when the relay refuses it", async () => { + const h = mount(); + const create = vi.fn(async () => { + throw new Error("Queue unavailable"); + }); + h.retarget({ + session: { + ...h.session, + scheduledMessages: { create } as unknown as NonNullable< + RelaySession["scheduledMessages"] + >, + }, + }); + h.fill("Keep this draft"); + const editor = h.input(); + fireEvent.click(screen.getByRole("button", { name: "Schedule message" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + if (screen.queryByRole("menuitem", { name: "Custom time" })) + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + const dialog = await screen.findByRole("dialog", { + name: "Schedule message", + }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Schedule message" }), + ); + await waitFor(() => + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Queue unavailable", + ), + ); + expect(editor).toHaveValue("Keep this draft"); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it("keeps a newer draft when an older scheduled save completes", async () => { + const h = mount(); + let accept!: () => void; + const create = vi.fn( + (_input: unknown) => + new Promise((resolve) => { + accept = resolve; + }), + ); + h.retarget({ + session: { + ...h.session, + scheduledMessages: { create } as unknown as NonNullable< + RelaySession["scheduledMessages"] + >, + }, + }); + h.fill("Original draft"); + const editor = h.input(); + fireEvent.click(screen.getByRole("button", { name: "Schedule message" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + if (screen.queryByRole("menuitem", { name: "Custom time" })) + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + const dialog = await screen.findByRole("dialog", { + name: "Schedule message", + }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Schedule message" }), + ); + await waitFor(() => expect(create).toHaveBeenCalledOnce()); + act(() => { + editor.value = "Newer work"; + }); + fireEvent.input(editor); + await act(async () => accept()); + await waitFor(() => expect(editor).toHaveValue("Newer work")); +}); + +it("refuses scheduling a slash command without executing it or clearing its draft", async () => { + const onCommand = vi.fn(async () => true), + create = vi.fn(async () => {}); + const h = mount({ onCommand }); + h.retarget({ + session: { + ...h.session, + scheduledMessages: { create } as unknown as NonNullable< + RelaySession["scheduledMessages"] + >, + }, + }); + h.fill("/leave"); + const editor = h.input(); + fireEvent.click(screen.getByRole("button", { name: "Schedule message" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + if (screen.queryByRole("menuitem", { name: "Custom time" })) + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + const dialog = await screen.findByRole("dialog", { + name: "Schedule message", + }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Schedule message" }), + ); + await waitFor(() => + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Commands cannot be scheduled", + ), + ); + expect(editor).toHaveValue("/leave"); + expect(onCommand).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); +}); + +it("keeps an attachment added while an older scheduled save is pending", async () => { + writeView("scope", "draft:channel", "Original text"); + const h = mount(); + let accept!: () => void; + const create = vi.fn( + () => + new Promise((resolve) => { + accept = resolve; + }), + ); + const upload = vi.fn(() => new Promise(() => {})); + h.retarget({ + session: { + ...h.session, + attachments: { upload }, + scheduledMessages: { create } as unknown as NonNullable< + RelaySession["scheduledMessages"] + >, + }, + }); + h.fill("Original text"); + fireEvent.click(screen.getByRole("button", { name: "Schedule message" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + if (screen.queryByRole("menuitem", { name: "Custom time" })) + fireEvent.click(screen.getByRole("menuitem", { name: "Custom time" })); + const dialog = await screen.findByRole("dialog", { + name: "Schedule message", + }); + fireEvent.click( + within(dialog).getByRole("button", { name: "Schedule message" }), + ); + await waitFor(() => expect(create).toHaveBeenCalledOnce()); + h.retarget({ channelId: "other" }); + h.retarget({ channelId: "channel" }); + // The remounted composer owns the same tab-local attachment store. + fireEvent.change(screen.getByLabelText("Choose attachments"), { + target: { + files: [new File(["new work"], "newer.txt", { type: "text/plain" })], + }, + }); + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + const newerText = h.input().value; + await act(async () => accept()); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Schedule message" }), + ).toBeNull(), + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Remove newer.txt" }), + ).toBeVisible(), + ); + expect(h.input()).toHaveValue(newerText); +}); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 1c1d2760b5..4a18d5774a 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,3 +1,7 @@ +import { ScheduleMessageDialog } from "./ScheduleMessageDialog"; +import { ScheduleMessageMenu } from "./ScheduleMessageMenu"; +import { scheduledLabel } from "./schedule-time"; +import scheduledStyles from "./ScheduledMessages.module.css"; import { soloModelPicksKey, parseSoloModelPicks, @@ -80,6 +84,7 @@ import { } from "../solo/people"; import { ArrowUpIcon, + ClockIcon, ChatCircleIcon, LockIcon, PaperclipIcon, @@ -1272,6 +1277,8 @@ function Composer({ // Notices float above the composer, not over Send. useToastClearance(form); const notify = useToastNotification(); + const [scheduleOpen, setScheduleOpen] = useState(false); + const [scheduledNotice, setScheduledNotice] = useState(); const handOffFocus = useEffectEvent(() => { if (!form.current?.contains(document.activeElement)) return; const text = input.current?.value ?? ""; @@ -2459,7 +2466,19 @@ function Composer({ } return recipients; } - async function send() { + async function send(scheduledAt?: number) { + if (scheduledAt !== undefined && (editing.target || submission)) { + setError("Finish the current action before scheduling a message."); + return false; + } + if ( + scheduledAt !== undefined && + (!session.scheduledMessages || + !Number.isFinite(scheduledAt) || + scheduledAt <= Date.now()) + ) + return false; + if (!permitted.current || voice.isCapturing()) return; if (editing.target) { if (editDisabled || editing.locked) return; @@ -2671,6 +2690,10 @@ function Composer({ agentKeys, ); try { + if (scheduledAt !== undefined && command) + throw new Error( + "Commands cannot be scheduled. Write a message or send the command now.", + ); if (command && onCommand) { const handled = onCommand(command, attempt.signal); if (handled !== false) { @@ -2927,6 +2950,28 @@ function Composer({ uploaded: readonly UploadedAttachment[], validated = false, ) => { + if (scheduledAt !== undefined) { + if (!session.scheduledMessages) + throw new Error("Scheduled delivery is unavailable"); + return session.scheduledMessages + .create({ + destination: { + channelId, + name: channelName, + ...(threadRootId ? { threadRootId } : {}), + }, + content, + sendAt: scheduledAt, + mentions: recipients, + attachments: uploaded, + ...(replyParentId ? { parentId: replyParentId } : {}), + references, + picks: sentPicks, + ...(mediaTargetUrl ? { mediaTargetUrl } : {}), + }) + .then(() => ""); + } + if (compactingNow(recipients)) throw new Error("Wait for compaction to finish, or interrupt it."); if (!queueFollowUp || !host) return publishNow(uploaded, session); @@ -2960,10 +3005,23 @@ function Composer({ ); }; let id: string | undefined; - if (capturedAttachments.length && !fullSession(session)) { + if ( + capturedAttachments.length && + (scheduledAt !== undefined || !fullSession(session)) + ) { setSending(true); const uploaded = await attachments.store.prepareForSend(attempt.signal); attempt.signal.throwIfAborted(); + if ( + scheduledAt !== undefined && + (valueRef.current !== captured || + viewRevision(scope, draftKey) !== savedRevision || + !sameAttachmentSelection( + attachments.store.snapshot(), + capturedAttachments, + )) + ) + return false; id = await publish(uploaded); } else if (capturedAttachments.length) { // The revision acceptance saves; remembered agents are not a draft. @@ -3065,6 +3123,21 @@ function Composer({ if (typeof result !== "string") setSending(true); id = typeof result === "string" ? result : await result; } + if (scheduledAt !== undefined && live.current) + setScheduledNotice(scheduledAt); + if ( + scheduledAt !== undefined && + (valueRef.current !== captured || + viewRevision(scope, draftKey) !== savedRevision || + !sameAttachmentSelection( + attachmentDraft(session, recoveryKey, channelId).snapshot(), + capturedAttachments, + )) + ) { + if (live.current) + notify("Message scheduled. Your newer draft was kept.", "success"); + return true; + } // Nothing is saved for the remembered agents: this composer shows them // in memory, and any composer here, on any device, seeds them from the // sent message once it lands (see seedMentions). @@ -3110,9 +3183,12 @@ function Composer({ if (latest) seedApplied.current = seedKey(latest); if (id) onSend?.(id); finishDraft(pending); + if (scheduledAt !== undefined) notify("Message scheduled", "success"); + return true; } catch (reason) { if (live.current && !attempt.signal.aborted) setError(reason instanceof Error ? reason.message : String(reason)); + return false; } finally { admission.current = false; if (sendAttempt.current === attempt) { @@ -3124,6 +3200,54 @@ function Composer({ } } } + const sendButton = ( + } + /> + ); const readingOnly = !outbox?.supports(9) && !cached; // The attachments section animates out over 150 ms; the form's gap and // padding (data-attachments) collapse when it has, not when the list empties. @@ -3239,6 +3363,30 @@ function Composer({ return ( {context} + {scheduledNotice !== undefined && ( +
+
+ )} {active && bringing.dialog} {active && nonmembers.dialog} {active && oldAgents.dialog} @@ -3691,56 +3839,39 @@ function Composer({ place={sessionPlace} /> )} - } - /> + {session.scheduledMessages && !editing.target && !submission ? ( + setScheduleOpen(true)} + schedule={send} + > + {sendButton} + + ) : ( + sendButton + )} + {scheduleOpen && ( + setScheduleOpen(false)} + schedule={send} + error={error} + /> + )}
{!editing.target && compacting && ( diff --git a/src/features/messages/ScheduleDateTime.tsx b/src/features/messages/ScheduleDateTime.tsx new file mode 100644 index 0000000000..c32c9586c6 --- /dev/null +++ b/src/features/messages/ScheduleDateTime.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { CalendarIcon, ClockIcon } from "../../shared/design-system/icons"; +import { Calendar } from "../../shared/design-system/ui/Calendar"; +import { Combobox } from "../../shared/design-system/ui/Combobox"; +import { FieldButton } from "../../shared/design-system/ui/FieldButton"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, +} from "../../shared/design-system/ui/Popover"; +import { + clockLabel, + dateLabel, + localScheduleTime, + parseScheduleTime, + quarterHours, + timeOfDay, +} from "./schedule-time"; +import styles from "./ScheduledMessages.module.css"; + +export function ScheduleDateTime({ + value, + onChange, + disabled = false, +}: { + value: number; + onChange(time: number): void; + disabled?: boolean; +}) { + const [day, setDay] = useState(() => localScheduleTime(value).slice(0, 10)); + const [time, setTime] = useState(() => clockLabel(value)); + const [clock, setClock] = useState(() => + localScheduleTime(value).slice(11, 16), + ); + const [calendar, setCalendar] = useState(false); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const last = new Date(Date.now() + 120 * 86_400_000); + function change(nextDay: string, nextClock: string | undefined) { + onChange(nextClock ? parseScheduleTime(`${nextDay}T${nextClock}`) : NaN); + } + return ( +
+ + } + > + + + + + { + const next = localScheduleTime(date.getTime()).slice(0, 10); + setDay(next); + change(next, clock); + setCalendar(false); + }} + /> + + + option.value === clock) ?? null} + inputValue={time} + onInputValueChange={(text, details) => { + // Keep custom typed times when the picker resets its selection on blur. + if (details.reason !== "input-change") return; + const parsed = + timeOfDay(text) ?? + quarterHours.find((option) => option.label === text)?.value; + setTime(text); + setClock(parsed); + change(day, parsed); + }} + onValueChange={(option) => { + if (option) { + setTime(option.label); + setClock(option.value); + change(day, option.value); + } + }} + > + +
+ ); +} diff --git a/src/features/messages/ScheduleMessageDialog.tsx b/src/features/messages/ScheduleMessageDialog.tsx new file mode 100644 index 0000000000..50620da643 --- /dev/null +++ b/src/features/messages/ScheduleMessageDialog.tsx @@ -0,0 +1,104 @@ +import { useId, useRef, useState } from "react"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { Button } from "../../shared/design-system/ui/Button"; +import { ScheduleDateTime } from "./ScheduleDateTime"; +import { scheduleError } from "./schedule-time"; +export { localScheduleTime, parseScheduleTime } from "./schedule-time"; + +export function ScheduleMessageDialog({ + close, + schedule, + error, + initialTime, + title = "Schedule message", +}: { + close(): void; + schedule(time: number): Promise; + error?: string | undefined; + initialTime?: number; + title?: string; +}) { + const [time, setTime] = useState( + () => initialTime ?? Math.ceil((Date.now() + 60_000) / 900_000) * 900_000, + ); + const [busy, setBusy] = useState(false), + [failed, setFailed] = useState(false); + const pending = useRef(false), + form = useId(); + const invalid = scheduleError(time); + const timezone = + new Intl.DateTimeFormat(undefined, { timeZoneName: "longGeneric" }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName")?.value ?? + Intl.DateTimeFormat().resolvedOptions().timeZone; + async function submit() { + if (scheduleError(time) || pending.current) return; + pending.current = true; + setBusy(true); + setFailed(false); + try { + if (await schedule(time)) close(); + else setFailed(true); + } catch { + setFailed(true); + } finally { + pending.current = false; + setBusy(false); + } + } + return ( + { + if (!open) close(); + }} + actions={ + <> + + + + } + > +
{ + event.preventDefault(); + event.stopPropagation(); + void submit(); + }} + > + + {invalid && ( +

+ {invalid} +

+ )} + {failed && ( +

+ {error || + "Could not schedule this message. Your draft is still here."} +

+ )} + +
+ ); +} diff --git a/src/features/messages/ScheduleMessageMenu.tsx b/src/features/messages/ScheduleMessageMenu.tsx new file mode 100644 index 0000000000..d527eb7b4c --- /dev/null +++ b/src/features/messages/ScheduleMessageMenu.tsx @@ -0,0 +1,84 @@ +import { useState, type ReactNode } from "react"; +import { CaretDownIcon } from "../../shared/design-system/icons"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { + MenuRoot, + MenuTrigger, + MenuPopup, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuSeparator, +} from "../../shared/design-system/ui/Menu"; +import { schedulePresets } from "./schedule-time"; +export function ScheduleMessageMenu({ + children, + disabled, + custom, + schedule, +}: { + children: ReactNode; + disabled: boolean; + custom(): void; + schedule(time: number): Promise; +}) { + const [handoff, setHandoff] = useState(false); + return ( + // biome-ignore lint/a11y/useSemanticElements: This groups send actions, not form fields. +
+ {children} + { + if (open) setHandoff(false); + }} + > + } + /> + } + /> + + + Schedule message + {schedulePresets().map((preset) => ( + void schedule(preset.time)} + > + {preset.label} + + ))} + + + { + setHandoff(true); + custom(); + }} + > + Custom time + + + +
+ ); +} diff --git a/src/features/messages/ScheduledMessages.module.css b/src/features/messages/ScheduledMessages.module.css new file mode 100644 index 0000000000..8c8c0b0d82 --- /dev/null +++ b/src/features/messages/ScheduledMessages.module.css @@ -0,0 +1,87 @@ +.dateTime { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: var(--space-3); + align-items: start; +} +.fieldValue { + white-space: nowrap; + display: inline-flex; + gap: var(--space-2); + align-items: center; +} +.confirmation { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + padding: var(--space-2) var(--space-control-inset); + color: var(--text-subtle); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); +} +.link { + color: var(--text-link); + text-decoration: underline; + cursor: pointer; + background: none; + border: 0; + padding: 0; + font: inherit; +} +.list { + list-style: none; + padding: var(--space-3) var(--space-row-gap); + margin: 0; + overflow-y: auto; + min-height: 0; +} +.row { + position: relative; + border: 1px solid var(--border-standard); + border-radius: var(--radius-row); + padding: var(--space-4); + margin-block-end: var(--space-3); +} +.heading { + display: flex; + gap: var(--space-3); + align-items: flex-start; +} +.body { + min-width: 0; + flex: 1; +} +.meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + color: var(--text-metadata); +} +.content { + white-space: pre-wrap; + overflow-wrap: anywhere; + margin-block: var(--space-1) 0; +} +.actions { + margin: 0; + display: flex; + flex: none; + align-items: center; + gap: var(--space-1); + border: 1px solid var(--border-standard); + border-radius: var(--radius-control); + padding: var(--space-1); + background: var(--surface-panel); +} +.notice { + padding: var(--space-3) var(--space-panel-inset); +} +@container (max-width: 30rem) { + .heading { + flex-wrap: wrap; + } + .actions { + margin-inline-start: auto; + } +} diff --git a/src/features/messages/ScheduledMessagesList.tsx b/src/features/messages/ScheduledMessagesList.tsx new file mode 100644 index 0000000000..796a2ab63d --- /dev/null +++ b/src/features/messages/ScheduledMessagesList.tsx @@ -0,0 +1,337 @@ +import { useRef, useState } from "react"; +import type { RelaySession } from "../relay/session"; +import type { ScheduledMessage } from "../relay/scheduled-messages"; +import { + PencilSimpleIcon, + ClockIcon, + SendIcon, + DotsThreeVerticalIcon, +} from "../../shared/design-system/icons"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { Button } from "../../shared/design-system/ui/Button"; +import { EmptyState } from "../../shared/design-system/ui/EmptyState"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; +import { + MenuRoot, + MenuTrigger, + MenuPopup, + MenuItem, +} from "../../shared/design-system/ui/Menu"; +import { avatarMedia } from "../../shared/avatar-source"; +import { ScheduleMessageDialog } from "./ScheduleMessageDialog"; +import { scheduledLabel } from "./schedule-time"; +import { matches, preview } from "../drafts-sent/model"; +import { cancelScheduledToDraft } from "./scheduled-draft"; +import styles from "./ScheduledMessages.module.css"; + +type Action = { + item: ScheduledMessage; + kind: "edit" | "reschedule" | "send" | "delete" | "draft"; +}; +export function ScheduledMessagesList({ + session, + items, + loading, + error, + refresh, + onDraft, + search = "", +}: { + session: RelaySession; + items: readonly ScheduledMessage[]; + loading: boolean; + error?: string | undefined; + refresh(): void; + onDraft(): void; + search?: string; +}) { + const [action, setAction] = useState(), + [content, setContent] = useState(""), + [busy, setBusy] = useState(false), + [failure, setFailure] = useState(); + const pending = useRef(false), + cancel = useRef(null); + const service = session.scheduledMessages, + profile = session.viewer + ? session.profiles.snapshot().get(session.viewer) + : undefined; + const rows = items + .filter( + (item) => + item.status !== "sent" && + matches( + preview(item.content, "Attachment"), + item.destination.name, + search, + ), + ) + .sort((a, b) => a.sendAt - b.sendAt); + function choose(item: ScheduledMessage, kind: Action["kind"]) { + setFailure(undefined); + setContent(item.content); + setAction({ item, kind }); + } + async function run(time?: number) { + if (!action || !service || pending.current) return false; + pending.current = true; + setBusy(true); + setFailure(undefined); + try { + const { item, kind } = action; + if (kind === "edit" || kind === "reschedule") + await service.update(item.id, item.revision, { + destination: item.destination, + content: kind === "edit" ? content : item.content, + sendAt: time ?? item.sendAt, + }); + else if (kind === "send") await service.sendNow(item.id, item.revision); + else if (kind === "draft") { + await cancelScheduledToDraft(session, item); + onDraft(); + } else await service.cancel(item.id, item.revision); + setAction(undefined); + refresh(); + return true; + } catch (cause) { + setFailure( + cause instanceof Error + ? cause.message + : "Could not update this message.", + ); + return false; + } finally { + pending.current = false; + setBusy(false); + } + } + return ( + <> + {(error || (!action && failure)) && ( +

+ {error ?? failure}{" "} + +

+ )} + {!service ? ( + } + title="Scheduling is unavailable" + description="This community does not support scheduled delivery yet." + /> + ) : loading && !items.length ? ( +

+ Loading scheduled messages… +

+ ) : !rows.length && !error ? ( + } + title={ + search.trim() + ? `No scheduled messages match “${search.trim()}”.` + : "No scheduled messages" + } + description={ + search.trim() + ? "Try a different message or conversation name." + : "Schedule a message from the arrow beside Send. It will wait here until it’s time." + } + /> + ) : null} +
    + {rows.map((item) => ( +
  • +
    + +
    +
    + + {profile?.name ?? "You"} + + + to {item.destination.name} + {item.destination.threadRootId ? " · Thread" : ""} + + +
    +

    {item.content}

    + {item.error && ( +

    {item.error}

    + )} +
    +
    + } + disabled={busy} + onClick={() => choose(item, "edit")} + /> + } + disabled={busy} + onClick={() => choose(item, "reschedule")} + /> + } + disabled={busy} + onClick={() => choose(item, "send")} + /> + + } + /> + } + /> + + choose(item, "draft")}> + Cancel schedule and save to drafts + + choose(item, "delete")} + > + Delete message + + + +
    +
    +
  • + ))} +
+ {action?.kind === "reschedule" && ( + setAction(undefined)} + schedule={run} + error={failure} + /> + )} + {action?.kind === "edit" && ( + { + if (!open) setAction(undefined); + }} + actions={ + <> + + + + } + > + +