v3: added attachments to resources, allow users to paste images into TextEditor, strengthened WS reliability
This commit is contained in:
29
src/App.css
29
src/App.css
@@ -59,6 +59,12 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.md img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Compact / card mode: strip vertical spacing */
|
||||
.md--inline p,
|
||||
.md--inline ul,
|
||||
@@ -1459,6 +1465,23 @@ body.has-player .fab-new {
|
||||
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 {
|
||||
margin: 2rem auto;
|
||||
max-width: 480px;
|
||||
@@ -3427,6 +3450,12 @@ body.has-player .fab-new {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mention-textarea-wrap--dragover textarea {
|
||||
outline: 2px dashed var(--color-accent, #6c8ebf);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.mention-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||
import { NotificationBell } from "./NotificationBell.tsx";
|
||||
|
||||
@@ -8,6 +9,7 @@ export function AppHeader(
|
||||
{ centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean },
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const { wsStatus, wsErrorMessage } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
@@ -60,6 +62,12 @@ export function AppHeader(
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||
<div className="app-header-status" role="alert">
|
||||
<strong>Live updates unavailable.</strong> {wsErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createModalOpen && (
|
||||
<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 { MentionDropdown } from "./MentionDropdown.tsx";
|
||||
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
focus(): void;
|
||||
@@ -40,6 +49,14 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const emojiViewportRef = useRef<HTMLDivElement>(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, () => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
@@ -76,8 +93,89 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [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 (
|
||||
<div className="mention-textarea-wrap">
|
||||
<div
|
||||
className={`mention-textarea-wrap${dragOver ? " mention-textarea-wrap--dragover" : ""}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
@@ -94,8 +192,12 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
handleMentionKeyDown(e);
|
||||
if (!e.defaultPrevented) onKeyDown?.(e);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
placeholder={uploading ? "Uploading image…" : placeholder}
|
||||
disabled={disabled || uploading}
|
||||
rows={rows}
|
||||
id={id}
|
||||
className={className}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface UserEvent {
|
||||
}
|
||||
|
||||
export interface WSContextValue {
|
||||
wsStatus: "connecting" | "connected" | "disconnected";
|
||||
wsErrorMessage: string | null;
|
||||
onlineUsers: OnlineUser[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
@@ -54,6 +56,8 @@ export interface WSContextValue {
|
||||
}
|
||||
|
||||
export const WSContext = createContext<WSContextValue>({
|
||||
wsStatus: "connecting",
|
||||
wsErrorMessage: null,
|
||||
onlineUsers: [],
|
||||
voteCounts: {},
|
||||
myVotes: new Set(),
|
||||
|
||||
@@ -40,6 +40,12 @@ interface WSProviderProps {
|
||||
|
||||
const MAX_BACKOFF = 30_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
|
||||
// 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(
|
||||
{ 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 [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||
@@ -91,15 +101,46 @@ export function WSProvider(
|
||||
});
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Tracks pending optimistic votes: dumpId → revert timeout ID
|
||||
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
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(() => {
|
||||
let closed = false;
|
||||
let backoff = 500;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let everConnected = false;
|
||||
|
||||
setWSStatus("connecting");
|
||||
setWSErrorMessage(null);
|
||||
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
@@ -110,6 +151,25 @@ export function WSProvider(
|
||||
const ws = new WebSocket(url);
|
||||
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) => {
|
||||
const msg = parseWSMessage(event.data);
|
||||
if (!msg) return;
|
||||
@@ -126,6 +186,9 @@ export function WSProvider(
|
||||
setOnlineUsers(msg.users);
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||
// welcome provides authoritative server state — cancel any
|
||||
// in-flight revert timers, they are now superseded.
|
||||
clearAllPendingVotes();
|
||||
break;
|
||||
|
||||
case "presence_update":
|
||||
@@ -139,6 +202,7 @@ export function WSProvider(
|
||||
// Keep myVotes in sync across tabs: if this vote event belongs to
|
||||
// the current user (from another tab), update myVotes accordingly.
|
||||
if (voterId === userIdRef.current) {
|
||||
clearPendingVote(dumpId);
|
||||
setMyVotes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(dumpId);
|
||||
@@ -182,12 +246,7 @@ export function WSProvider(
|
||||
|
||||
case "vote_ack": {
|
||||
const { dumpId, action, voteCount } = msg;
|
||||
// Clear pending revert timeout
|
||||
const timeout = pendingRef.current.get(dumpId);
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
pendingRef.current.delete(dumpId);
|
||||
}
|
||||
clearPendingVote(dumpId);
|
||||
// Reconcile with authoritative count
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
// Confirm vote state
|
||||
@@ -272,14 +331,24 @@ export function WSProvider(
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// On error, revert any pending optimistic update for the affected dump
|
||||
// (the revert timeout will handle it)
|
||||
// Vote errors currently don't identify which dump/action failed, so
|
||||
// fall back to the per-dump timeout rollback instead of guessing.
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (connectTimeout) {
|
||||
clearTimeout(connectTimeout);
|
||||
connectTimeout = null;
|
||||
}
|
||||
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(() => {
|
||||
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
||||
connect();
|
||||
@@ -297,12 +366,15 @@ export function WSProvider(
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (connectTimeout) clearTimeout(connectTimeout);
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
for (const t of pending.values()) clearTimeout(t);
|
||||
for (const pendingVote of pending.values()) {
|
||||
clearTimeout(pendingVote.timeout);
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
}, [token]);
|
||||
}, [clearAllPendingVotes, clearPendingVote, token]);
|
||||
|
||||
const castVote = useCallback((dumpId: string) => {
|
||||
// Optimistic update
|
||||
@@ -317,22 +389,23 @@ export function WSProvider(
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
||||
|
||||
// Schedule revert if no ack
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRef.current.delete(dumpId);
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
});
|
||||
|
||||
socketRef.current?.send(
|
||||
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage),
|
||||
);
|
||||
}, []);
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
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) => {
|
||||
// Optimistic update
|
||||
@@ -350,24 +423,25 @@ export function WSProvider(
|
||||
[dumpId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no ack
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRef.current.delete(dumpId);
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
});
|
||||
|
||||
socketRef.current?.send(
|
||||
JSON.stringify(
|
||||
{ type: "vote_remove", dumpId } satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
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) => {
|
||||
setRecentDumps((prev) => {
|
||||
@@ -381,6 +455,8 @@ export function WSProvider(
|
||||
}, []);
|
||||
|
||||
const value: WSContextValue = useMemo(() => ({
|
||||
wsStatus,
|
||||
wsErrorMessage,
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
@@ -399,6 +475,8 @@ export function WSProvider(
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
}), [
|
||||
wsStatus,
|
||||
wsErrorMessage,
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
|
||||
Reference in New Issue
Block a user