feat: add live Discord sharing functionality with dynamic habit progress images
- Implemented live card image generation for user habits using canvas. - Created API endpoints for starting and managing live Discord sharing sessions. - Added tests for live Discord sharing to ensure functionality and reliability. - Introduced CSS styles for the live Discord sharing interface. - Enhanced error handling and state management for Discord message updates.
This commit is contained in:
@@ -405,3 +405,11 @@ The disposable `SHARE_PREVIEW=1 bun scripts/dashboard-preview.ts` mode also runs
|
||||
reminder worker against a local transport stub. Set a reminder to `14:00` (its fixed
|
||||
Europe/Belgrade time), leave a habit unfinished, and inspect delivery status in
|
||||
settings or `/__preview/reminder`. No real Discord messages are sent in this mode.
|
||||
|
||||
### Live Discord image
|
||||
|
||||
Open **Share progress → Live image · 16:9** to preview/download a 1920×1080 PNG with every active habit and a rolling 30-day history. **Share live image** posts the attachment through the configured bot; later changes replace the attachment on that same Discord message after a two-second trailing debounce. There is no public live link. Display name, habit names, and progress are shared; task details remain private. Newly created active habits are included automatically.
|
||||
|
||||
Rendering and updates run on the server even when the dashboard is closed. Identical card content skips Discord edits. Message IDs, themes, and retry state persist in SQLite; known messages resume after restart. Rate-limited edits honor Discord's retry delay. When an update confirms the old message was deleted, the bot posts a replacement and saves its new message ID. Missing channels or permissions do not trigger replacement posts. An unconfirmed initial or replacement post is never automatically repeated. **Stop updates** leaves the last image in Discord; **Resume updates** edits the original message again.
|
||||
|
||||
Development uses `bun --watch` so source edits restart the process and recreate the background worker; in-process hot reload can leave its timers stalled. The worker assumes one Bun API process per database. Reverse proxies need no special streaming configuration. Run the normal migrations/build/start workflow to install the live-image table and bundle its font; the server uses `@napi-rs/canvas` for PNG rendering. The previous experimental public-link table is removed by migration 0010.
|
||||
|
||||
6
build.ts
6
build.ts
@@ -1,5 +1,5 @@
|
||||
import tailwind from "bun-plugin-tailwind";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { rm, mkdir, copyFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const outdir = path.join(process.cwd(), "dist");
|
||||
@@ -11,6 +11,7 @@ const result = await Bun.build({
|
||||
plugins: [tailwind],
|
||||
minify: true,
|
||||
target: "bun",
|
||||
external: ["@napi-rs/canvas"],
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
@@ -24,3 +25,6 @@ if (!result.success) {
|
||||
for (const output of result.outputs) {
|
||||
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
||||
await copyFile("src/assets/fonts/InstrumentSerif-Regular.ttf", path.join(outdir, "fonts/InstrumentSerif-Regular.ttf"));
|
||||
|
||||
25
bun.lock
25
bun.lock
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "mina-habits",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.8",
|
||||
"@nivo/calendar": "^0.99.0",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -89,6 +90,30 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.8", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.8", "@napi-rs/canvas-darwin-arm64": "1.0.8", "@napi-rs/canvas-darwin-x64": "1.0.8", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", "@napi-rs/canvas-linux-arm64-musl": "1.0.8", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-gnu": "1.0.8", "@napi-rs/canvas-linux-x64-musl": "1.0.8", "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", "@napi-rs/canvas-win32-x64-msvc": "1.0.8" } }, "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.8", "", { "os": "android", "cpu": "arm64" }, "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.8", "", { "os": "linux", "cpu": "arm" }, "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.8", "", { "os": "linux", "cpu": "none" }, "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ=="],
|
||||
|
||||
"@nivo/calendar": ["@nivo/calendar@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^3.0.0", "d3-scale": "^4.0.2", "d3-time": "^1.0.10", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw=="],
|
||||
|
||||
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
|
||||
|
||||
7
drizzle/0009_purple_wasp.sql
Normal file
7
drizzle/0009_purple_wasp.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE `live_cards` (
|
||||
`user_id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `live_cards_token_unique` ON `live_cards` (`token`);
|
||||
17
drizzle/0010_tidy_thena.sql
Normal file
17
drizzle/0010_tidy_thena.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `live_discord_cards` (
|
||||
`user_id` text PRIMARY KEY NOT NULL,
|
||||
`channel_id` text NOT NULL,
|
||||
`message_id` text,
|
||||
`message_url` text,
|
||||
`nonce` text NOT NULL,
|
||||
`theme` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`image_hash` text,
|
||||
`dirty` integer DEFAULT true NOT NULL,
|
||||
`error` text,
|
||||
`retry_at` integer,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `live_cards`;
|
||||
1003
drizzle/meta/0009_snapshot.json
Normal file
1003
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1066
drizzle/meta/0010_snapshot.json
Normal file
1066
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,20 @@
|
||||
"when": 1788539875559,
|
||||
"tag": "0008_reminder_cooldown",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1788596417948,
|
||||
"tag": "0009_purple_wasp",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1788597003524,
|
||||
"tag": "0010_tidy_thena",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts",
|
||||
"dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --watch src/index.ts",
|
||||
"start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts",
|
||||
"build": "bun run build.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -19,6 +19,7 @@
|
||||
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.8",
|
||||
"@nivo/calendar": "^0.99.0",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -10,7 +10,7 @@ const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Respo
|
||||
const port = probe.port!; probe.stop(true);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const env = { ...process.env, NODE_ENV: 'production', PORT: String(port), APP_ORIGIN: origin, DATABASE_PATH: databasePath,
|
||||
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
||||
DISCORD_CLIENT_ID: '', DISCORD_CLIENT_SECRET: '', DISCORD_BOT_TOKEN: '', DISCORD_SHARING_CHANNEL_ID: '', AUTH_COOKIE_SECRET: 'smoke-test-only-secret-at-least-32-characters' };
|
||||
const token = 's'.repeat(43);
|
||||
let processHandle: ReturnType<typeof Bun.spawn> | undefined;
|
||||
async function boot() {
|
||||
@@ -56,6 +56,10 @@ try {
|
||||
await request(`/habits/${t.id}`, 'PATCH', { archived: true });
|
||||
await request(`/habits/${t.id}`, 'PATCH', { archived: false });
|
||||
check((await request(`/habits/${t.id}/days/${date}`)).complete, 'Archive and restore lost task completion');
|
||||
const livePreview = await fetch(`${origin}/api/sharing/live/preview?theme=light`, { headers: { Cookie: `minabot_session=${token}` } });
|
||||
check(livePreview.ok, 'Production live PNG rendering failed');
|
||||
const png = Buffer.from(await livePreview.arrayBuffer());
|
||||
check(png.readUInt32BE(16) === 1920 && png.readUInt32BE(20) === 1080, 'Live image is not 16:9');
|
||||
await stop(); await boot();
|
||||
check((await request(`/habits/${h.id}/days/${date}`)).value === 7, 'Count did not survive restart');
|
||||
check((await request(`/charts/${c.id}/days/${date}`)).ratio === 0.5, 'Tasks or membership did not survive restart');
|
||||
|
||||
@@ -14,7 +14,7 @@ let sharedImage: Blob | null = null;
|
||||
let reminderMessage: string | null = null;
|
||||
const previewDiscord: DiscordFetch = async (url, init) => {
|
||||
if (url.endsWith("/users/@me/channels")) return Response.json({ id: "523456789012345678" });
|
||||
if (init?.method === "POST") {
|
||||
if (init?.method === "POST" || init?.method === "PATCH") {
|
||||
if (init.body instanceof FormData) sharedImage = init.body.get("files[0]") as Blob;
|
||||
else reminderMessage = JSON.parse(String(init.body)).content;
|
||||
return Response.json({ id: "423456789012345678" });
|
||||
|
||||
10
src/api.ts
10
src/api.ts
@@ -1,3 +1,4 @@
|
||||
import { createLiveDiscord } from "./sharing/live-discord";
|
||||
import { createReminderRoutes } from "./reminders/routes";
|
||||
import { errorDetails } from "./ops/monitor";
|
||||
import { createAccountRoutes } from "./account/routes";
|
||||
@@ -19,12 +20,19 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
await next();
|
||||
});
|
||||
const auth = createAuth(db, config, request, now);
|
||||
const liveSharing = createLiveDiscord(db, auth, sharingConfig, now ?? Date.now, discordRequest);
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method) && /^\/api\/(habits|me|account)(\/|$)/.test(c.req.path) && c.res.status < 400 && c.get("user"))
|
||||
liveSharing.changed(c.get("user").id);
|
||||
});
|
||||
app.get("/api/health", c => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
db.get(sql`SELECT 1`);
|
||||
if (operations.healthy && !operations.healthy()) return c.json({ status: "degraded" }, 503);
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
app.route("/api/sharing/live", liveSharing.routes);
|
||||
app.route("/api/auth", auth.routes);
|
||||
app.route("/api/reminders", createReminderRoutes(db, auth, Boolean(sharingConfig.token), now ?? Date.now));
|
||||
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
|
||||
@@ -41,5 +49,5 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
||||
(operations.log ?? (event => console.error(JSON.stringify(event))))(event);
|
||||
return c.json({ error: "Internal server error", requestId }, 500);
|
||||
});
|
||||
return app;
|
||||
return Object.assign(app, { stopLiveSharing: liveSharing.stop, flushLiveSharing: liveSharing.flush });
|
||||
}
|
||||
|
||||
65
src/components/LiveDiscordShare.tsx
Normal file
65
src/components/LiveDiscordShare.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download, Send, Pause } from "lucide-react";
|
||||
import { useTheme, ThemeControl } from "./ThemeProvider";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { habitRequest } from "../lib/dashboard";
|
||||
import type { LiveDiscordState } from "../sharing/live-discord";
|
||||
import type { DiscordConnection } from "../sharing/contracts";
|
||||
import "../../styles/live-discord.css";
|
||||
|
||||
export function LiveDiscordShare({ revision }: { revision: number }) {
|
||||
const { theme } = useTheme();
|
||||
const [state, setState] = useState<LiveDiscordState | null>(null);
|
||||
const [connection, setConnection] = useState<DiscordConnection | null>(null);
|
||||
const [preview, setPreview] = useState<{ url: string; theme: string; revision: number } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [previewError, setPreviewError] = useState('');
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError('');
|
||||
Promise.all([habitRequest<LiveDiscordState>('/sharing/live', { signal: controller.signal }), habitRequest<DiscordConnection>('/sharing/discord', { signal: controller.signal })])
|
||||
.then(([next, destination]) => { if (!controller.signal.aborted) { setState(next); setConnection(destination); } })
|
||||
.catch(e => { if (!controller.signal.aborted) setError(e.message); });
|
||||
return () => controller.abort();
|
||||
}, [attempt]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); let url: string | undefined;
|
||||
setPreviewError('');
|
||||
fetch(`/api/sharing/live/preview?theme=${theme}`, { signal: controller.signal, cache: 'no-store' })
|
||||
.then(async response => { if (!response.ok) throw new Error('Could not render your card.'); return response.blob(); })
|
||||
.then(blob => { if (!controller.signal.aborted) { url = URL.createObjectURL(blob); setPreview({ url, theme, revision }); } })
|
||||
.catch(e => { if (!controller.signal.aborted) setPreviewError(e.message); });
|
||||
return () => { controller.abort(); if (url) URL.revokeObjectURL(url); };
|
||||
}, [theme, revision, attempt]);
|
||||
async function change(method: 'POST' | 'DELETE') {
|
||||
setBusy(true); setError('');
|
||||
try { setState(await habitRequest<LiveDiscordState>('/sharing/live', { method, ...(method === 'POST' ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme }) } : {}) })); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Could not update sharing.'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const ready = preview?.theme === theme && preview.revision === revision ? preview : null;
|
||||
const active = state?.status === 'active' || state?.status === 'sending';
|
||||
return <section className="ds-live-discord" aria-label="Live Discord image">
|
||||
<div className="ds-live-discord-heading"><div><h3>All your habits. One shared image.</h3><p>1920 × 1080 · 16:9 · Last 30 days</p></div><ThemeControl disabled={busy} /></div>
|
||||
<div className="ds-live-discord-preview" aria-busy={!ready && !previewError}>
|
||||
{previewError ? <p role="alert">{previewError}</p> : ready ? <img src={ready.url} alt="16:9 Discord card containing all active habit names and their daily progress for the last 30 days." /> : <p role="status">Creating your image…</p>}
|
||||
</div>
|
||||
<p className="ds-live-discord-description">The bot posts this image in {connection?.connected ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a> : 'the configured Discord channel'}, then replaces the attachment on that same message after changes settle for two seconds. Updates continue with this page closed.</p>
|
||||
<p className="ds-live-discord-privacy">Shares your display name, avatar, all active habit names, and progress—including new habits. Task details stay private. Stopping updates leaves the last image in Discord.</p>
|
||||
<div className="ds-live-discord-footer">
|
||||
<div aria-live="polite">{state?.messageUrl && <a href={state.messageUrl} target="_blank" rel="noreferrer">View message in Discord ↗</a>}
|
||||
<p>{state?.error || (active ? 'Live sharing is on.' : state?.status === 'paused' ? 'Updates stopped.' : 'Preview your image before sharing.')}</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{connection && !connection.connected && <p>{connection.message}</p>}
|
||||
</div>
|
||||
<div className="ds-share-actions">
|
||||
<Button variant="text" disabled={busy} onClick={() => setAttempt(n => n + 1)}>Refresh preview</Button>
|
||||
{ready && <a className="ds-button ds-button--secondary" download="minabot-live-progress.png" href={ready.url}><Download size={17} />Download PNG</a>}
|
||||
{active && <Button variant="secondary" disabled={busy} onClick={() => void change('DELETE')}><Pause size={17} />Stop updates</Button>}
|
||||
<Button disabled={busy || !ready || !state || !connection?.connected || state.status === 'uncertain' || (active && state.theme === theme)} onClick={() => void change('POST')}><Send size={17} />{busy ? 'Saving…' : active ? 'Apply theme' : state?.messageUrl ? 'Resume updates' : 'Share live image'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LiveDiscordShare } from "./LiveDiscordShare";
|
||||
import { ThemeControl, useTheme } from "./ThemeProvider";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -12,6 +13,7 @@ import type { Delivery, DiscordConnection, ShareData } from "../sharing/contract
|
||||
|
||||
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
|
||||
const { theme } = useTheme();
|
||||
const [mode, setMode] = useState<"snapshot" | "live">("live");
|
||||
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
|
||||
const [thumbnail, setThumbnail] = useState(true);
|
||||
const [range, setRange] = useState("30");
|
||||
@@ -44,6 +46,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
}, [connectionAttempt]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
if (mode === "live") return () => controller.abort();
|
||||
let objectUrl: string | undefined;
|
||||
setError(""); setSendError("");
|
||||
const timer = setTimeout(async () => {
|
||||
@@ -62,7 +65,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
} catch (e) { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "Could not create your card."); }
|
||||
}, 200);
|
||||
return () => { clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [key, renderAttempt]);
|
||||
}, [key, renderAttempt, mode]);
|
||||
|
||||
async function send() {
|
||||
if (!ready || !connection?.connected || lock.current || sent) return;
|
||||
@@ -74,6 +77,12 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
}
|
||||
return (
|
||||
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what you’ll share." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
|
||||
<div className="ds-sharing-mode" role="group" aria-label="Sharing format">
|
||||
<Button variant={mode === "live" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "live"} onClick={() => setMode("live")}>Live image · 16:9</Button>
|
||||
<Button variant={mode === "snapshot" ? "primary" : "secondary"} disabled={busy} aria-pressed={mode === "snapshot"} onClick={() => setMode("snapshot")}>Snapshot</Button>
|
||||
</div>
|
||||
{mode === "live" ? <LiveDiscordShare revision={revision} /> : <>
|
||||
|
||||
<div className="ds-share-layout">
|
||||
<section className="ds-share-preview-column" aria-label="Card preview">
|
||||
<div className="ds-share-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
|
||||
@@ -116,6 +125,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
|
||||
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}>{sent?.status === "sent" ? <Check size={17} aria-hidden="true" /> : <Send size={17} aria-hidden="true" />}{sending ? "Sending…" : sent?.status === "sent" ? "Shared" : sent ? "Check Discord" : "Send to Discord"}</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,3 +84,18 @@ export const reminderDeliveries = sqliteTable('reminder_deliveries', {
|
||||
export const reminderWorkerState = sqliteTable('reminder_worker_state', {
|
||||
id: text('id').primaryKey(), blockedUntil: integer('blocked_until').notNull(),
|
||||
});
|
||||
|
||||
export const liveDiscordCards = sqliteTable('live_discord_cards', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
channelId: text('channel_id').notNull(),
|
||||
messageId: text('message_id'),
|
||||
messageUrl: text('message_url'),
|
||||
nonce: text('nonce').notNull(),
|
||||
theme: text('theme').$type<import('../lib/theme').Theme>().notNull(),
|
||||
status: text('status').$type<'active' | 'paused' | 'sending' | 'uncertain' | 'error'>().notNull(),
|
||||
imageHash: text('image_hash'),
|
||||
dirty: integer('dirty', { mode: 'boolean' }).notNull().default(true),
|
||||
error: text('error'),
|
||||
retryAt: integer('retry_at'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -22,5 +22,5 @@ export function fixture(path = ':memory:') {
|
||||
if (response.status !== status) throw new Error(`${method} ${path}: expected ${status}, got ${response.status}: ${JSON.stringify(data)}`);
|
||||
return data;
|
||||
}
|
||||
return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => sqlite.close() };
|
||||
return { sqlite, db, app, origin, request, json, setTime: (date: string) => { time = Date.parse(date); }, close: () => { app.stopLiveSharing(); sqlite.close(); } };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ if (import.meta.hot) import.meta.hot.dispose(() => reminders?.stop());
|
||||
|
||||
const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, readDiscordSharingConfig(), { healthy: () => !backups?.state.enabled || (!backups.state.failed && backups.state.lastSuccess > 0) });
|
||||
|
||||
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
|
||||
127
src/sharing/live-card-image.ts
Normal file
127
src/sharing/live-card-image.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas";
|
||||
import { existsSync } from "node:fs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../auth";
|
||||
import { users } from "../db/schema";
|
||||
import { HabitService, ApiError } from "../habits/service";
|
||||
import { addDays } from "../habits/calendar";
|
||||
import { themePalette, type Theme } from "../lib/theme";
|
||||
import { cardDate } from "../lib/progress-card";
|
||||
import { calendarTimeline } from "../components/design-system/calendar-model";
|
||||
import { themedHabitShade } from "../lib/habit-palette";
|
||||
|
||||
const sourceFont = new URL('../assets/fonts/InstrumentSerif-Regular.ttf', import.meta.url);
|
||||
const builtFont = new URL('./fonts/InstrumentSerif-Regular.ttf', import.meta.url);
|
||||
GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, 'Instrument Serif');
|
||||
|
||||
export function liveCardData(db: AppDatabase, userId: string, now: number) {
|
||||
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
||||
if (!user) throw new ApiError(404, "Account unavailable.");
|
||||
const service = new HabitService(db, { ...user, displayName: user.globalName ?? user.username, avatarUrl: null }, now);
|
||||
service.sync();
|
||||
const from = addDays(service.today, -29);
|
||||
const avatarUrl = /^\d{17,20}$/.test(user.discordId) && /^(?:a_)?[a-f0-9]{32}$/i.test(user.avatarHash ?? '')
|
||||
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.png?size=128` : null;
|
||||
return { name: user.globalName ?? user.username, avatarUrl, from, to: service.today, habits: service.list().map(h => {
|
||||
const chart = service.calendar([h.id], from, service.today, service.settings(h.id), false);
|
||||
return { name: h.name, color: chart.settings.mainColor,
|
||||
completed: chart.days.filter(d => d.completed > 0).length, scheduled: chart.days.filter(d => d.due > 0).length,
|
||||
days: chart.days.map(d => ({ date: d.date, color: d.color, due: d.due > 0, ratio: d.ratio })) };
|
||||
}) };
|
||||
}
|
||||
export type LiveImageData = ReturnType<typeof liveCardData>;
|
||||
|
||||
export function liveCardLayout(count: number) {
|
||||
const columns = count <= 8 ? 2 : Math.ceil(Math.sqrt(count / 2));
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
return { columns, rows, width: 1920, height: 1080, panelWidth: (1792 - (columns - 1) * 32) / columns, panelHeight: 650 / rows };
|
||||
}
|
||||
|
||||
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */
|
||||
export async function renderLiveCard(data: LiveImageData, theme: Theme) {
|
||||
const layout = liveCardLayout(data.habits.length);
|
||||
const canvas = createCanvas(layout.width, layout.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const colors = themePalette[theme];
|
||||
let avatar: Awaited<ReturnType<typeof loadImage>> | null = null;
|
||||
if (data.avatarUrl) {
|
||||
try {
|
||||
const response = await fetch(data.avatarUrl, { redirect: 'error', signal: AbortSignal.timeout(3000) });
|
||||
if (response.ok) avatar = await loadImage(Buffer.from(await response.arrayBuffer()));
|
||||
} catch { /* Match the snapshot card's neutral avatar fallback. */ }
|
||||
}
|
||||
const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => {
|
||||
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
|
||||
};
|
||||
const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => {
|
||||
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : 'sans-serif'}`; ctx.fillStyle = color;
|
||||
const chars = Array.from(value); let fitted = value;
|
||||
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = chars.join('') + '…'; }
|
||||
ctx.fillText(fitted, x, y);
|
||||
};
|
||||
rect(0, 0, 1920, 1080, colors.paper);
|
||||
rect(0, 0, 1920, 8, data.habits[0]?.color ?? '#285d49');
|
||||
// The same profile / wordmark hierarchy as the snapshot card.
|
||||
ctx.save(); ctx.beginPath(); ctx.arc(106, 94, 42, 0, Math.PI * 2); ctx.clip();
|
||||
rect(64, 52, 84, 84, colors.avatar);
|
||||
if (avatar) ctx.drawImage(avatar, 64, 52, 84, 84);
|
||||
else {
|
||||
ctx.fillStyle = colors.muted; ctx.beginPath(); ctx.arc(106, 85, 14, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(106, 130, 30, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
text(data.name, 172, 109, 44, 1140, colors.ink, false, '600');
|
||||
ctx.textAlign = 'right'; text('Mina Habits.', 1856, 112, 64, 470, colors.ink, true); ctx.textAlign = 'left';
|
||||
text('A little, every day.', 64, 228, 96, 1792, colors.ink, true);
|
||||
text(`${cardDate(data.from)} – ${cardDate(data.to)}`, 64, 289, 36, 1600, colors.muted);
|
||||
data.habits.forEach((habit, index) => {
|
||||
const x = 64 + index % layout.columns * (layout.panelWidth + 32);
|
||||
const y = 340 + Math.floor(index / layout.columns) * layout.panelHeight;
|
||||
const w = layout.panelWidth, h = layout.panelHeight;
|
||||
const compact = h < 220;
|
||||
const fontSize = Math.min(compact ? 26 : 38, h * (compact ? .20 : .105), w * .05);
|
||||
const labelSize = Math.min(28, h * .067, w * .04);
|
||||
rect(x, y, w, 1, colors.rule);
|
||||
text(habit.name, x, y + fontSize + h * .035, fontSize, w, colors.ink, false, '600');
|
||||
if (compact) {
|
||||
text(`${habit.completed} / ${habit.scheduled}`, x, y + h * .56, Math.min(40, h * .30), w * .46, colors.ink, false, '600');
|
||||
text('check-ins complete', x, y + h * .75, Math.min(20, h * .14), w * .46, colors.muted);
|
||||
} else {
|
||||
text(`${habit.completed}`, x, y + h * .43, Math.min(104, h * .25), w * .43, colors.ink, false, '600');
|
||||
text('check-ins complete', x, y + h * .54, labelSize, w * .46, colors.ink, false, '500');
|
||||
text(`of ${habit.scheduled} scheduled`, x, y + h * .64, labelSize, w * .46, colors.muted);
|
||||
text('over 30 days', x, y + h * .74, labelSize, w * .46, colors.muted);
|
||||
}
|
||||
const chart = calendarTimeline(habit.days.map(day => ({ date: day.date, value: day.ratio ?? 0, target: 1, state: day.due ? 'due' : 'not-due', color: day.color })));
|
||||
const chartWidth = w * .44;
|
||||
const step = Math.min(44, h * .61 / 7, chartWidth / Math.max(1, chart.weeks));
|
||||
const cell = step * .86;
|
||||
const chartX = x + w * .56 + (chartWidth - chart.weeks * step) / 2;
|
||||
const chartY = y + h * (compact ? .30 : .19);
|
||||
for (const row of [1, 3, 5]) {
|
||||
ctx.textAlign = 'right';
|
||||
text(['S', 'M', 'T', 'W', 'T', 'F', 'S'][row]!, chartX - step * .38, chartY + row * step + cell * .8, Math.min(26, step * .7), step, colors.muted);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
chart.entries.forEach(({ day, column, row }) => {
|
||||
const dx = chartX + (column - 1) * step, dy = chartY + (row - 1) * step;
|
||||
if (day.state === 'due') rect(dx, dy, cell, cell, day.value ? themedHabitShade(day.color!, habit.color, theme) : colors.empty, Math.min(3, cell / 6));
|
||||
else { ctx.fillStyle = colors.dot; ctx.beginPath(); ctx.arc(dx + cell / 2, dy + cell / 2, Math.max(.5, cell * .07), 0, Math.PI * 2); ctx.fill(); }
|
||||
});
|
||||
if (compact) return;
|
||||
const legendY = y + h * .92;
|
||||
const legendSize = Math.min(23, h * .052, w * .03);
|
||||
text('0', x, legendY, legendSize, w * .05, colors.muted);
|
||||
const shadeColors = [colors.empty, ...[...new Set(habit.days.filter(d => d.due && d.ratio && d.ratio < 1).map(d => d.color))].slice(0, 3).map(color => themedHabitShade(color, habit.color, theme)), themedHabitShade(habit.color, habit.color, theme)];
|
||||
const swatch = legendSize * .72;
|
||||
shadeColors.forEach((color, i) => rect(x + legendSize * 1.1 + i * legendSize, legendY - swatch, swatch, swatch, color, Math.min(2, swatch / 5)));
|
||||
text('Complete', x + legendSize * (shadeColors.length + 1.4), legendY, legendSize, w * .26, colors.muted);
|
||||
ctx.fillStyle = colors.dot; ctx.beginPath(); ctx.arc(x + w * .70, legendY - legendSize * .3, Math.max(.5, legendSize * .1), 0, Math.PI * 2); ctx.fill();
|
||||
ctx.textAlign = 'right'; text('Not scheduled', x + w, legendY, legendSize, w * .28, colors.muted); ctx.textAlign = 'left';
|
||||
});
|
||||
if (!data.habits.length) text('No active habits yet.', 64, 440, 36, 1400, colors.muted);
|
||||
rect(64, 1010, 1792, 1, colors.rule);
|
||||
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted);
|
||||
ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted);
|
||||
return canvas.encode('png');
|
||||
}
|
||||
52
src/sharing/live-discord-runtime.test.ts
Normal file
52
src/sharing/live-discord-runtime.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, appendFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
test('development process restart preserves the live message and subsequent debounced updates', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'minabot-live-watch-'));
|
||||
const entry = join(directory, 'server.ts');
|
||||
const log = join(directory, 'deliveries.log');
|
||||
const probe = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response() });
|
||||
const port = probe.port!; probe.stop(true);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const channelId = '223456789012345678', messageId = '423456789012345678';
|
||||
writeFileSync(entry, `
|
||||
import { fixture } from ${JSON.stringify(resolve('src/habits/test-fixture.ts'))};
|
||||
import { createApi } from ${JSON.stringify(resolve('src/api.ts'))};
|
||||
import { appendFileSync } from 'node:fs';
|
||||
const f = fixture(${JSON.stringify(join(directory, 'test.sqlite'))}); f.app.stopLiveSharing();
|
||||
const app = createApi(f.db, {origin:${JSON.stringify(origin)},clientId:'',clientSecret:'',cookieSecret:'test-secret-at-least-32-characters'}, undefined, () => Date.parse('2026-09-04T12:00:00Z'), async (url, init) => {
|
||||
if (init?.method) appendFileSync(${JSON.stringify(log)}, JSON.stringify({method:init.method,url})+'\\n');
|
||||
return Response.json(init?.method ? {id:'${messageId}'} : {id:'${channelId}',guild_id:'323456789012345678',type:0});
|
||||
}, {token:'test-only-token',channelId:'${channelId}'});
|
||||
Bun.serve({hostname:'127.0.0.1',port:${port},routes:{'/api/*':req=>app.fetch(req)}});
|
||||
globalThis.watchBootCount = (globalThis.watchBootCount ?? 0) + 1;
|
||||
appendFileSync(${JSON.stringify(log)},JSON.stringify({event:'boot',generation:globalThis.watchBootCount})+'\\n');
|
||||
`);
|
||||
const server = Bun.spawn([process.execPath, '--watch', entry], { cwd: process.cwd(), env: { ...process.env, NODE_ENV: 'development', DISCORD_BOT_TOKEN: '' }, stdout: 'ignore', stderr: 'ignore' });
|
||||
const events = () => existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [];
|
||||
async function until(check: () => boolean) {
|
||||
const deadline = Date.now() + 7000;
|
||||
while (!check()) { if (Date.now() > deadline) throw new Error('Live worker did not make progress'); await Bun.sleep(50); }
|
||||
}
|
||||
async function call(path: string, method: string, body: unknown) {
|
||||
const response = await fetch(`${origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${'a'.repeat(43)}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
expect(response.ok).toBe(true); return response.json();
|
||||
}
|
||||
try {
|
||||
await until(() => events().some(e => e.event === 'boot'));
|
||||
const habit = await call('/habits', 'POST', {name:'Read',method:'count',target:10});
|
||||
await call('/sharing/live', 'POST', {theme:'light'});
|
||||
appendFileSync(entry, '\n// Simulate a source change.\n');
|
||||
await until(() => events().filter(e => e.event === 'boot').length === 2);
|
||||
const boots = events().filter(e => e.event === 'boot'); expect(boots.map(e => e.generation)).toEqual([1, 1]);
|
||||
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', {count:8});
|
||||
await until(() => events().some(e => e.method === 'PATCH'));
|
||||
const deliveries = events().filter(e => e.method);
|
||||
expect(deliveries.map(e => e.method)).toEqual(['POST', 'PATCH']);
|
||||
expect(deliveries[1].url).toEndWith(`/messages/${messageId}`);
|
||||
} finally {
|
||||
server.kill(); await server.exited; rmSync(directory, {recursive:true,force:true});
|
||||
}
|
||||
}, 20000);
|
||||
153
src/sharing/live-discord.test.ts
Normal file
153
src/sharing/live-discord.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { afterEach, test, expect } from 'bun:test';
|
||||
import { fixture } from '../habits/test-fixture';
|
||||
import { createApi } from '../api';
|
||||
import { liveDiscordCards } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { liveCardData, liveCardLayout } from './live-card-image';
|
||||
import type { DiscordFetch } from './routes';
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup(); });
|
||||
const channelId = '223456789012345678';
|
||||
const messageId = '423456789012345678';
|
||||
const config = { token: 'test-bot-secret', channelId };
|
||||
function setup(custom?: DiscordFetch) {
|
||||
const f = fixture();
|
||||
const writes: { url: string; method: string; body: FormData }[] = [];
|
||||
const request: DiscordFetch = async (url, init) => {
|
||||
if (init?.method === 'POST' || init?.method === 'PATCH') writes.push({ url, method: init.method, body: init.body as FormData });
|
||||
return custom ? custom(url, init) : Response.json(init?.method ? { id: messageId } : { id: channelId, guild_id: '323456789012345678', type: 0, name: 'test' });
|
||||
};
|
||||
const app = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-secret-with-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), request, config);
|
||||
cleanups.push(() => { app.stopLiveSharing(); f.close(); });
|
||||
const call = (path: string, method = 'GET', body?: unknown, user = 'a', origin = f.origin) => app.request(`${f.origin}/api${path}`, { method, headers: { Origin: origin, Cookie: `minabot_session=${user.repeat(43)}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const start = () => call('/sharing/live', 'POST', { theme: 'light' });
|
||||
return { f, app, call, start, writes, request };
|
||||
}
|
||||
|
||||
test('PNG is 1920x1080 and includes more than six habits, without private tasks', async () => {
|
||||
const { f, call } = setup();
|
||||
for (let i = 0; i < 14; i++) await f.json('/habits', 'POST', { name: `Habit ${i}`, method: 'manual' }, 201);
|
||||
await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{ name: 'SECRET TASK' }] }, 201);
|
||||
const data = liveCardData(f.db, 'alice', Date.parse('2026-09-04T12:00:00Z'));
|
||||
expect(data.habits).toHaveLength(15); expect(JSON.stringify(data)).not.toContain('SECRET TASK');
|
||||
const response = await call('/sharing/live/preview?theme=dark');
|
||||
expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
const png = Buffer.from(await response.arrayBuffer());
|
||||
expect(png.readUInt32BE(16)).toBe(1920); expect(png.readUInt32BE(20)).toBe(1080); expect(png.byteLength).toBeLessThan(4 * 1024 * 1024);
|
||||
for (const count of [1, 6, 15, 40, 100]) { const layout = liveCardLayout(count); expect(layout.rows * layout.columns).toBeGreaterThanOrEqual(count); expect(layout.panelHeight * layout.rows).toBeLessThanOrEqual(650); }
|
||||
});
|
||||
|
||||
test('authenticated same-origin controls; no public live link or image', async () => {
|
||||
const { call, app } = setup();
|
||||
expect((await call('/sharing/live', 'POST', { theme: 'light' }, 'x')).status).toBe(401);
|
||||
expect((await call('/sharing/live', 'POST', { theme: 'light' }, 'a', 'https://evil.example')).status).toBe(403);
|
||||
expect((await call('/sharing/live/preview', 'GET', undefined, 'x')).status).toBe(401);
|
||||
expect((await app.request('/api/live-card/view/anything')).status).toBe(404);
|
||||
});
|
||||
|
||||
test('posts once, debounces mutations, skips unchanged content, then replaces attachment on same message', async () => {
|
||||
const { f, app, call, start, writes } = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Read', method: 'count', target: 10 }, 201);
|
||||
const first = await start(); expect(first.status).toBe(200); expect((await first.json()).status).toBe('active');
|
||||
expect(writes).toHaveLength(1); expect(writes[0]!.method).toBe('POST');
|
||||
await start(); expect(writes).toHaveLength(1);
|
||||
for (const count of [2, 5, 10]) await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { count });
|
||||
expect(writes).toHaveLength(1);
|
||||
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
|
||||
expect(writes[1]!.url).toBe(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`);
|
||||
expect(writes[1]!.method).toBe('PATCH');
|
||||
const payload = JSON.parse(String(writes[1]!.body.get('payload_json')));
|
||||
expect(payload.attachments).toHaveLength(1); expect(payload.attachments[0].id).toBe(0); expect(payload.allowed_mentions).toEqual({ parse: [] });
|
||||
expect(writes[1]!.body.get('files[0]')).toBeInstanceOf(File);
|
||||
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { count: 10 });
|
||||
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
|
||||
await call('/sharing/live', 'DELETE');
|
||||
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { count: 0 });
|
||||
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
|
||||
await start(); expect(writes).toHaveLength(3); expect(writes[2]!.method).toBe('PATCH');
|
||||
expect((await (await call('/sharing/live', 'GET', undefined, 'b')).json()).status).toBe('off');
|
||||
});
|
||||
|
||||
test('unconfirmed initial delivery cannot be duplicated by retry, stop, or restart', async () => {
|
||||
const { f, start, call, writes } = setup(async (_url, init) => {
|
||||
if (init?.method === 'POST') throw new Error('network interrupted');
|
||||
return Response.json({ id: channelId, guild_id: '323456789012345678', type: 0 });
|
||||
});
|
||||
expect((await (await start()).json()).status).toBe('uncertain');
|
||||
await call('/sharing/live', 'DELETE');
|
||||
expect((await start()).status).toBe(409); expect(writes).toHaveLength(1);
|
||||
expect(f.db.select().from(liveDiscordCards).get()!.status).toBe('uncertain');
|
||||
});
|
||||
|
||||
test('known message is reused after restart and rate limits defer edits', async () => {
|
||||
let failure = 0;
|
||||
const { f, app, start, call, writes, request } = setup(async (_url, init) => init?.method === 'PATCH' && failure ? Response.json({ retry_after: 3 }, { status: failure }) : Response.json(init?.method ? { id: messageId } : { id: channelId, guild_id: '323456789012345678', type: 0 }));
|
||||
await start(); app.stopLiveSharing();
|
||||
const resumed = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-secret-with-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), request, config);
|
||||
cleanups.unshift(() => resumed.stopLiveSharing());
|
||||
await resumed.flushLiveSharing('alice'); expect(writes).toHaveLength(1);
|
||||
f.db.update(liveDiscordCards).set({ theme: 'dark' }).where(eq(liveDiscordCards.userId, 'alice')).run();
|
||||
failure = 429; await resumed.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
|
||||
expect(f.db.select().from(liveDiscordCards).get()!.retryAt).not.toBeNull();
|
||||
await resumed.flushLiveSharing('alice'); expect(writes).toHaveLength(2);
|
||||
expect(writes.filter(w => w.method === 'POST')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('deleted Discord message is replaced once and later edits use the new message', async () => {
|
||||
const replacementId = '523456789012345678';
|
||||
let posts = 0;
|
||||
const { f, app, start, writes } = setup(async (url, init) => {
|
||||
if (init?.method === 'PATCH' && url.endsWith(`/${messageId}`)) return Response.json({ code: 10008 }, { status: 404 });
|
||||
if (init?.method === 'POST') return Response.json({ id: ++posts === 1 ? messageId : replacementId });
|
||||
return Response.json({ id: channelId, guild_id: '323456789012345678', type: 0 });
|
||||
});
|
||||
await start();
|
||||
const firstNonce = JSON.parse(String(writes[0]!.body.get('payload_json'))).nonce;
|
||||
f.db.update(liveDiscordCards).set({ theme: 'dark' }).run();
|
||||
await Promise.all([app.flushLiveSharing('alice'), app.flushLiveSharing('alice')]);
|
||||
const saved = f.db.select().from(liveDiscordCards).get()!;
|
||||
expect(saved.status).toBe('active'); expect(saved.messageId).toBe(replacementId);
|
||||
expect(saved.messageUrl).toEndWith(`/${replacementId}`);
|
||||
expect(writes.map(w => w.method)).toEqual(['POST', 'PATCH', 'POST']);
|
||||
const payload = JSON.parse(String(writes[2]!.body.get('payload_json')));
|
||||
expect(payload.nonce).not.toBe(firstNonce); expect(payload.enforce_nonce).toBe(true);
|
||||
expect(payload.content).toContain('updates live'); expect(writes[2]!.body.get('files[0]')).toBeInstanceOf(File);
|
||||
await app.flushLiveSharing('alice'); expect(writes).toHaveLength(3);
|
||||
f.db.update(liveDiscordCards).set({ theme: 'light' }).run();
|
||||
await app.flushLiveSharing('alice'); expect(writes[3]!.url).toEndWith(`/${replacementId}`);
|
||||
expect(posts).toBe(2);
|
||||
});
|
||||
|
||||
test('missing channels and forbidden messages never trigger replacement posts', async () => {
|
||||
for (const [status, code] of [[404, 10003], [403, 10008], [404, undefined]]) {
|
||||
const { f, app, start, writes } = setup(async (_url, init) => init?.method === 'PATCH' ? Response.json({ code }, { status }) : Response.json(init?.method ? { id: messageId } : { id: channelId, guild_id: '323456789012345678', type: 0 }));
|
||||
await start(); f.db.update(liveDiscordCards).set({ theme: 'dark' }).run();
|
||||
await app.flushLiveSharing('alice');
|
||||
expect(f.db.select().from(liveDiscordCards).get()!.status).toBe('error');
|
||||
expect(writes.filter(w => w.method === 'POST')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('unconfirmed replacement is not duplicated on retry', async () => {
|
||||
let posts = 0;
|
||||
const { f, app, start, writes } = setup(async (_url, init) => {
|
||||
if (init?.method === 'PATCH') return Response.json({ code: 10008 }, { status: 404 });
|
||||
if (init?.method === 'POST') { if (++posts > 1) throw new Error('network interrupted'); return Response.json({ id: messageId }); }
|
||||
return Response.json({ id: channelId, guild_id: '323456789012345678', type: 0 });
|
||||
});
|
||||
await start(); f.db.update(liveDiscordCards).set({ theme: 'dark' }).run();
|
||||
await app.flushLiveSharing('alice');
|
||||
expect(f.db.select().from(liveDiscordCards).get()!.status).toBe('uncertain');
|
||||
expect((await start()).status).toBe(409); expect(posts).toBe(2);
|
||||
});
|
||||
|
||||
test('debounced worker edits without any open dashboard', async () => {
|
||||
const { f, start, call, writes } = setup();
|
||||
const habit = await f.json('/habits', 'POST', { name: 'Walk', method: 'manual' }, 201);
|
||||
await start();
|
||||
await call(`/habits/${habit.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||
expect(writes).toHaveLength(1);
|
||||
await Bun.sleep(2300);
|
||||
expect(writes).toHaveLength(2); expect(writes[1]!.method).toBe('PATCH');
|
||||
});
|
||||
190
src/sharing/live-discord.ts
Normal file
190
src/sharing/live-discord.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { z } from "zod";
|
||||
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||
import { liveDiscordCards, users } from "../db/schema";
|
||||
import { ApiError } from "../habits/service";
|
||||
import { endOfDay, localDate } from "../habits/calendar";
|
||||
import { liveCardData, renderLiveCard } from "./live-card-image";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
import type { DiscordFetch } from "./routes";
|
||||
|
||||
const liveMessageText = "🔄 This image updates live as habit progress changes. Updates appear in this same message.";
|
||||
const snowflake = /^\d{17,20}$/;
|
||||
const options = z.object({ theme: z.enum(['light', 'dark']) }).strict();
|
||||
export type LiveDiscordState = { status: 'off' | 'active' | 'paused' | 'sending' | 'uncertain' | 'error'; messageUrl: string | null; error: string | null; theme?: 'light' | 'dark' };
|
||||
|
||||
export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof createAuth>, config: DiscordSharingConfig, now: () => number, request: DiscordFetch = fetch) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const running = new Map<string, Promise<void>>();
|
||||
let stopped = false;
|
||||
// Serialize all sends in this Bun process, including different users.
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
let channelRetryAt = 0;
|
||||
const configured = Boolean(config.token && !/\s/.test(config.token) && snowflake.test(config.channelId));
|
||||
const row = (id: string) => db.select().from(liveDiscordCards).where(eq(liveDiscordCards.userId, id)).get();
|
||||
const update = (id: string, values: Partial<typeof liveDiscordCards.$inferInsert>) => db.update(liveDiscordCards).set(values).where(eq(liveDiscordCards.userId, id)).run();
|
||||
const allowed = (discordId: string) => !config.allowedUserIds?.length || config.allowedUserIds.includes(discordId);
|
||||
function state(id: string): LiveDiscordState {
|
||||
const current = row(id);
|
||||
return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null };
|
||||
}
|
||||
function schedule(id: string, delay: number) {
|
||||
if (stopped) return;
|
||||
clearTimeout(timers.get(id));
|
||||
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
|
||||
timer.unref(); timers.set(id, timer);
|
||||
}
|
||||
function changed(id: string) {
|
||||
const current = row(id);
|
||||
if (!current || !['active', 'sending'].includes(current.status)) return;
|
||||
update(id, { dirty: true });
|
||||
// Trailing debounce combines rapid check-ins into one attachment replacement.
|
||||
schedule(id, Math.max(2000, (current.retryAt ?? 0) - now(), channelRetryAt - now()));
|
||||
}
|
||||
function rollover(id: string) {
|
||||
const owner = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!owner) return;
|
||||
const timestamp = now();
|
||||
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
|
||||
}
|
||||
async function deliver(id: string) {
|
||||
if (stopped) return;
|
||||
const current = row(id);
|
||||
if (!current || !['active', 'sending'].includes(current.status)) return;
|
||||
const owner = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!owner || !configured || !allowed(owner.discordId) || current.channelId !== config.channelId) {
|
||||
update(id, { status: 'error', error: 'Live sharing is unavailable for this account or channel.' }); return;
|
||||
}
|
||||
const delay = Math.max(current.retryAt ?? 0, channelRetryAt) - now();
|
||||
if (delay > 0) { schedule(id, delay); return; }
|
||||
update(id, { dirty: false, retryAt: null });
|
||||
const data = liveCardData(db, id, now());
|
||||
// Hash the rendered content, excluding timestamps and private task details.
|
||||
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 2, content: liveMessageText })).digest('hex');
|
||||
if (current.messageId && current.imageHash === hash) {
|
||||
update(id, { error: null }); rollover(id); return;
|
||||
}
|
||||
let guildId: string | undefined;
|
||||
if (!current.messageId) {
|
||||
try {
|
||||
const channelResponse = await request(`https://discord.com/api/v10/channels/${current.channelId}`, { headers: { Authorization: `Bot ${config.token}` }, redirect: 'error', signal: AbortSignal.timeout(10000) });
|
||||
const channel = channelResponse.ok ? await channelResponse.json() as { id?: string; guild_id?: string; type?: number } : null;
|
||||
if (!channel || channel.id !== current.channelId || !snowflake.test(channel.guild_id ?? '') || ![0, 5, 10, 11, 12].includes(channel.type ?? -1)) {
|
||||
update(id, { status: 'error', error: 'The bot cannot access a supported Discord server channel. Check its configuration.' }); return;
|
||||
}
|
||||
guildId = channel.guild_id;
|
||||
} catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; }
|
||||
}
|
||||
const png = await renderLiveCard(data, current.theme);
|
||||
if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; }
|
||||
// A stop or account deletion during image generation cancels the outbound edit.
|
||||
if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return;
|
||||
const body = new FormData();
|
||||
body.set('payload_json', JSON.stringify({ content: liveMessageText, allowed_mentions: { parse: [] },
|
||||
...(!current.messageId ? { nonce: current.nonce, enforce_nonce: true } : {}),
|
||||
attachments: [{ id: 0, filename: 'minabot-live-progress.png', description: `${data.name}: all ${data.habits.length} active habits, ${data.from} to ${data.to}.` .slice(0, 1024) }] }));
|
||||
body.set('files[0]', new Blob([new Uint8Array(png)], { type: 'image/png' }), 'minabot-live-progress.png');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await request(`https://discord.com/api/v10/channels/${current.channelId}/messages${current.messageId ? `/${current.messageId}` : ''}`, {
|
||||
method: current.messageId ? 'PATCH' : 'POST', headers: { Authorization: `Bot ${config.token}` }, body, redirect: 'error', signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
} catch { handleFailure(id, current.messageId, 'Discord could not confirm the update.'); return; }
|
||||
if (!row(id)) return;
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
const payload = await response.json().catch(() => null) as { retry_after?: number } | null;
|
||||
const seconds = Number(payload?.retry_after ?? response.headers.get('Retry-After') ?? 5);
|
||||
const wait = Math.min(86400000, Math.max(2000, Number.isFinite(seconds) ? seconds * 1000 : 5000));
|
||||
channelRetryAt = now() + wait;
|
||||
update(id, { dirty: true, retryAt: channelRetryAt, error: 'Discord rate limit; update queued.' }); schedule(id, wait); return;
|
||||
}
|
||||
if (response.status >= 500) { handleFailure(id, current.messageId, 'Discord could not confirm the update.'); return; }
|
||||
if (response.status === 404 && current.messageId) {
|
||||
const failure = await response.json().catch(() => null) as { code?: number } | null;
|
||||
// Unknown Message is distinct from a missing channel or lost permissions.
|
||||
// Only a confirmed deletion authorizes a fresh post and delivery nonce.
|
||||
if (failure?.code === 10008 && !stopped && row(id)?.status === 'active') {
|
||||
update(id, { messageId: null, messageUrl: null, imageHash: null, nonce: randomBytes(12).toString('hex'),
|
||||
status: 'sending', dirty: true, retryAt: null, error: null });
|
||||
await deliver(id);
|
||||
return;
|
||||
}
|
||||
if (row(id)?.status === 'paused' || stopped) return;
|
||||
}
|
||||
update(id, { status: 'error', error: response.status === 404 ? 'The Discord message or channel is unavailable. No replacement message was posted.' : 'Discord rejected the update. Check the bot permissions and configuration.' }); return;
|
||||
}
|
||||
const message = await response.json().catch(() => null) as { id?: string; channel_id?: string } | null;
|
||||
const messageId = current.messageId ?? (snowflake.test(message?.id ?? '') ? message!.id! : null);
|
||||
if (!messageId) { update(id, { status: 'uncertain', error: 'Delivery unconfirmed. Check Discord; this card will not be posted again automatically.' }); return; }
|
||||
const latest = row(id);
|
||||
if (!latest) return;
|
||||
update(id, { messageId, messageUrl: current.messageUrl ?? `https://discord.com/channels/${guildId}/${current.channelId}/${messageId}`,
|
||||
status: latest.status === 'paused' ? 'paused' : 'active', imageHash: hash, updatedAt: now(), error: null });
|
||||
if (latest.status === 'paused') return;
|
||||
if (latest.dirty) schedule(id, 2000); else rollover(id);
|
||||
}
|
||||
function handleFailure(id: string, messageId: string | null, message: string) {
|
||||
if (!row(id) || row(id)!.status === 'paused') return;
|
||||
if (!messageId) update(id, { status: 'uncertain', error: `${message} Check Discord; the initial post will not be retried automatically.` });
|
||||
else { update(id, { dirty: true, retryAt: now() + 10000, error: `${message} Retrying the same message.` }); schedule(id, 10000); }
|
||||
}
|
||||
function flush(id: string): Promise<void> {
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
const existing = running.get(id);
|
||||
if (existing) return existing;
|
||||
const work = queue.then(() => deliver(id)).catch(() => {
|
||||
if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); }
|
||||
}).finally(() => running.delete(id));
|
||||
queue = work; running.set(id, work); return work;
|
||||
}
|
||||
function stop() { stopped = true; for (const timer of timers.values()) clearTimeout(timer); timers.clear(); }
|
||||
|
||||
app.use('*', auth.requireAuth);
|
||||
app.use('*', async (c, next) => c.req.method === 'GET' ? next() : auth.requireSameOrigin(c, next));
|
||||
app.use('*', bodyLimit({ maxSize: 1024 }));
|
||||
app.get('/', c => c.json(state(c.get('user').id)));
|
||||
app.get('/preview', async c => {
|
||||
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light';
|
||||
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme);
|
||||
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
|
||||
return c.body(new Uint8Array(image));
|
||||
});
|
||||
app.post('/', async c => {
|
||||
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
|
||||
const user = c.get('user');
|
||||
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
|
||||
const input = options.safeParse(await c.req.json().catch(() => null));
|
||||
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
|
||||
const existing = row(user.id);
|
||||
if (existing?.status === 'uncertain')
|
||||
throw new ApiError(409, 'Check the original delivery in Discord before starting another live card.');
|
||||
if (existing?.channelId && existing.channelId !== config.channelId) throw new ApiError(409, 'The configured channel changed. Restore the original channel to resume this message.');
|
||||
if (existing?.status !== 'sending') {
|
||||
db.insert(liveDiscordCards).values({ userId: user.id, channelId: config.channelId, nonce: randomBytes(12).toString('hex'), theme: input.data.theme, status: 'sending', updatedAt: now() })
|
||||
.onConflictDoUpdate({ target: liveDiscordCards.userId, set: { theme: input.data.theme, status: existing?.messageId ? 'active' : 'sending', dirty: true, error: null } }).run();
|
||||
}
|
||||
await flush(user.id);
|
||||
return c.json(state(user.id));
|
||||
});
|
||||
app.delete('/', async c => {
|
||||
const id = c.get('user').id;
|
||||
if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
// Return only after any already-sent edit has finished.
|
||||
await running.get(id);
|
||||
if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
|
||||
clearTimeout(timers.get(id)); timers.delete(id);
|
||||
return c.json(state(id));
|
||||
});
|
||||
// Recover only known message edits. Never risk duplicating an interrupted first post.
|
||||
for (const current of db.select().from(liveDiscordCards).all()) {
|
||||
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
|
||||
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
|
||||
}
|
||||
return { routes: app, changed, flush, stop };
|
||||
}
|
||||
14
styles/live-discord.css
Normal file
14
styles/live-discord.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.ds-live-discord { display: grid; gap: 18px; }
|
||||
.ds-live-discord-heading, .ds-live-discord-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; }
|
||||
.ds-live-discord-heading h3 { font-size: 20px; margin: 0 0 6px; }
|
||||
.ds-live-discord-heading p, .ds-live-discord-footer p { margin: 4px 0; font-size: 13px; }
|
||||
.ds-live-discord-preview { aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; border: 1px solid var(--ds-border, #858a8066); display: grid; place-items: center; }
|
||||
.ds-live-discord-preview img { width: 100%; height: 100%; display: block; object-fit: contain; }
|
||||
.ds-live-discord-description { font-size: 14px; margin: 0; }
|
||||
.ds-live-discord-privacy { font-size: 12px; opacity: .75; margin: 0; }
|
||||
.ds-live-discord-footer { border-top: 1px solid #858a8033; padding-top: 18px; }
|
||||
.ds-live-discord-footer .ds-share-actions { flex-wrap: wrap; }
|
||||
.ds-sharing-mode { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.ds-live-discord { padding: 0 32px 24px; }
|
||||
.ds-sharing-mode { padding: 0 32px; margin-top: 20px; }
|
||||
@media (max-width: 600px) { .ds-live-discord { padding: 0 16px 16px; } .ds-sharing-mode { padding: 0 16px; } }
|
||||
Reference in New Issue
Block a user