v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone
This commit is contained in:
49
src/hooks/useDumpListSync.ts
Normal file
49
src/hooks/useDumpListSync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { useWS } from "./useWS.ts";
|
||||
|
||||
/**
|
||||
* Keeps a dump list in sync with real-time WS events:
|
||||
* - deletedDumpIds: filters out dumps that were deleted or privatised.
|
||||
* - lastDumpEvent: updates existing dumps in-place; optionally prepends
|
||||
* new ones when `addFilter` returns true for them.
|
||||
*
|
||||
* @param setDumps Updater that patches the caller's dump array.
|
||||
* @param addFilter Optional predicate: return true to prepend a dump that
|
||||
* isn't already in the list (e.g. became public).
|
||||
*/
|
||||
export function useDumpListSync(
|
||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||
addFilter?: (dump: Dump) => boolean,
|
||||
): void {
|
||||
const { deletedDumpIds, lastDumpEvent } = useWS();
|
||||
|
||||
// Keep refs up-to-date so closures in effects are never stale.
|
||||
const setDumpsRef = useRef(setDumps);
|
||||
const addFilterRef = useRef(addFilter);
|
||||
useLayoutEffect(() => {
|
||||
setDumpsRef.current = setDumps;
|
||||
addFilterRef.current = addFilter;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
setDumpsRef.current((prev) => prev.filter((d) => !deletedDumpIds.has(d.id)));
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
setDumpsRef.current((prev) => {
|
||||
const idx = prev.findIndex((d) => d.id === lastDumpEvent.id);
|
||||
if (idx !== -1) {
|
||||
const next = [...prev];
|
||||
next[idx] = lastDumpEvent;
|
||||
return next;
|
||||
}
|
||||
if (addFilterRef.current?.(lastDumpEvent)) {
|
||||
return [lastDumpEvent, ...prev];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [lastDumpEvent]);
|
||||
}
|
||||
162
src/hooks/useMentionAutocomplete.ts
Normal file
162
src/hooks/useMentionAutocomplete.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
export interface UserResult {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime: string | null;
|
||||
}
|
||||
|
||||
interface MentionState {
|
||||
open: boolean;
|
||||
query: string;
|
||||
start: number;
|
||||
results: UserResult[];
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
const CLOSED: MentionState = {
|
||||
open: false,
|
||||
query: "",
|
||||
start: 0,
|
||||
results: [],
|
||||
selectedIndex: 0,
|
||||
};
|
||||
|
||||
function getMentionQuery(
|
||||
value: string,
|
||||
pos: number,
|
||||
): { query: string; start: number } | null {
|
||||
const textBefore = value.slice(0, pos);
|
||||
// Match @word at end of text before cursor, not preceded by [ ( or word char
|
||||
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
|
||||
if (!match || match[1].length === 0) return null;
|
||||
const start = pos - match[0].length;
|
||||
return { query: match[1], start };
|
||||
}
|
||||
|
||||
export function useMentionAutocomplete(
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
||||
) {
|
||||
const [state, setState] = useState<MentionState>(CLOSED);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cursorRef = useRef<number>(0);
|
||||
|
||||
const handleMentionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const pos = e.target.selectionStart ?? newValue.length;
|
||||
cursorRef.current = pos;
|
||||
onChange(newValue);
|
||||
|
||||
const mention = getMentionQuery(newValue, pos);
|
||||
if (!mention) {
|
||||
setState(CLOSED);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => ({
|
||||
...s,
|
||||
open: false,
|
||||
query: mention.query,
|
||||
start: mention.start,
|
||||
selectedIndex: 0,
|
||||
}));
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/users/search?q=${encodeURIComponent(mention.query)}`,
|
||||
);
|
||||
const body = await res.json();
|
||||
if (body.success && body.data.length > 0) {
|
||||
setState((s) =>
|
||||
s.query === mention.query
|
||||
? { ...s, open: true, results: body.data, selectedIndex: 0 }
|
||||
: s
|
||||
);
|
||||
} else {
|
||||
setState(CLOSED);
|
||||
}
|
||||
} catch {
|
||||
setState(CLOSED);
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const doSelect = useCallback(
|
||||
(username: string, start: number, cursorPos: number) => {
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(cursorPos);
|
||||
onChange(`${before}@${username} ${after}`);
|
||||
setState(CLOSED);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const newPos = start + username.length + 2; // @ + username + space
|
||||
el.focus();
|
||||
el.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
[value, onChange, textareaRef],
|
||||
);
|
||||
|
||||
const handleMentionKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!state.open || state.results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setState((s) => ({
|
||||
...s,
|
||||
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
|
||||
}));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setState((s) => ({
|
||||
...s,
|
||||
selectedIndex: Math.max(s.selectedIndex - 1, 0),
|
||||
}));
|
||||
} else if (
|
||||
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
const user = state.results[state.selectedIndex];
|
||||
if (user) doSelect(user.username, state.start, cursorRef.current);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setState(CLOSED);
|
||||
}
|
||||
},
|
||||
[state, doSelect],
|
||||
);
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(username: string) => {
|
||||
doSelect(username, state.start, cursorRef.current);
|
||||
},
|
||||
[doSelect, state.start],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mentionOpen: state.open,
|
||||
mentionResults: state.results,
|
||||
mentionSelectedIndex: state.selectedIndex,
|
||||
handleMentionChange,
|
||||
handleMentionKeyDown,
|
||||
handleMentionSelect,
|
||||
};
|
||||
}
|
||||
88
src/hooks/usePlaylistListSync.ts
Normal file
88
src/hooks/usePlaylistListSync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { useWS } from "./useWS.ts";
|
||||
|
||||
interface PlaylistListSyncOptions {
|
||||
/** Keep private playlists visible (caller is the owner). */
|
||||
isOwner?: boolean;
|
||||
/**
|
||||
* Only act on created/updated events for playlists owned by this user.
|
||||
* Leave undefined to act on any playlist already in the list.
|
||||
*/
|
||||
ownerId?: string;
|
||||
/**
|
||||
* Set true for "followed" lists: never prepend newly created playlists
|
||||
* (followed membership is managed separately), but still update/remove.
|
||||
*/
|
||||
noNewEntries?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a playlist list in sync with real-time WS events:
|
||||
* - lastPlaylistEvent "created" → prepend (unless noNewEntries)
|
||||
* - lastPlaylistEvent "updated" → update in-place, or remove if now private
|
||||
* - lastPlaylistEvent "deleted" → remove
|
||||
* - deletedPlaylistIds growing → filter
|
||||
*/
|
||||
export function usePlaylistListSync(
|
||||
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
|
||||
options?: PlaylistListSyncOptions,
|
||||
): void {
|
||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||
|
||||
const setPlaylistsRef = useRef(setPlaylists);
|
||||
const optionsRef = useRef(options);
|
||||
useLayoutEffect(() => {
|
||||
setPlaylistsRef.current = setPlaylists;
|
||||
optionsRef.current = options;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent) return;
|
||||
const { isOwner, ownerId, noNewEntries } = optionsRef.current ?? {};
|
||||
const ev = lastPlaylistEvent;
|
||||
|
||||
setPlaylistsRef.current((prev) => {
|
||||
if (ev.type === "created" && ev.playlist) {
|
||||
if (noNewEntries) return prev;
|
||||
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||
if (prev.some((p) => p.id === ev.playlist!.id)) return prev;
|
||||
return [ev.playlist, ...prev];
|
||||
}
|
||||
|
||||
if (ev.type === "updated" && ev.playlist) {
|
||||
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
|
||||
if (idx === -1) {
|
||||
// Playlist was removed (went private). Re-add if it's now public and
|
||||
// visible to this list. noNewEntries lists (followed) skip this since
|
||||
// we can't verify the follow relationship is still active.
|
||||
if (noNewEntries) return prev;
|
||||
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||
return [ev.playlist, ...prev];
|
||||
}
|
||||
// Remove if it became private and the viewer can't see private playlists
|
||||
if (!ev.playlist.isPublic && !isOwner) {
|
||||
return prev.filter((p) => p.id !== ev.playlist!.id);
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = ev.playlist;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (ev.type === "deleted") {
|
||||
return prev.filter((p) => p.id !== ev.playlistId);
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, [lastPlaylistEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deletedPlaylistIds.size === 0) return;
|
||||
setPlaylistsRef.current((prev) =>
|
||||
prev.filter((p) => !deletedPlaylistIds.has(p.id))
|
||||
);
|
||||
}, [deletedPlaylistIds]);
|
||||
}
|
||||
Reference in New Issue
Block a user