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/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-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/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={ + <> + + + + } + > + +