v3: added attachments to resources, allow users to paste images into TextEditor, strengthened WS reliability
This commit is contained in:
@@ -13,11 +13,12 @@ GERBEUR_LISTEN_HOST=0.0.0.0
|
|||||||
# Port the API server listens on
|
# Port the API server listens on
|
||||||
GERBEUR_PORT=8000
|
GERBEUR_PORT=8000
|
||||||
|
|
||||||
# Comma-separated list of origins allowed to make cross-origin requests.
|
# Comma-separated list of extra origins allowed to reach the API/WS cross-origin.
|
||||||
# In dev: Vite's dev server URL (check actual host/port in Vite output).
|
# The server's own BASE_URL is always allowed automatically.
|
||||||
# In prod (same container): your public domain.
|
# In dev: add Vite's dev server URL (check actual host/port in Vite output).
|
||||||
|
# In prod with a separate frontend host: add that public frontend origin here.
|
||||||
# Example: http://localhost:3000,http://127.0.0.1:3000
|
# Example: http://localhost:3000,http://127.0.0.1:3000
|
||||||
GERBEUR_ALLOWED_ORIGINS=http://localhost:3000
|
GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
|
# Secret key used to sign JWTs. Generate with: openssl rand -hex 32
|
||||||
GERBEUR_JWT_SECRET=
|
GERBEUR_JWT_SECRET=
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -38,18 +38,18 @@ Open [http://localhost:3000](http://localhost:3000). On first run a default `adm
|
|||||||
|
|
||||||
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
|
See [`.env.example`](.env.example) for the full list with descriptions. Key variables:
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ------------------------- | ---------------------------------------------------------------------------- | ----------------------- |
|
| ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||||
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — |
|
||||||
| `GERBEUR_PORT` | API server port | `8000` |
|
| `GERBEUR_PORT` | API server port | `8000` |
|
||||||
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of allowed CORS origins | `http://localhost:3000` |
|
| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` |
|
||||||
| `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset |
|
| `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset |
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
|
|
||||||
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no CORS configuration needed, no `VITE_API_*` build args needed.
|
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. The server's own `BASE_URL` is always allowed for HTTP/WebSocket requests automatically.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t gerbeur .
|
docker build -t gerbeur .
|
||||||
@@ -59,7 +59,6 @@ docker run -d \
|
|||||||
-v gerbeur-db:/app/api/sql \
|
-v gerbeur-db:/app/api/sql \
|
||||||
-v gerbeur-uploads:/app/api/uploads \
|
-v gerbeur-uploads:/app/api/uploads \
|
||||||
-e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
|
-e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
|
||||||
-e GERBEUR_ALLOWED_ORIGINS=https://example.com \
|
|
||||||
-e GERBEUR_PROTOCOL=https \
|
-e GERBEUR_PROTOCOL=https \
|
||||||
-e GERBEUR_HOSTNAME=example.com \
|
-e GERBEUR_HOSTNAME=example.com \
|
||||||
-e GERBEUR_PORT=8000 \
|
-e GERBEUR_PORT=8000 \
|
||||||
@@ -73,7 +72,7 @@ The two volumes are required for persistence:
|
|||||||
|
|
||||||
#### Separate API and frontend (optional)
|
#### Separate API and frontend (optional)
|
||||||
|
|
||||||
If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle:
|
If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin HTTP/WebSocket requests are accepted:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
|
|||||||
|
|
||||||
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
|
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
|
||||||
"http://localhost:3000";
|
"http://localhost:3000";
|
||||||
export const ALLOWED_ORIGINS: string[] = rawOrigins
|
export const ALLOWED_ORIGINS: string[] = Array.from(new Set([
|
||||||
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
|
BASE_URL,
|
||||||
: [];
|
...(
|
||||||
|
rawOrigins
|
||||||
|
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const UPLOADS_DIR = "api/uploads";
|
|||||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||||
|
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||||
|
|
||||||
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Application } from "@oak/oak";
|
import { Application } from "@oak/oak";
|
||||||
import { oakCors } from "@tajpouria/cors";
|
import { oakCors } from "@tajpouria/cors";
|
||||||
|
|
||||||
|
import attachmentsRouter from "./routes/attachments.ts";
|
||||||
import dumpsRouter from "./routes/dumps.ts";
|
import dumpsRouter from "./routes/dumps.ts";
|
||||||
import filesRouter from "./routes/files.ts";
|
import filesRouter from "./routes/files.ts";
|
||||||
import usersRouter from "./routes/users.ts";
|
import usersRouter from "./routes/users.ts";
|
||||||
@@ -42,6 +43,10 @@ app.use(
|
|||||||
filesRouter.routes(),
|
filesRouter.routes(),
|
||||||
filesRouter.allowedMethods(),
|
filesRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
attachmentsRouter.routes(),
|
||||||
|
attachmentsRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(
|
app.use(
|
||||||
usersRouter.routes(),
|
usersRouter.routes(),
|
||||||
usersRouter.allowedMethods(),
|
usersRouter.allowedMethods(),
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ db.prepare(
|
|||||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
|
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour
|
||||||
|
const orphanedAttachments = db.prepare(
|
||||||
|
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`,
|
||||||
|
).all() as { id: string }[];
|
||||||
|
if (orphanedAttachments.length > 0) {
|
||||||
|
const { ATTACHMENTS_DIR } = await import("../lib/upload.ts");
|
||||||
|
for (const { id } of orphanedAttachments) {
|
||||||
|
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`,
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
// Create default admin user if no users exist
|
// Create default admin user if no users exist
|
||||||
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
|
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
68
api/routes/attachments.ts
Normal file
68
api/routes/attachments.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import {
|
||||||
|
createAttachment,
|
||||||
|
getAttachment,
|
||||||
|
} from "../services/attachment-service.ts";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import {
|
||||||
|
ATTACHMENTS_DIR,
|
||||||
|
serveUploadedFile,
|
||||||
|
validateImageUpload,
|
||||||
|
} from "../lib/upload.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||||
|
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("multipart/form-data")) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
400,
|
||||||
|
"Expected multipart/form-data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.request.body.formData();
|
||||||
|
const file = body.get("file");
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const mime = validateImageUpload(data);
|
||||||
|
|
||||||
|
const attachmentId = crypto.randomUUID();
|
||||||
|
await Deno.mkdir(ATTACHMENTS_DIR, { recursive: true });
|
||||||
|
await Deno.writeFile(`${ATTACHMENTS_DIR}/${attachmentId}`, data);
|
||||||
|
try {
|
||||||
|
createAttachment(attachmentId, mime);
|
||||||
|
} catch (err) {
|
||||||
|
await Deno.remove(`${ATTACHMENTS_DIR}/${attachmentId}`).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, data: { id: attachmentId } };
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/attachments/:attachmentId", async (ctx) => {
|
||||||
|
const { attachmentId } = ctx.params;
|
||||||
|
|
||||||
|
let attachment;
|
||||||
|
try {
|
||||||
|
attachment = getAttachment(attachmentId);
|
||||||
|
} catch {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await serveUploadedFile(
|
||||||
|
ctx,
|
||||||
|
`${ATTACHMENTS_DIR}/${attachmentId}`,
|
||||||
|
attachment.mime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
42
api/services/attachment-service.ts
Normal file
42
api/services/attachment-service.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { db } from "../model/db.ts";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
|
export function createAttachment(id: string, mime: string): void {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
|
||||||
|
).run(id, mime, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachment(attachmentId: string): { mime: string } {
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT mime FROM attachments WHERE id = ?",
|
||||||
|
).get(attachmentId) as { mime: string } | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"Attachment not found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mime: row.mime };
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUID pattern used to extract attachment IDs from markdown/text bodies.
|
||||||
|
const ATTACHMENT_RE =
|
||||||
|
/\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `text` for attachment URLs and bind any unowned attachments to
|
||||||
|
* `resourceId`. Only updates rows where resource_id IS NULL so a user cannot
|
||||||
|
* claim attachments that already belong to another resource.
|
||||||
|
*/
|
||||||
|
export function linkAttachments(text: string, resourceId: string): void {
|
||||||
|
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);
|
||||||
|
for (const id of ids) {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL",
|
||||||
|
).run(resourceId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { type SQLOutputValue } from "node:sqlite";
|
import { type SQLOutputValue } from "node:sqlite";
|
||||||
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
|
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
|
||||||
import { notifyMentions } from "./notification-service.ts";
|
import { notifyMentions } from "./notification-service.ts";
|
||||||
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
|
||||||
const SELECT_COLS =
|
const SELECT_COLS =
|
||||||
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted,
|
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted,
|
||||||
@@ -66,6 +67,7 @@ export function createComment(
|
|||||||
dumpId,
|
dumpId,
|
||||||
) as { title: string } | undefined;
|
) as { title: string } | undefined;
|
||||||
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
||||||
|
linkAttachments(body, id);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ export function updateComment(
|
|||||||
dumpRow?.title ?? "",
|
dumpRow?.title ?? "",
|
||||||
row.dump_id,
|
row.dump_id,
|
||||||
);
|
);
|
||||||
|
linkAttachments(body, commentId);
|
||||||
return {
|
return {
|
||||||
comment: fetchComment(commentId),
|
comment: fetchComment(commentId),
|
||||||
dumpId: row.dump_id,
|
dumpId: row.dump_id,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||||
import { DUMPS_DIR } from "../lib/upload.ts";
|
import { DUMPS_DIR } from "../lib/upload.ts";
|
||||||
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ export async function createUrlDump(
|
|||||||
}
|
}
|
||||||
if (request.comment) {
|
if (request.comment) {
|
||||||
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
notifyMentions(userId, request.comment, "dump", dumpId, title);
|
||||||
|
linkAttachments(request.comment, dumpId);
|
||||||
}
|
}
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
@@ -185,7 +187,10 @@ export async function createFileDump(
|
|||||||
broadcastNewDump(dump);
|
broadcastNewDump(dump);
|
||||||
notifyUserFollowersNewDump(userId, dumpId, file.name);
|
notifyUserFollowersNewDump(userId, dumpId, file.name);
|
||||||
}
|
}
|
||||||
if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name);
|
if (comment) {
|
||||||
|
notifyMentions(userId, comment, "dump", dumpId, file.name);
|
||||||
|
linkAttachments(comment, dumpId);
|
||||||
|
}
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +287,7 @@ export async function updateDump(
|
|||||||
dumpId,
|
dumpId,
|
||||||
updatedDump.title,
|
updatedDump.title,
|
||||||
);
|
);
|
||||||
|
linkAttachments(updatedDump.comment, dumpId);
|
||||||
}
|
}
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
@@ -344,6 +350,7 @@ export async function updateDump(
|
|||||||
dumpId,
|
dumpId,
|
||||||
updatedDump.title,
|
updatedDump.title,
|
||||||
);
|
);
|
||||||
|
linkAttachments(updatedDump.comment, dumpId);
|
||||||
}
|
}
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
@@ -401,7 +408,10 @@ export async function replaceFileDump(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
if (comment) {
|
||||||
|
notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
||||||
|
linkAttachments(comment, dumpId);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...dump,
|
...dump,
|
||||||
title: file.name,
|
title: file.name,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
notifyPlaylistFollowersNewDump,
|
notifyPlaylistFollowersNewDump,
|
||||||
} from "./notification-service.ts";
|
} from "./notification-service.ts";
|
||||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||||
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
|
||||||
const DUMP_SELECT_COLS =
|
const DUMP_SELECT_COLS =
|
||||||
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
||||||
@@ -74,6 +75,7 @@ export function createPlaylist(
|
|||||||
};
|
};
|
||||||
if (req.description) {
|
if (req.description) {
|
||||||
notifyMentions(userId, req.description, "playlist", id, req.title);
|
notifyMentions(userId, req.description, "playlist", id, req.title);
|
||||||
|
linkAttachments(req.description, id);
|
||||||
}
|
}
|
||||||
broadcastPlaylistCreated(playlist);
|
broadcastPlaylistCreated(playlist);
|
||||||
return playlist;
|
return playlist;
|
||||||
@@ -184,6 +186,7 @@ export function updatePlaylist(
|
|||||||
playlist.id,
|
playlist.id,
|
||||||
newTitle,
|
newTitle,
|
||||||
);
|
);
|
||||||
|
linkAttachments(newDescription, playlist.id);
|
||||||
}
|
}
|
||||||
broadcastPlaylistUpdated(updated);
|
broadcastPlaylistUpdated(updated);
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
|||||||
import { disconnectUser } from "./ws-service.ts";
|
import { disconnectUser } from "./ws-service.ts";
|
||||||
|
|
||||||
import { hashPassword } from "../lib/jwt.ts";
|
import { hashPassword } from "../lib/jwt.ts";
|
||||||
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
|
|
||||||
const USER_SELECT =
|
const USER_SELECT =
|
||||||
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by,
|
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by,
|
||||||
@@ -147,6 +148,10 @@ export async function updateUser(
|
|||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updatedUser.description) {
|
||||||
|
linkAttachments(updatedUser.description, userId);
|
||||||
|
}
|
||||||
|
|
||||||
return updatedUser;
|
return updatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../model/db.ts";
|
|||||||
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||||
|
|
||||||
export function castVote(dumpId: string, userId: string): number {
|
export function castVote(dumpId: string, userId: string): number {
|
||||||
|
let voteCount: number;
|
||||||
try {
|
try {
|
||||||
db.exec("BEGIN;");
|
db.exec("BEGIN;");
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -15,10 +16,11 @@ export function castVote(dumpId: string, userId: string): number {
|
|||||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||||
).get(dumpId) as { vote_count: number } | undefined;
|
).get(dumpId) as { vote_count: number } | undefined;
|
||||||
db.exec("COMMIT;");
|
db.exec("COMMIT;");
|
||||||
notifyDumpOwnerUpvote(userId, dumpId);
|
voteCount = row?.vote_count ?? 0;
|
||||||
return row?.vote_count ?? 0;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec("ROLLBACK;");
|
try {
|
||||||
|
db.exec("ROLLBACK;");
|
||||||
|
} catch { /* ignore if no active transaction */ }
|
||||||
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
@@ -28,6 +30,11 @@ export function castVote(dumpId: string, userId: string): number {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
// Notification is best-effort — must not prevent vote_ack from being sent
|
||||||
|
try {
|
||||||
|
notifyDumpOwnerUpvote(userId, dumpId);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return voteCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeVote(dumpId: string, userId: string): number {
|
export function removeVote(dumpId: string, userId: string): number {
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ CREATE TABLE invites (
|
|||||||
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
resource_id TEXT,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
|
||||||
|
|
||||||
CREATE TABLE notifications (
|
CREATE TABLE notifications (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
29
src/App.css
29
src/App.css
@@ -59,6 +59,12 @@
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact / card mode: strip vertical spacing */
|
/* Compact / card mode: strip vertical spacing */
|
||||||
.md--inline p,
|
.md--inline p,
|
||||||
.md--inline ul,
|
.md--inline ul,
|
||||||
@@ -1459,6 +1465,23 @@ body.has-player .fab-new {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header-status {
|
||||||
|
margin: 1rem auto 0 auto;
|
||||||
|
max-width: 860px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--color-danger-bg) 92%, white 8%);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-status strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.page-error-wrap {
|
.page-error-wrap {
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -3427,6 +3450,12 @@ body.has-player .fab-new {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention-textarea-wrap--dragover textarea {
|
||||||
|
outline: 2px dashed var(--color-accent, #6c8ebf);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.mention-dropdown {
|
.mention-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||||
import { NotificationBell } from "./NotificationBell.tsx";
|
import { NotificationBell } from "./NotificationBell.tsx";
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ export function AppHeader(
|
|||||||
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
|
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
|
||||||
) {
|
) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { wsStatus, wsErrorMessage } = useWS();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ export function AppHeader(
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||||
|
<div className="app-header-status" role="alert">
|
||||||
|
<strong>Live updates unavailable.</strong> {wsErrorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{createModalOpen && (
|
{createModalOpen && (
|
||||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { EmojiPicker } from "frimousse";
|
import { EmojiPicker } from "frimousse";
|
||||||
import { MentionDropdown } from "./MentionDropdown.tsx";
|
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
export interface TextEditorHandle {
|
export interface TextEditorHandle {
|
||||||
focus(): void;
|
focus(): void;
|
||||||
@@ -40,6 +49,14 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
const emojiViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
const emojiSearchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
valueRef.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => textareaRef.current?.focus(),
|
focus: () => textareaRef.current?.focus(),
|
||||||
@@ -76,8 +93,89 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
el.style.height = `${el.scrollHeight}px`;
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
}, [value, autoResize]);
|
}, [value, autoResize]);
|
||||||
|
|
||||||
|
const insertAtCursor = useCallback((text: string) => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart ?? valueRef.current.length;
|
||||||
|
const end = el.selectionEnd ?? valueRef.current.length;
|
||||||
|
const newValue = valueRef.current.slice(0, start) +
|
||||||
|
text +
|
||||||
|
valueRef.current.slice(end);
|
||||||
|
onChange(newValue);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!textareaRef.current) return;
|
||||||
|
const pos = start + text.length;
|
||||||
|
textareaRef.current.selectionStart = pos;
|
||||||
|
textareaRef.current.selectionEnd = pos;
|
||||||
|
});
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const uploadImage = useCallback(async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/api/attachments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
insertAtCursor(``);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently ignore — user can retry
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [authFetch, insertAtCursor]);
|
||||||
|
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
const imageItem = Array.from(e.clipboardData.items).find(
|
||||||
|
(item) => item.kind === "file" && item.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
if (!imageItem) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const file = imageItem.getAsFile();
|
||||||
|
if (file) await uploadImage(file);
|
||||||
|
},
|
||||||
|
[uploadImage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
||||||
|
f.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
for (const file of files) {
|
||||||
|
await uploadImage(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[uploadImage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
|
const hasImage = Array.from(e.dataTransfer.items).some(
|
||||||
|
(item) => item.kind === "file" && item.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
if (!hasImage) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mention-textarea-wrap">
|
<div
|
||||||
|
className={`mention-textarea-wrap${dragOver ? " mention-textarea-wrap--dragover" : ""}`}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -94,8 +192,12 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
handleMentionKeyDown(e);
|
handleMentionKeyDown(e);
|
||||||
if (!e.defaultPrevented) onKeyDown?.(e);
|
if (!e.defaultPrevented) onKeyDown?.(e);
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
onPaste={handlePaste}
|
||||||
disabled={disabled}
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
placeholder={uploading ? "Uploading image…" : placeholder}
|
||||||
|
disabled={disabled || uploading}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
id={id}
|
id={id}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface UserEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WSContextValue {
|
export interface WSContextValue {
|
||||||
|
wsStatus: "connecting" | "connected" | "disconnected";
|
||||||
|
wsErrorMessage: string | null;
|
||||||
onlineUsers: OnlineUser[];
|
onlineUsers: OnlineUser[];
|
||||||
voteCounts: Record<string, number>;
|
voteCounts: Record<string, number>;
|
||||||
myVotes: Set<string>;
|
myVotes: Set<string>;
|
||||||
@@ -54,6 +56,8 @@ export interface WSContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const WSContext = createContext<WSContextValue>({
|
export const WSContext = createContext<WSContextValue>({
|
||||||
|
wsStatus: "connecting",
|
||||||
|
wsErrorMessage: null,
|
||||||
onlineUsers: [],
|
onlineUsers: [],
|
||||||
voteCounts: {},
|
voteCounts: {},
|
||||||
myVotes: new Set(),
|
myVotes: new Set(),
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ interface WSProviderProps {
|
|||||||
|
|
||||||
const MAX_BACKOFF = 30_000;
|
const MAX_BACKOFF = 30_000;
|
||||||
const ACK_TIMEOUT = 5_000;
|
const ACK_TIMEOUT = 5_000;
|
||||||
|
const CONNECT_TIMEOUT = 2_500;
|
||||||
|
|
||||||
|
interface PendingVote {
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
rollback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
// Minimal runtime check: verify the `type` field is a known string so we can
|
// Minimal runtime check: verify the `type` field is a known string so we can
|
||||||
// safely cast to the discriminated union and let TypeScript narrow from there.
|
// safely cast to the discriminated union and let TypeScript narrow from there.
|
||||||
@@ -58,6 +64,10 @@ function parseWSMessage(data: string): IncomingWSMessage | null {
|
|||||||
export function WSProvider(
|
export function WSProvider(
|
||||||
{ children, token, userId, onForceLogout }: WSProviderProps,
|
{ children, token, userId, onForceLogout }: WSProviderProps,
|
||||||
) {
|
) {
|
||||||
|
const [wsStatus, setWSStatus] = useState<
|
||||||
|
"connecting" | "connected" | "disconnected"
|
||||||
|
>("connecting");
|
||||||
|
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
||||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||||
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||||
@@ -91,15 +101,46 @@ export function WSProvider(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
// Tracks pending optimistic votes: dumpId → revert timeout ID
|
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||||
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearPendingVote = useCallback((dumpId: string) => {
|
||||||
|
const pending = pendingRef.current.get(dumpId);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingRef.current.delete(dumpId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllPendingVotes = useCallback(() => {
|
||||||
|
for (const pending of pendingRef.current.values()) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
}
|
||||||
|
pendingRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const schedulePendingVote = useCallback((
|
||||||
|
dumpId: string,
|
||||||
|
rollback: () => void,
|
||||||
|
) => {
|
||||||
|
clearPendingVote(dumpId);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRef.current.delete(dumpId);
|
||||||
|
rollback();
|
||||||
|
}, ACK_TIMEOUT);
|
||||||
|
pendingRef.current.set(dumpId, { timeout, rollback });
|
||||||
|
}, [clearPendingVote]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let backoff = 500;
|
let backoff = 500;
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let everConnected = false;
|
||||||
|
|
||||||
|
setWSStatus("connecting");
|
||||||
|
setWSErrorMessage(null);
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -110,6 +151,25 @@ export function WSProvider(
|
|||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
socketRef.current = ws;
|
socketRef.current = ws;
|
||||||
|
|
||||||
|
connectTimeout = setTimeout(() => {
|
||||||
|
if (ws.readyState !== WebSocket.CONNECTING) return;
|
||||||
|
setWSStatus("disconnected");
|
||||||
|
setWSErrorMessage(
|
||||||
|
"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.",
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
}, CONNECT_TIMEOUT);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (connectTimeout) {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
connectTimeout = null;
|
||||||
|
}
|
||||||
|
everConnected = true;
|
||||||
|
setWSStatus("connected");
|
||||||
|
setWSErrorMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = parseWSMessage(event.data);
|
const msg = parseWSMessage(event.data);
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
@@ -126,6 +186,9 @@ export function WSProvider(
|
|||||||
setOnlineUsers(msg.users);
|
setOnlineUsers(msg.users);
|
||||||
setMyVotes(new Set(msg.myVotes));
|
setMyVotes(new Set(msg.myVotes));
|
||||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||||
|
// welcome provides authoritative server state — cancel any
|
||||||
|
// in-flight revert timers, they are now superseded.
|
||||||
|
clearAllPendingVotes();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "presence_update":
|
case "presence_update":
|
||||||
@@ -139,6 +202,7 @@ export function WSProvider(
|
|||||||
// Keep myVotes in sync across tabs: if this vote event belongs to
|
// Keep myVotes in sync across tabs: if this vote event belongs to
|
||||||
// the current user (from another tab), update myVotes accordingly.
|
// the current user (from another tab), update myVotes accordingly.
|
||||||
if (voterId === userIdRef.current) {
|
if (voterId === userIdRef.current) {
|
||||||
|
clearPendingVote(dumpId);
|
||||||
setMyVotes((prev) => {
|
setMyVotes((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (action === "cast") next.add(dumpId);
|
if (action === "cast") next.add(dumpId);
|
||||||
@@ -182,12 +246,7 @@ export function WSProvider(
|
|||||||
|
|
||||||
case "vote_ack": {
|
case "vote_ack": {
|
||||||
const { dumpId, action, voteCount } = msg;
|
const { dumpId, action, voteCount } = msg;
|
||||||
// Clear pending revert timeout
|
clearPendingVote(dumpId);
|
||||||
const timeout = pendingRef.current.get(dumpId);
|
|
||||||
if (timeout !== undefined) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
pendingRef.current.delete(dumpId);
|
|
||||||
}
|
|
||||||
// Reconcile with authoritative count
|
// Reconcile with authoritative count
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||||
// Confirm vote state
|
// Confirm vote state
|
||||||
@@ -272,14 +331,24 @@ export function WSProvider(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
// On error, revert any pending optimistic update for the affected dump
|
// Vote errors currently don't identify which dump/action failed, so
|
||||||
// (the revert timeout will handle it)
|
// fall back to the per-dump timeout rollback instead of guessing.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
if (connectTimeout) {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
connectTimeout = null;
|
||||||
|
}
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
setWSStatus("disconnected");
|
||||||
|
setWSErrorMessage(
|
||||||
|
everConnected
|
||||||
|
? "Live updates are temporarily disconnected. Trying to reconnect..."
|
||||||
|
: "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.",
|
||||||
|
);
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
||||||
connect();
|
connect();
|
||||||
@@ -297,12 +366,15 @@ export function WSProvider(
|
|||||||
return () => {
|
return () => {
|
||||||
closed = true;
|
closed = true;
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
if (connectTimeout) clearTimeout(connectTimeout);
|
||||||
socketRef.current?.close();
|
socketRef.current?.close();
|
||||||
socketRef.current = null;
|
socketRef.current = null;
|
||||||
for (const t of pending.values()) clearTimeout(t);
|
for (const pendingVote of pending.values()) {
|
||||||
|
clearTimeout(pendingVote.timeout);
|
||||||
|
}
|
||||||
pending.clear();
|
pending.clear();
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [clearAllPendingVotes, clearPendingVote, token]);
|
||||||
|
|
||||||
const castVote = useCallback((dumpId: string) => {
|
const castVote = useCallback((dumpId: string) => {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
@@ -317,22 +389,23 @@ export function WSProvider(
|
|||||||
});
|
});
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
||||||
|
|
||||||
// Schedule revert if no ack
|
// Schedule revert if no authoritative confirmation arrives.
|
||||||
const timeout = setTimeout(() => {
|
schedulePendingVote(dumpId, () => {
|
||||||
pendingRef.current.delete(dumpId);
|
|
||||||
setMyVotes((prev) => {
|
setMyVotes((prev) => {
|
||||||
const n = new Set(prev);
|
const n = new Set(prev);
|
||||||
n.delete(dumpId);
|
n.delete(dumpId);
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||||
}, ACK_TIMEOUT);
|
});
|
||||||
pendingRef.current.set(dumpId, timeout);
|
|
||||||
|
|
||||||
socketRef.current?.send(
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage),
|
socketRef.current.send(
|
||||||
);
|
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage),
|
||||||
}, []);
|
);
|
||||||
|
}
|
||||||
|
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||||
|
}, [schedulePendingVote]);
|
||||||
|
|
||||||
const removeVote = useCallback((dumpId: string) => {
|
const removeVote = useCallback((dumpId: string) => {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
@@ -350,24 +423,25 @@ export function WSProvider(
|
|||||||
[dumpId]: Math.max(0, prevCount - 1),
|
[dumpId]: Math.max(0, prevCount - 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Schedule revert if no ack
|
// Schedule revert if no authoritative confirmation arrives.
|
||||||
const timeout = setTimeout(() => {
|
schedulePendingVote(dumpId, () => {
|
||||||
pendingRef.current.delete(dumpId);
|
|
||||||
setMyVotes((prev) => {
|
setMyVotes((prev) => {
|
||||||
const n = new Set(prev);
|
const n = new Set(prev);
|
||||||
n.add(dumpId);
|
n.add(dumpId);
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||||
}, ACK_TIMEOUT);
|
});
|
||||||
pendingRef.current.set(dumpId, timeout);
|
|
||||||
|
|
||||||
socketRef.current?.send(
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
JSON.stringify(
|
socketRef.current.send(
|
||||||
{ type: "vote_remove", dumpId } satisfies OutgoingWSMessage,
|
JSON.stringify(
|
||||||
),
|
{ type: "vote_remove", dumpId } satisfies OutgoingWSMessage,
|
||||||
);
|
),
|
||||||
}, []);
|
);
|
||||||
|
}
|
||||||
|
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||||
|
}, [schedulePendingVote]);
|
||||||
|
|
||||||
const injectDump = useCallback((dump: Dump) => {
|
const injectDump = useCallback((dump: Dump) => {
|
||||||
setRecentDumps((prev) => {
|
setRecentDumps((prev) => {
|
||||||
@@ -381,6 +455,8 @@ export function WSProvider(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: WSContextValue = useMemo(() => ({
|
const value: WSContextValue = useMemo(() => ({
|
||||||
|
wsStatus,
|
||||||
|
wsErrorMessage,
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
@@ -399,6 +475,8 @@ export function WSProvider(
|
|||||||
injectDump,
|
injectDump,
|
||||||
clearUnreadNotifications,
|
clearUnreadNotifications,
|
||||||
}), [
|
}), [
|
||||||
|
wsStatus,
|
||||||
|
wsErrorMessage,
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
|
|||||||
Reference in New Issue
Block a user