v3: fix the frontend's findings, and the backend pieces they needed — each by a rule the code now keeps everywhere. Votes and likes go through one useOptimisticToggles in WSProvider: clicks on one item before any ack share one baseline, which a failure or the timeout restores (a second click used to cancel the first one's rollback), only the last ack's count is applied, a votes_update or comment_likes_update waits while this tab has toggles in flight, and a welcome reconciles them against the server's sets (a vote cast offline left its count one too high). lastLikeEvent, which nothing read, is gone. Follows are refetched when the new follows_changed message, sent to the follower's sockets on every follow write, says another tab changed them; a failed load keeps the Follow buttons disabled and is retried 10 s later instead of leaving them disabled for good (success: false) or enabled over empty sets (network error); follow and unfollow resolve to whether they worked, and the button says when they didn't. CategoriesProvider follows the same rule, so a network error no longer turns every category URL into a 404. Every list is kept by one rule: useDumpListSync and usePlaylistListSync never add an item, and usePositionAwareSync remembers the place of any item that leaves a list, whatever took it out, and puts it back when its next event shows it again; with dump_created now setting lastDumpEvent, a dump going private and back returns to its place on another user's profile, which never happened before, and an updated playlist missing from a list is no longer prepended. useFeedCache keys every cache by viewer (the profile pages restored the owner's private dumps to whoever went back) and reads it again when its key changes (another profile in the same page showed the previous one's list). Every response of a list's first page or next pages goes through expectOk (Index, UserPlaylists, useUserDumpFeed and Notifications read body.data unchecked).
Smaller fixes: the redirects from / and /<slug> keep the navigation state, so the index hides the dump DumpEdit just deleted; Index's mainFetchDone ref, which blocked nothing, is gone; DumpEdit refuses someone else's dump up front, as the API does; the Dump page takes a handed-over dump once and clears it from the history entry, so a reload fetches; a newly upvoted dump is fetched with the viewer's token; the notifications page sends read-all once per notification; the profile's followed and invitees tabs finish loading when left mid-fetch; UserListPopover no longer closes when its own list scrolls; the vote and like counts open their lists for visitors too (a disabled button swallowed the click; they are aria-disabled now); the removal of a playlist's dump forgets it (an edit brought it back), a failed removal puts it back where it was, an undo waits for the removal in flight, and a refused undo says so; PlaylistCreateForm tells whether the dump made it into the new playlist; the cover preview's object URL is revoked; the muted icon has its cross; the bell no longer stays rung after two notifications within 700 ms; a stale invite check can't overwrite a newer one; a stored theme or color scheme is checked; the default feed tab is one store every reader follows, across tabs; the file fields enforce the size they state, derived from DUMP_MAX_FILE_SIZE_BYTES; formatBytes, friendlyFetchError and ImagePicker are translated (Intl names the units), and ImagePicker ignores keys while uploading; visited dumps and playlists keep a week of visits and are parsed once; a chime is claimed under a Web Lock, so two tabs can't both play it; a date in the future reads as "now". Dead code goes: PageShell's centerSlot, overLimit, the exports of isSafeHex and hueFromString, the comment tree's token prop and ChatContext's isChatOpen and closeChat. The French catalogue has the 11 new messages. 214 backend and 632 frontend tests pass; tsc, deno lint, ESLint and doc:lint are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,10 +25,11 @@ import { isFollowRow } from "../model/follow.ts";
|
||||
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { attachCategoryIds } from "./category-service.ts";
|
||||
import { sendToUser } from "./ws-service.ts";
|
||||
|
||||
/**
|
||||
* Makes `followerId` follow `followedUserId`, and notifies the followed user.
|
||||
* Following again changes nothing.
|
||||
* Following again changes nothing. The follower's other tabs are told.
|
||||
*
|
||||
* @throws {APIException} 400 when a user tries to follow themselves, 404 when
|
||||
* there is no such user.
|
||||
@@ -61,6 +62,7 @@ export function followUser(followerId: string, followedUserId: string): void {
|
||||
}
|
||||
}
|
||||
if (isNew) notifyUserNewFollower(followerId, followedUserId);
|
||||
sendToUser(followerId, { type: "follows_changed" });
|
||||
}
|
||||
|
||||
/** Makes `followerId` stop following `followedUserId`, if they did. */
|
||||
@@ -68,6 +70,7 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
|
||||
db.prepare(
|
||||
`DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`,
|
||||
).run(followerId, followedUserId);
|
||||
sendToUser(followerId, { type: "follows_changed" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +114,7 @@ export function followPlaylist(followerId: string, playlistId: string): void {
|
||||
}
|
||||
}
|
||||
if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId);
|
||||
sendToUser(followerId, { type: "follows_changed" });
|
||||
}
|
||||
|
||||
/** Makes `followerId` stop following a playlist, if they did. */
|
||||
@@ -118,6 +122,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
|
||||
db.prepare(
|
||||
`DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`,
|
||||
).run(followerId, playlistId);
|
||||
sendToUser(followerId, { type: "follows_changed" });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,7 +129,8 @@ function broadcast(
|
||||
|
||||
/**
|
||||
* Sends `data` to every socket of `userId`. The notification service sends
|
||||
* `notification_created` with it, and the route `chat_read_update`.
|
||||
* `notification_created` with it, the follow service `follows_changed`, and
|
||||
* the route `chat_read_update`.
|
||||
*/
|
||||
export function sendToUser(userId: string, data: ServerToClientMessage): void {
|
||||
broadcast(data, (client) => client.userId === userId);
|
||||
|
||||
30
shared/ws.ts
30
shared/ws.ts
@@ -249,9 +249,10 @@ export interface CommentLikeAckMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by the server to every socket when a public dump is posted, or when a
|
||||
* private one goes public. `WSProvider` puts it at the top of its recent
|
||||
* dumps.
|
||||
* Sent by the server when a dump is posted: to every socket if it is public,
|
||||
* to its poster's alone if private. Also sent to everyone but the poster when
|
||||
* a private dump goes public, since to them it is new. `WSProvider` puts it at
|
||||
* the top of its recent dumps.
|
||||
*/
|
||||
export interface DumpCreatedMessage {
|
||||
type: "dump_created";
|
||||
@@ -259,8 +260,9 @@ export interface DumpCreatedMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by the server to every socket when a public dump changes. `WSProvider`
|
||||
* refreshes it in place, without moving it up the feeds.
|
||||
* Sent by the server when a dump changes: to every socket if it is public, to
|
||||
* its poster's alone if private, as when it has just gone private.
|
||||
* `WSProvider` refreshes it in place, without moving it up the feeds.
|
||||
*/
|
||||
export interface DumpUpdatedMessage {
|
||||
type: "dump_updated";
|
||||
@@ -268,8 +270,10 @@ export interface DumpUpdatedMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by the server to every socket when a dump is deleted, or when a public
|
||||
* one goes private. `WSProvider` drops it from every list.
|
||||
* Sent by the server when a dump is deleted: to every socket if it was
|
||||
* public, to its poster's alone if private. Also sent to everyone but the
|
||||
* poster when a public dump goes private. `WSProvider` drops it from every
|
||||
* list.
|
||||
*/
|
||||
export interface DumpDeletedMessage {
|
||||
type: "dump_deleted";
|
||||
@@ -439,6 +443,15 @@ export interface ChatReadUpdateMessage {
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by the server to every socket of a user who followed or unfollowed a
|
||||
* user or a playlist, so that `FollowProvider` in their other tabs refetches
|
||||
* the follow status.
|
||||
*/
|
||||
export interface FollowsChangedMessage {
|
||||
type: "follows_changed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by `services/ws-service.ts` and the WebSocket route, and handled by
|
||||
* `WSProvider`'s `onmessage`.
|
||||
@@ -469,4 +482,5 @@ export type ServerToClientMessage =
|
||||
| ChatMessageUpdatedMessage
|
||||
| ChatMessageDeletedMessage
|
||||
| ChatTypingUpdateMessage
|
||||
| ChatReadUpdateMessage;
|
||||
| ChatReadUpdateMessage
|
||||
| FollowsChangedMessage;
|
||||
|
||||
20
src/App.css
20
src/App.css
@@ -1916,7 +1916,7 @@ body.has-player .chat-fab {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.vote-btn:hover:not(:disabled) {
|
||||
.vote-btn:hover:not([aria-disabled="true"]) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
@@ -1927,7 +1927,7 @@ body.has-player .chat-fab {
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.vote-btn--active:hover:not(:disabled) {
|
||||
.vote-btn--active:hover:not([aria-disabled="true"]) {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
color: var(--color-on-accent);
|
||||
@@ -1942,7 +1942,7 @@ body.has-player .chat-fab {
|
||||
color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.vote-btn:disabled {
|
||||
.vote-btn[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -2281,6 +2281,12 @@ body.has-player .chat-fab {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.follow-error {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Profile description ── */
|
||||
.profile-description {
|
||||
width: 100%;
|
||||
@@ -5523,7 +5529,11 @@ body.has-player .chat-fab {
|
||||
var(--dur-fast);
|
||||
}
|
||||
|
||||
.comment-action-btn:hover {
|
||||
.comment-action-btn[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.comment-action-btn:not([aria-disabled="true"]):hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border-subtle);
|
||||
background: color-mix(in srgb, var(--color-text) 6%, transparent);
|
||||
@@ -6100,7 +6110,7 @@ body.has-player .chat-fab {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) {
|
||||
.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
25
src/App.tsx
25
src/App.tsx
@@ -121,22 +121,33 @@ function useResolvedDefaultTab() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects `/` to the default feed tab. The query string must survive: the
|
||||
* web app manifest's share target opens `/?share_url=…` for an Android share,
|
||||
* and only the feed acts on it.
|
||||
* Redirects `/` to the default feed tab. The query string and the navigation
|
||||
* state must survive: the web app manifest's share target opens
|
||||
* `/?share_url=…` for an Android share, and the edit page hands the feed the
|
||||
* id of a dump it just deleted.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { search } = useLocation();
|
||||
return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
|
||||
const { search, state } = useLocation();
|
||||
return (
|
||||
<Navigate
|
||||
to={`/~/${useResolvedDefaultTab()}${search}`}
|
||||
state={state}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects a bare `/<slug>` to that category's default feed tab. */
|
||||
/**
|
||||
* Redirects a bare `/<slug>` to that category's default feed tab, keeping the
|
||||
* query string and the navigation state as {@link IndexRedirect} does.
|
||||
*/
|
||||
function CategoryRedirect() {
|
||||
const { categorySlug } = useParams();
|
||||
const { search } = useLocation();
|
||||
const { search, state } = useLocation();
|
||||
return (
|
||||
<Navigate
|
||||
to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`}
|
||||
state={state}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,8 +44,6 @@ export interface CommentNodeProps {
|
||||
depth: number;
|
||||
dumpId: string;
|
||||
currentUser: SelfUser | null;
|
||||
/** Only checked for being set before a delete. */
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
@@ -63,7 +61,6 @@ export function CommentNode({
|
||||
depth,
|
||||
dumpId,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
@@ -96,7 +93,6 @@ export function CommentNode({
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!token) return;
|
||||
setDeleteFailed(false);
|
||||
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||
method: "DELETE",
|
||||
@@ -157,7 +153,6 @@ export function CommentNode({
|
||||
depth={depth + 1}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
@@ -312,7 +307,6 @@ export function CommentNode({
|
||||
depth={depth + 1}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface CommentThreadProps {
|
||||
*/
|
||||
comments: Comment[];
|
||||
currentUser: SelfUser | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
@@ -59,7 +58,6 @@ export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
@@ -117,7 +115,6 @@ export function CommentThread({
|
||||
depth={0}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
onCommentUpdated={onCommentUpdated}
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
import { useAuth } from "../../../hooks/auth/useAuth.ts";
|
||||
import { useWS } from "../../../hooks/ws/useWS.ts";
|
||||
import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
|
||||
import { titleFromFileName } from "../../../utils/shared/format.ts";
|
||||
import {
|
||||
formatBytes,
|
||||
titleFromFileName,
|
||||
} from "../../../utils/shared/format.ts";
|
||||
import {
|
||||
clearDumpDraft,
|
||||
type DumpDraft,
|
||||
@@ -413,7 +416,8 @@ export function DumpCreateModal(
|
||||
} else {
|
||||
if (!title.trim()) throw new Error(t`Title is required.`);
|
||||
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
|
||||
throw new Error(t`File too large (max 50 MB).`);
|
||||
const max = formatBytes(DUMP_MAX_FILE_SIZE_BYTES);
|
||||
throw new Error(t`File too large (max ${max}).`);
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
@@ -683,6 +687,7 @@ export function DumpCreateModal(
|
||||
onValueChange={selectFile}
|
||||
disabled={submitting}
|
||||
hint={t`Drop a file here, or paste one`}
|
||||
maxBytes={DUMP_MAX_FILE_SIZE_BYTES}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
/**
|
||||
* Passed to {@link FollowedFeed} by `Index`, with the vote state and deleted
|
||||
* dumps of `WSContext`. Unlike the other tabs, it loads its own dumps.
|
||||
* Passed to {@link FollowedFeed} by `Index`, with the vote state of
|
||||
* `WSContext`. Unlike the other tabs, it loads its own dumps.
|
||||
*/
|
||||
export interface FollowedFeedProps {
|
||||
/** Scopes both sections to a category. */
|
||||
@@ -66,7 +66,6 @@ export interface FollowedFeedProps {
|
||||
user: SelfUser;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
deletedDumpIds: Set<string>;
|
||||
}
|
||||
|
||||
interface FollowedSubFeedProps {
|
||||
@@ -76,7 +75,6 @@ interface FollowedSubFeedProps {
|
||||
user: SelfUser;
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
deletedDumpIds: Set<string>;
|
||||
emptyMessage: string;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
@@ -89,7 +87,6 @@ function FollowedSubFeed({
|
||||
user,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
emptyMessage,
|
||||
onLoadMore,
|
||||
}: FollowedSubFeedProps) {
|
||||
@@ -108,16 +105,14 @@ function FollowedSubFeed({
|
||||
return <ErrorCard title={t`Failed to load`} message={state.error} />;
|
||||
}
|
||||
|
||||
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
|
||||
|
||||
if (visible.length === 0) {
|
||||
if (state.dumps.length === 0) {
|
||||
return <p className="index-status">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="dump-feed">
|
||||
{visible.map((dump) => (
|
||||
{state.dumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
@@ -158,7 +153,6 @@ export function FollowedFeed({
|
||||
user,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
}: FollowedFeedProps): React.JSX.Element {
|
||||
const { token } = useAuth();
|
||||
|
||||
@@ -168,13 +162,13 @@ export function FollowedFeed({
|
||||
: "";
|
||||
|
||||
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
|
||||
`feed:followed-users:${scope}:${user.id}`,
|
||||
`feed:followed-users:${scope}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
||||
Dump
|
||||
>(
|
||||
`feed:followed-playlists:${scope}:${user.id}`,
|
||||
`feed:followed-playlists:${scope}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
@@ -268,22 +262,24 @@ export function FollowedFeed({
|
||||
}
|
||||
}, [token, usersState.status, playlistsState.status, catQuery]);
|
||||
|
||||
// Only the lists are kept here: `Index` saves and restores the page's scroll
|
||||
// for every tab, this one included.
|
||||
useScrollSave(
|
||||
usersState.status === "loaded",
|
||||
useCallback((y) => {
|
||||
useCallback(() => {
|
||||
if (usersState.status !== "loaded") return;
|
||||
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
|
||||
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, 0);
|
||||
}, [usersState, saveUsers]),
|
||||
);
|
||||
useScrollSave(
|
||||
playlistsState.status === "loaded",
|
||||
useCallback((y) => {
|
||||
useCallback(() => {
|
||||
if (playlistsState.status !== "loaded") return;
|
||||
savePlaylists(
|
||||
playlistsState.dumps,
|
||||
playlistsState.page,
|
||||
playlistsState.hasMore,
|
||||
y,
|
||||
0,
|
||||
);
|
||||
}, [playlistsState, savePlaylists]),
|
||||
);
|
||||
@@ -376,7 +372,6 @@ export function FollowedFeed({
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
emptyMessage={t`Follow some users to see their dumps here.`}
|
||||
onLoadMore={loadMoreUsers}
|
||||
/>
|
||||
@@ -390,7 +385,6 @@ export function FollowedFeed({
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
emptyMessage={t`Follow some public playlists to see their dumps here.`}
|
||||
onLoadMore={loadMorePlaylists}
|
||||
/>
|
||||
|
||||
@@ -32,9 +32,10 @@ export interface FileDropZoneProps {
|
||||
/** Replaces the "Drop a file here" prompt. */
|
||||
hint?: string;
|
||||
/**
|
||||
* Shows "Max 50 MB", which is written out rather than read from
|
||||
* `DUMP_MAX_FILE_SIZE_BYTES`. The zone doesn't enforce it.
|
||||
* Stated under the prompt when `showLimit` is on. `FileField` refuses a
|
||||
* larger file.
|
||||
*/
|
||||
maxBytes?: number;
|
||||
showLimit?: boolean;
|
||||
}
|
||||
|
||||
@@ -48,9 +49,11 @@ export function FileDropZone({
|
||||
onChange,
|
||||
disabled,
|
||||
hint,
|
||||
maxBytes,
|
||||
showLimit = true,
|
||||
}: FileDropZoneProps): React.JSX.Element {
|
||||
const resolvedHint = hint ?? t`Drop a file here`;
|
||||
const limit = maxBytes === undefined ? null : formatBytes(maxBytes);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
@@ -161,9 +164,9 @@ export function FileDropZone({
|
||||
or <span className="fdz__browse-link">browse files</span>
|
||||
</Trans>
|
||||
</p>
|
||||
{showLimit && (
|
||||
{showLimit && limit && (
|
||||
<p className="fdz__limit">
|
||||
<Trans>Max 50 MB</Trans>
|
||||
<Trans>Max {limit}</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
import { formatBytes } from "../../utils/shared/format.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link FileField} by a dump form. The field it names holds the
|
||||
@@ -23,6 +25,9 @@ export interface FileFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
/** Refuses a larger file, as a field error, as soon as it is picked. */
|
||||
maxBytes?: number;
|
||||
/** States `maxBytes` in the drop zone. */
|
||||
showLimit?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
@@ -41,13 +46,22 @@ export function FileField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
maxBytes,
|
||||
showLimit,
|
||||
disabled,
|
||||
rules,
|
||||
onValueChange,
|
||||
}: FileFieldProps<T>): React.JSX.Element {
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const { control, formState: { errors }, trigger } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
const fitsLimit = (file: File | null) => {
|
||||
if (maxBytes === undefined || !file || file.size <= maxBytes) return true;
|
||||
const limit = formatBytes(maxBytes);
|
||||
return t`That file is over ${limit}.`;
|
||||
};
|
||||
const validate = typeof rules?.validate === "function"
|
||||
? { custom: rules.validate, fitsLimit }
|
||||
: { ...rules?.validate, fitsLimit };
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
@@ -55,15 +69,18 @@ export function FileField<T extends FieldValues>({
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
rules={{ ...rules, validate } as RegisterOptions<T, Path<T>>}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={(field.value as File | null) ?? null}
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
onValueChange?.(file);
|
||||
// Said at once, not on submit.
|
||||
trigger(name);
|
||||
}}
|
||||
hint={hint}
|
||||
maxBytes={maxBytes}
|
||||
showLimit={showLimit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,11 @@ export function NotificationBell(): React.JSX.Element {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [ringing, setRinging] = useState(false);
|
||||
// The ring's timers outlive the effect that starts them: a notification
|
||||
// arriving mid-ring must not cancel its end, or the bell stays rung.
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const animatingRef = useRef(false);
|
||||
useEffect(() => () => timersRef.current.forEach(clearTimeout), []);
|
||||
// Each page mounts its own header, so a notification already in context
|
||||
// must not ring the bell again after a navigation.
|
||||
const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null);
|
||||
@@ -29,15 +33,13 @@ export function NotificationBell(): React.JSX.Element {
|
||||
|
||||
if (animatingRef.current) return;
|
||||
animatingRef.current = true;
|
||||
const tStart = setTimeout(() => setRinging(true), 0);
|
||||
const tEnd = setTimeout(() => {
|
||||
setRinging(false);
|
||||
animatingRef.current = false;
|
||||
}, 700);
|
||||
return () => {
|
||||
clearTimeout(tStart);
|
||||
clearTimeout(tEnd);
|
||||
};
|
||||
timersRef.current = [
|
||||
setTimeout(() => setRinging(true), 0),
|
||||
setTimeout(() => {
|
||||
setRinging(false);
|
||||
animatingRef.current = false;
|
||||
}, 700),
|
||||
];
|
||||
}, [lastNotification]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,17 +13,15 @@ export interface PageShellProps {
|
||||
children: ReactNode;
|
||||
/** Centers the content, for a lone card such as a form or an error. */
|
||||
centered?: boolean;
|
||||
/** Passed on to `AppHeader`. */
|
||||
centerSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/** Renders {@link AppHeader}, then the page's content as the `<main>`. */
|
||||
export function PageShell(
|
||||
{ children, centered = false, centerSlot }: PageShellProps,
|
||||
{ children, centered = false }: PageShellProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<AppHeader centerSlot={centerSlot} />
|
||||
<AppHeader />
|
||||
<main
|
||||
className={`page-content${centered ? " page-content--centered" : ""}`}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,17 @@ export const IconVolume = (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
<>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" />
|
||||
{/* The cross is stroked: the icon's other shapes are fills. */}
|
||||
<path
|
||||
d="M16 9l5 6M21 9l-5 6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
||||
|
||||
@@ -33,7 +33,8 @@ import { VisibilityToggle } from "../form/SegmentedField.tsx";
|
||||
export interface PlaylistCreateFormProps {
|
||||
/** Added to the new playlist, which the submit button says. */
|
||||
dumpId?: string;
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
/** `hasDump` tells whether `dumpId` made it into the new playlist. */
|
||||
onCreated: (playlist: Playlist, hasDump: boolean) => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -46,7 +47,8 @@ interface Values {
|
||||
|
||||
/**
|
||||
* Renders the form that creates a playlist with `POST /api/playlists`, then
|
||||
* adds `dumpId` to it when given. A failure to add the dump goes unreported.
|
||||
* adds `dumpId` to it when given. The playlist exists even if adding the dump
|
||||
* fails, so `onCreated` runs either way and says which.
|
||||
*/
|
||||
export function PlaylistCreateForm(
|
||||
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
|
||||
@@ -72,13 +74,15 @@ export function PlaylistCreateForm(
|
||||
}),
|
||||
),
|
||||
);
|
||||
let hasDump = false;
|
||||
if (dumpId) {
|
||||
await authFetch(
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
).catch(() => null);
|
||||
hasDump = !!res?.ok;
|
||||
}
|
||||
onCreated(playlist);
|
||||
onCreated(playlist, hasDump);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -91,8 +91,8 @@ export function PlaylistMembershipPanel({
|
||||
<PlaylistCreateForm
|
||||
className="animate-in"
|
||||
dumpId={dumpId}
|
||||
onCreated={(playlist) => {
|
||||
onPlaylistCreated({ playlist, hasDump: true });
|
||||
onCreated={(playlist, hasDump) => {
|
||||
onPlaylistCreated({ playlist, hasDump });
|
||||
setShowNewForm(false);
|
||||
}}
|
||||
onCancel={() => setShowNewForm(false)}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/**
|
||||
* Passed to {@link ImagePicker} by an edit page, which uploads the file and
|
||||
@@ -43,6 +44,9 @@ export function ImagePicker({
|
||||
}: ImagePickerProps): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const sizeStyle = { width: size, height: size, borderRadius };
|
||||
const pick = () => {
|
||||
if (!uploading) inputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -55,12 +59,16 @@ export function ImagePicker({
|
||||
<div
|
||||
className={className ? `img-picker ${className}` : "img-picker"}
|
||||
style={sizeStyle}
|
||||
onClick={() => !uploading && inputRef.current?.click()}
|
||||
title={src ? "Change image" : "Add image"}
|
||||
onClick={pick}
|
||||
title={src ? t`Change image` : t`Add image`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={uploading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
// Or Space would also scroll the page.
|
||||
e.preventDefault();
|
||||
pick();
|
||||
}}
|
||||
>
|
||||
{src
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Renders the Follow buttons of a user's profile and of a playlist's page.
|
||||
* Both go through `FollowContext`, which follows at once and rolls back when
|
||||
* the server refuses.
|
||||
* the request fails; the button then says so beside it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
@@ -35,24 +36,33 @@ export function FollowUserButton(
|
||||
): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!user || user.id === targetUserId) return null;
|
||||
|
||||
const isFollowing = followedUserIds.has(targetUserId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||
disabled={!isLoaded}
|
||||
onClick={() =>
|
||||
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
|
||||
aria-label={isFollowing
|
||||
? t`Unfollow ${targetUsername}`
|
||||
: t`Follow ${targetUsername}`}
|
||||
>
|
||||
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||
disabled={!isLoaded}
|
||||
onClick={async () => {
|
||||
setFailed(false);
|
||||
const ok = await (isFollowing
|
||||
? unfollowUser(targetUserId)
|
||||
: followUser(targetUserId));
|
||||
setFailed(!ok);
|
||||
}}
|
||||
aria-label={isFollowing
|
||||
? t`Unfollow ${targetUsername}`
|
||||
: t`Follow ${targetUsername}`}
|
||||
>
|
||||
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
|
||||
</button>
|
||||
{failed && <FollowError />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,23 +78,38 @@ export function FollowPlaylistButton(
|
||||
const { user } = useAuth();
|
||||
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
|
||||
useFollows();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!user || !isPublic) return null;
|
||||
|
||||
const isFollowing = followedPlaylistIds.has(targetPlaylistId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||
disabled={!isLoaded}
|
||||
onClick={() =>
|
||||
isFollowing
|
||||
? unfollowPlaylist(targetPlaylistId)
|
||||
: followPlaylist(targetPlaylistId)}
|
||||
aria-label={isFollowing ? t`Unfollow playlist` : t`Follow playlist`}
|
||||
>
|
||||
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||
disabled={!isLoaded}
|
||||
onClick={async () => {
|
||||
setFailed(false);
|
||||
const ok = await (isFollowing
|
||||
? unfollowPlaylist(targetPlaylistId)
|
||||
: followPlaylist(targetPlaylistId));
|
||||
setFailed(!ok);
|
||||
}}
|
||||
aria-label={isFollowing ? t`Unfollow playlist` : t`Follow playlist`}
|
||||
>
|
||||
{isFollowing ? <Trans>Following</Trans> : <Trans>Follow</Trans>}
|
||||
</button>
|
||||
{failed && <FollowError />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowError(): React.JSX.Element {
|
||||
return (
|
||||
<span className="follow-error" role="alert">
|
||||
<Trans>Couldn't update. Try again.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,9 +44,13 @@ export function LikeButton(
|
||||
className={`comment-action-btn${
|
||||
liked ? " comment-action-btn--liked" : ""
|
||||
}`}
|
||||
onClick={() =>
|
||||
liked ? onUnlike(commentId, count) : onLike(commentId, count)}
|
||||
disabled={disabled}
|
||||
// Not `disabled`, as for VoteButton: its count opens the likers.
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (liked) onUnlike(commentId, count);
|
||||
else onLike(commentId, count);
|
||||
}}
|
||||
aria-disabled={disabled}
|
||||
aria-label={liked ? t`Remove like` : t`Like`}
|
||||
title={disabled ? t`Log in to like` : undefined}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { PublicUser } from "../../../shared/user.ts";
|
||||
*/
|
||||
export interface UserListPopoverProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Called on an outside click, on Escape, and on any scroll. */
|
||||
/** Called on an outside click, on Escape, and on a scroll outside it. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* A paginated endpoint answering `PaginatedData<PublicUser>`, without a query
|
||||
@@ -120,8 +120,12 @@ export function UserListPopover(
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
// Captured, so a scroll inside any container closes it too.
|
||||
function onScroll() {
|
||||
// Captured, so a scroll inside any container closes it too, but not one
|
||||
// of its own list, which scrolls through a long list of users.
|
||||
function onScroll(e: Event) {
|
||||
if (e.target instanceof Node && popoverRef.current?.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
|
||||
@@ -41,8 +41,14 @@ export function VoteButton(
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||
onClick={() => voted ? onRemove(dumpId, count) : onCast(dumpId, count)}
|
||||
disabled={disabled}
|
||||
// Not `disabled`: a disabled button swallows its count's clicks, and
|
||||
// visitors may open the list of voters too.
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (voted) onRemove(dumpId, count);
|
||||
else onCast(dumpId, count);
|
||||
}}
|
||||
aria-disabled={disabled}
|
||||
aria-label={voted ? t`Remove vote` : t`Upvote`}
|
||||
title={disabled ? t`Log in to vote` : undefined}
|
||||
>
|
||||
|
||||
@@ -27,8 +27,8 @@ export interface AuthProviderProps {
|
||||
/**
|
||||
* Provides {@link AuthContext}. The session starts as the stored one and
|
||||
* follows it when another tab changes it. The server checks it on mount and
|
||||
* whenever the tab regains focus: a 401 ends it, and a changed role replaces
|
||||
* the stored user. A session from before media tokens gets one.
|
||||
* whenever the tab regains focus: a 401 ends it, and a user changed on the
|
||||
* server replaces the stored one. A session from before media tokens gets one.
|
||||
*/
|
||||
export function AuthProvider(
|
||||
{ children }: AuthProviderProps,
|
||||
@@ -70,15 +70,19 @@ export function AuthProvider(
|
||||
setAuthResponse((prev) => prev?.token === token ? null : prev);
|
||||
return;
|
||||
}
|
||||
// The stored user dates from sign-in, and an admin may have changed
|
||||
// the role since. Only a role change replaces it, so an unchanged
|
||||
// session keeps its identity and doesn't re-render the app.
|
||||
// The stored user dates from sign-in, and the role, avatar or email
|
||||
// may have changed since, by an admin or in another browser. Only a
|
||||
// change replaces it, so an unchanged session keeps its identity and
|
||||
// doesn't re-render the app.
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json?.success && json.data) {
|
||||
const freshUser = deserializeSelfUser(json.data);
|
||||
setAuthResponse((prev) => {
|
||||
if (!prev || prev.user.role === freshUser.role) return prev;
|
||||
if (
|
||||
!prev ||
|
||||
JSON.stringify(prev.user) === JSON.stringify(freshUser)
|
||||
) return prev;
|
||||
const updated = { ...prev, user: freshUser };
|
||||
storeSession(updated);
|
||||
return updated;
|
||||
|
||||
@@ -20,20 +20,20 @@ import { API_URL } from "../../config/api.ts";
|
||||
import type { Category } from "../../../shared/category.ts";
|
||||
import { deserializeCategory } from "../../utils/shared/deserialize.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
|
||||
/** How long a failed first fetch waits before it is tried again. */
|
||||
const RETRY_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Returns every category, or null when the API answers a failure.
|
||||
* Returns every category.
|
||||
*
|
||||
* @throws {Error} When the request fails or answers an error status.
|
||||
* @throws {Error} When the request fails or the API answers a failure.
|
||||
*/
|
||||
async function fetchCategories(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Category[] | null> {
|
||||
async function fetchCategories(signal?: AbortSignal): Promise<Category[]> {
|
||||
const res = await fetch(`${API_URL}/api/categories`, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
if (!body.success) return null;
|
||||
return (body.data as Wire<Category>[]).map(deserializeCategory);
|
||||
const data = await expectOk<Wire<Category>[]>(res);
|
||||
return data.map(deserializeCategory);
|
||||
}
|
||||
|
||||
/** Passed to {@link CategoriesProvider} by `App`. */
|
||||
@@ -42,35 +42,44 @@ export interface CategoriesProviderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link CategoriesContext}, fetching the categories on mount. A
|
||||
* failed fetch keeps the previous list.
|
||||
* Provides {@link CategoriesContext}, fetching the categories on mount. Until
|
||||
* a fetch succeeds, `isLoaded` stays false, so that no page takes an empty
|
||||
* list for the truth and answers a category's URL with a 404; a failed first
|
||||
* fetch is tried again 10 s later. A failed refresh keeps the previous list.
|
||||
*/
|
||||
export function CategoriesProvider(
|
||||
{ children }: CategoriesProviderProps,
|
||||
): React.JSX.Element {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) =>
|
||||
fetchCategories(signal)
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
const load = useCallback(
|
||||
(signal?: AbortSignal) =>
|
||||
fetchCategories(signal).then((data) => {
|
||||
setCategories(data);
|
||||
setIsLoaded(true);
|
||||
})
|
||||
// A failed fetch still ends the loading, so that pages waiting on it
|
||||
// don't wait forever.
|
||||
.catch((err) => {
|
||||
if ((err as Error).name !== "AbortError") setIsLoaded(true);
|
||||
}), []);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded) return;
|
||||
const controller = new AbortController();
|
||||
load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
load(controller.signal).catch((err) => {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
retry = setTimeout(() => setAttempt((n) => n + 1), RETRY_MS);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(retry);
|
||||
};
|
||||
// isLoaded only stops the retries once a fetch has worked.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load, attempt]);
|
||||
|
||||
const refresh = useCallback(() => load(), [load]);
|
||||
const refresh = useCallback(() => load().catch(() => {}), [load]);
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(categories.map((c) => [c.id, c])),
|
||||
|
||||
@@ -8,22 +8,16 @@
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** Provided by `ChatProvider` through {@link ChatContext}. */
|
||||
/**
|
||||
* Provided by `ChatProvider` through {@link ChatContext}. The modal closes
|
||||
* itself, so opening is all its callers need.
|
||||
*/
|
||||
export interface ChatContextValue {
|
||||
/** Never true for a visitor, whatever `openChat` or the URL asked. */
|
||||
isChatOpen: boolean;
|
||||
/** Does nothing for a visitor. */
|
||||
openChat: () => void;
|
||||
closeChat: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the chat modal's open state. Outside `ChatProvider` it is closed and
|
||||
* its functions do nothing.
|
||||
*/
|
||||
/** Opens the chat modal. Outside `ChatProvider` it does nothing. */
|
||||
export const ChatContext: React.Context<ChatContextValue> = createContext<
|
||||
ChatContextValue
|
||||
>({
|
||||
isChatOpen: false,
|
||||
openChat: () => {},
|
||||
closeChat: () => {},
|
||||
});
|
||||
>({ openChat: () => {} });
|
||||
|
||||
@@ -56,10 +56,7 @@ export function ChatProvider(
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const value: ChatContextValue = useMemo(
|
||||
() => ({ isChatOpen, openChat, closeChat }),
|
||||
[isChatOpen, openChat, closeChat],
|
||||
);
|
||||
const value: ChatContextValue = useMemo(() => ({ openChat }), [openChat]);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={value}>
|
||||
|
||||
@@ -11,18 +11,19 @@ import { createContext } from "react";
|
||||
/**
|
||||
* Provided by `FollowProvider` through {@link FollowContext}. The four
|
||||
* functions change the sets at once and undo the change when the request
|
||||
* fails. Their promises never reject, so a caller can't tell a failure.
|
||||
* fails. They resolve to whether it worked, and never reject.
|
||||
*/
|
||||
export interface FollowContextValue {
|
||||
followedUserIds: Set<string>;
|
||||
followedPlaylistIds: Set<string>;
|
||||
followUser: (userId: string) => Promise<void>;
|
||||
unfollowUser: (userId: string) => Promise<void>;
|
||||
followPlaylist: (playlistId: string) => Promise<void>;
|
||||
unfollowPlaylist: (playlistId: string) => Promise<void>;
|
||||
followUser: (userId: string) => Promise<boolean>;
|
||||
unfollowUser: (userId: string) => Promise<boolean>;
|
||||
followPlaylist: (playlistId: string) => Promise<boolean>;
|
||||
unfollowPlaylist: (playlistId: string) => Promise<boolean>;
|
||||
/**
|
||||
* False until the follows are fetched, and always for a visitor. The sets
|
||||
* are empty until then, so a button would show "Follow" for a followed user.
|
||||
* A failed fetch keeps it false, and is tried again 10 s later.
|
||||
*/
|
||||
isLoaded: boolean;
|
||||
}
|
||||
@@ -31,10 +32,10 @@ export interface FollowContextValue {
|
||||
export const defaultFollowValue: FollowContextValue = {
|
||||
followedUserIds: new Set(),
|
||||
followedPlaylistIds: new Set(),
|
||||
followUser: async () => {},
|
||||
unfollowUser: async () => {},
|
||||
followPlaylist: async () => {},
|
||||
unfollowPlaylist: async () => {},
|
||||
followUser: () => Promise.resolve(false),
|
||||
unfollowUser: () => Promise.resolve(false),
|
||||
followPlaylist: () => Promise.resolve(false),
|
||||
unfollowPlaylist: () => Promise.resolve(false),
|
||||
isLoaded: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import type { FollowStatus } from "../../../shared/follow.ts";
|
||||
|
||||
/** Passed to {@link FollowProvider} by `App`. */
|
||||
@@ -22,15 +23,19 @@ export interface FollowProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** How long a failed fetch of the follows waits before it is tried again. */
|
||||
const RETRY_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Provides {@link FollowContext}. It fetches the follows whenever the token
|
||||
* changes, and empties them on sign-out. Follows made in another tab don't
|
||||
* show until the next sign-in or reload: no socket message carries them.
|
||||
* changes, and again when `follows_changed` says another tab changed them. It
|
||||
* empties them on sign-out.
|
||||
*/
|
||||
export function FollowProvider(
|
||||
{ children }: FollowProviderProps,
|
||||
): React.JSX.Element {
|
||||
const { token, authFetch } = useAuth();
|
||||
const { followsVersion } = useWS();
|
||||
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -38,6 +43,7 @@ export function FollowProvider(
|
||||
new Set(),
|
||||
);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -51,92 +57,80 @@ export function FollowProvider(
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
// Whatever went wrong, the buttons stay disabled rather than show empty
|
||||
// sets as the truth, until a later try succeeds.
|
||||
const fail = () => {
|
||||
if (controller.signal.aborted) return;
|
||||
retry = setTimeout(() => setAttempt((n) => n + 1), RETRY_MS);
|
||||
};
|
||||
fetch(`${API_URL}/api/follows/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) return;
|
||||
if (!body.success) return fail();
|
||||
const status = body.data as FollowStatus;
|
||||
setFollowedUserIds(new Set(status.followedUserIds));
|
||||
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Enables the buttons anyway, though the sets may be empty or stale.
|
||||
if (err.name !== "AbortError") setIsLoaded(true);
|
||||
});
|
||||
.catch(fail);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(retry);
|
||||
};
|
||||
}, [token]);
|
||||
}, [token, followsVersion, attempt]);
|
||||
|
||||
const followUser = useCallback(async (userId: string) => {
|
||||
setFollowedUserIds((prev) => new Set([...prev, userId]));
|
||||
/**
|
||||
* Adds or removes `id` in a set at once, sends the request, and undoes the
|
||||
* change if it fails.
|
||||
*/
|
||||
const toggle = useCallback(async (
|
||||
setIds: React.Dispatch<React.SetStateAction<Set<string>>>,
|
||||
path: string,
|
||||
id: string,
|
||||
follow: boolean,
|
||||
): Promise<boolean> => {
|
||||
const apply = (add: boolean) =>
|
||||
setIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (add) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
apply(follow);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
|
||||
method: "POST",
|
||||
const res = await authFetch(`${API_URL}/api/follows/${path}/${id}`, {
|
||||
method: follow ? "POST" : "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return true;
|
||||
} catch {
|
||||
setFollowedUserIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(userId);
|
||||
return n;
|
||||
});
|
||||
apply(!follow);
|
||||
return false;
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const unfollowUser = useCallback(async (userId: string) => {
|
||||
setFollowedUserIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(userId);
|
||||
return n;
|
||||
});
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedUserIds((prev) => new Set([...prev, userId]));
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const followPlaylist = useCallback(async (playlistId: string) => {
|
||||
setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/follows/playlists/${playlistId}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedPlaylistIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(playlistId);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const unfollowPlaylist = useCallback(async (playlistId: string) => {
|
||||
setFollowedPlaylistIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(playlistId);
|
||||
return n;
|
||||
});
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/follows/playlists/${playlistId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||
}
|
||||
}, [authFetch]);
|
||||
const followUser = useCallback(
|
||||
(userId: string) => toggle(setFollowedUserIds, "users", userId, true),
|
||||
[toggle],
|
||||
);
|
||||
const unfollowUser = useCallback(
|
||||
(userId: string) => toggle(setFollowedUserIds, "users", userId, false),
|
||||
[toggle],
|
||||
);
|
||||
const followPlaylist = useCallback(
|
||||
(playlistId: string) =>
|
||||
toggle(setFollowedPlaylistIds, "playlists", playlistId, true),
|
||||
[toggle],
|
||||
);
|
||||
const unfollowPlaylist = useCallback(
|
||||
(playlistId: string) =>
|
||||
toggle(setFollowedPlaylistIds, "playlists", playlistId, false),
|
||||
[toggle],
|
||||
);
|
||||
|
||||
const value: FollowContextValue = useMemo(() => ({
|
||||
followedUserIds,
|
||||
|
||||
@@ -8,18 +8,25 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/**
|
||||
* A site style. Each has a stylesheet under `src/themes/`, keyed on the root
|
||||
* element's `data-style`, which is absent for "smooth".
|
||||
* Every site style, which `ThemeProvider` checks a stored choice against. Each
|
||||
* has a stylesheet under `src/themes/`, keyed on the root element's
|
||||
* `data-style`, which is absent for "smooth".
|
||||
*/
|
||||
export type StyleName =
|
||||
| "smooth"
|
||||
| "brutalist"
|
||||
| "geocities"
|
||||
| "nyt"
|
||||
| "kanagawa"
|
||||
| "melange";
|
||||
/** A color scheme. "auto" follows the system's. */
|
||||
export type ColorScheme = "auto" | "light" | "dark";
|
||||
export const STYLE_NAMES = [
|
||||
"smooth",
|
||||
"brutalist",
|
||||
"geocities",
|
||||
"nyt",
|
||||
"kanagawa",
|
||||
"melange",
|
||||
] as const;
|
||||
/** One of {@link STYLE_NAMES}. */
|
||||
export type StyleName = typeof STYLE_NAMES[number];
|
||||
|
||||
/** Every color scheme; "auto" follows the system's. */
|
||||
export const COLOR_SCHEMES = ["auto", "light", "dark"] as const;
|
||||
/** One of {@link COLOR_SCHEMES}. */
|
||||
export type ColorScheme = typeof COLOR_SCHEMES[number];
|
||||
|
||||
/**
|
||||
* Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
COLOR_SCHEMES,
|
||||
type ColorScheme,
|
||||
STYLE_NAMES,
|
||||
type StyleName,
|
||||
ThemeContext,
|
||||
} from "./ThemeContext.ts";
|
||||
@@ -17,6 +19,22 @@ export interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Returns the stored `key` when it is one of `allowed`, else `fallback`. */
|
||||
function readChoice<T extends string>(
|
||||
key: string,
|
||||
allowed: readonly T[],
|
||||
fallback: T,
|
||||
): T {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
return (allowed as readonly string[]).includes(stored ?? "")
|
||||
? stored as T
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link ThemeContext}. It sets the choices on the root element as
|
||||
* `data-style` and `data-color-scheme`, which the stylesheets key on, and
|
||||
@@ -29,15 +47,13 @@ export interface ThemeProviderProps {
|
||||
export function ThemeProvider(
|
||||
{ children }: ThemeProviderProps,
|
||||
): React.JSX.Element {
|
||||
const [style, setStyleState] = useState<StyleName>(() => {
|
||||
const stored = localStorage.getItem("style");
|
||||
// "smooth" was once stored as "default".
|
||||
if (stored === "default" || stored === null) return "smooth";
|
||||
return stored as StyleName;
|
||||
});
|
||||
const [colorScheme, setColorSchemeState] = useState<ColorScheme>(
|
||||
() =>
|
||||
(localStorage.getItem("color-scheme") as ColorScheme | null) ?? "auto",
|
||||
// A value from an older version, or edited by hand, falls back to the
|
||||
// default ("smooth" was once stored as "default").
|
||||
const [style, setStyleState] = useState<StyleName>(() =>
|
||||
readChoice("style", STYLE_NAMES, "smooth")
|
||||
);
|
||||
const [colorScheme, setColorSchemeState] = useState<ColorScheme>(() =>
|
||||
readChoice("color-scheme", COLOR_SCHEMES, "auto")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -25,13 +25,6 @@ export interface VoteEvent {
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** Set as `lastLikeEvent` by `WSProvider` on a `comment_likes_update`. */
|
||||
export interface LikeEvent {
|
||||
commentId: string;
|
||||
likerId: string;
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message.
|
||||
* The pages that show playlists apply it, some through `usePlaylistListSync`.
|
||||
@@ -147,14 +140,15 @@ export interface WSContextValue {
|
||||
*/
|
||||
recentDumps: Dump[];
|
||||
/**
|
||||
* Dumps deleted or made private this session, for the lists to filter out.
|
||||
* A dump made public again leaves it.
|
||||
* Dumps deleted this session, or made private by someone else, for the
|
||||
* lists to filter out. A dump made public again leaves it.
|
||||
*/
|
||||
deletedDumpIds: Set<string>;
|
||||
lastVoteEvent: VoteEvent | null;
|
||||
/** Read by no consumer at the moment. */
|
||||
lastLikeEvent: LikeEvent | null;
|
||||
/** The dump as it is after its latest edit. */
|
||||
/**
|
||||
* The dump as its latest `dump_created` or `dump_updated` has it, for the
|
||||
* lists to update it in place, or put it back where it was.
|
||||
*/
|
||||
lastDumpEvent: Dump | null;
|
||||
lastPlaylistEvent: PlaylistEvent | null;
|
||||
deletedPlaylistIds: Set<string>;
|
||||
@@ -169,6 +163,11 @@ export interface WSContextValue {
|
||||
* fetch again what they missed while the socket was down.
|
||||
*/
|
||||
connectionEpoch: number;
|
||||
/**
|
||||
* Incremented when the user follows or unfollows something in another tab
|
||||
* (or this one), so `FollowProvider` fetches the follow status again.
|
||||
*/
|
||||
followsVersion: number;
|
||||
/**
|
||||
* Upvotes a dump at once and sends `vote_cast`. The server's answer confirms
|
||||
* or reverts it, and so does a timeout of 5 s, which covers a closed socket.
|
||||
@@ -285,7 +284,6 @@ export const defaultWSValue: WSContextValue = {
|
||||
recentDumps: [],
|
||||
deletedDumpIds: new Set(),
|
||||
lastVoteEvent: null,
|
||||
lastLikeEvent: null,
|
||||
lastDumpEvent: null,
|
||||
lastPlaylistEvent: null,
|
||||
deletedPlaylistIds: new Set(),
|
||||
@@ -294,6 +292,7 @@ export const defaultWSValue: WSContextValue = {
|
||||
unreadNotificationCount: 0,
|
||||
lastNotification: null,
|
||||
connectionEpoch: 0,
|
||||
followsVersion: 0,
|
||||
castVote: () => {},
|
||||
removeVote: () => {},
|
||||
castCommentLike: () => {},
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
type ChatError,
|
||||
type ChatReplyTarget,
|
||||
type CommentEvent,
|
||||
type LikeEvent,
|
||||
type PendingChatMessage,
|
||||
type PlaylistEvent,
|
||||
type UserEvent,
|
||||
@@ -94,16 +93,106 @@ const CHAT_READ_THROTTLE = 1_000;
|
||||
*/
|
||||
const CHAT_ECHO_WINDOW = 60_000;
|
||||
|
||||
/** An optimistic vote waiting for the server. */
|
||||
interface PendingVote {
|
||||
/** Toggles of one item that the server hasn't acknowledged yet. */
|
||||
interface PendingToggle {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
rollback: () => void;
|
||||
/** The count and state before the first of them, to restore on failure. */
|
||||
count: number;
|
||||
on: boolean;
|
||||
/** How many acks are still to come. */
|
||||
outstanding: number;
|
||||
}
|
||||
|
||||
/** An optimistic like waiting for the server. */
|
||||
interface PendingLike {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
rollback: () => void;
|
||||
/**
|
||||
* Returned by {@link useOptimisticToggles}, for the vote and like actions and
|
||||
* the socket's handlers.
|
||||
*/
|
||||
interface OptimisticToggles {
|
||||
/** Records a toggle already shown, from the count and state before it. */
|
||||
begin: (id: string, count: number, on: boolean) => void;
|
||||
/** Settles one toggle; true once none of the item's is left in flight. */
|
||||
ack: (id: string) => boolean;
|
||||
/** Checks whether the item has toggles in flight. */
|
||||
pending: (id: string) => boolean;
|
||||
/** Restores the item as it was before its toggles, now. */
|
||||
fail: (id: string) => void;
|
||||
/**
|
||||
* Settles every toggle against the server's set after a (re)connect: each
|
||||
* count moves by one from its baseline when the server's state differs.
|
||||
*/
|
||||
reconcile: (serverOn: Set<string>) => void;
|
||||
/** Forgets every toggle, for a new session. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks optimistic toggles of votes or likes, which show at once and go back
|
||||
* when the server refuses them or doesn't answer within {@link ACK_TIMEOUT}.
|
||||
* Several clicks on one item before any ack share one baseline, so a failure
|
||||
* restores what was there before the first, and only the last ack's count is
|
||||
* applied.
|
||||
*/
|
||||
function useOptimisticToggles(
|
||||
setOn: React.Dispatch<React.SetStateAction<Set<string>>>,
|
||||
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>,
|
||||
): OptimisticToggles {
|
||||
const ref = useRef(new Map<string, PendingToggle>());
|
||||
return useMemo(() => {
|
||||
const pendingMap = ref.current;
|
||||
const restore = (id: string, count: number, on: boolean) => {
|
||||
setOn((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
setCounts((prev) => ({ ...prev, [id]: count }));
|
||||
};
|
||||
const fail = (id: string) => {
|
||||
const entry = pendingMap.get(id);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timeout);
|
||||
pendingMap.delete(id);
|
||||
restore(id, entry.count, entry.on);
|
||||
};
|
||||
return {
|
||||
begin(id, count, on) {
|
||||
const entry = pendingMap.get(id);
|
||||
if (entry) clearTimeout(entry.timeout);
|
||||
// Nothing is sent while the socket is down, so the timer fails it.
|
||||
pendingMap.set(id, {
|
||||
count: entry?.count ?? count,
|
||||
on: entry?.on ?? on,
|
||||
outstanding: (entry?.outstanding ?? 0) + 1,
|
||||
timeout: setTimeout(() => fail(id), ACK_TIMEOUT),
|
||||
});
|
||||
},
|
||||
ack(id) {
|
||||
const entry = pendingMap.get(id);
|
||||
if (!entry) return true;
|
||||
entry.outstanding -= 1;
|
||||
if (entry.outstanding > 0) return false;
|
||||
clearTimeout(entry.timeout);
|
||||
pendingMap.delete(id);
|
||||
return true;
|
||||
},
|
||||
pending: (id) => pendingMap.has(id),
|
||||
fail,
|
||||
reconcile(serverOn) {
|
||||
for (const [id, entry] of pendingMap) {
|
||||
clearTimeout(entry.timeout);
|
||||
const on = serverOn.has(id);
|
||||
const count = entry.count + (on === entry.on ? 0 : on ? 1 : -1);
|
||||
setCounts((prev) => ({ ...prev, [id]: Math.max(0, count) }));
|
||||
}
|
||||
pendingMap.clear();
|
||||
},
|
||||
clear() {
|
||||
for (const entry of pendingMap.values()) clearTimeout(entry.timeout);
|
||||
pendingMap.clear();
|
||||
},
|
||||
};
|
||||
}, [setOn, setCounts]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +238,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
||||
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
|
||||
const [lastLikeEvent, setLastLikeEvent] = useState<LikeEvent | null>(null);
|
||||
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
|
||||
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
|
||||
PlaylistEvent | null
|
||||
@@ -179,6 +267,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
>({});
|
||||
// Never reset: pages compare it with the value they last saw.
|
||||
const [connectionEpoch, setConnectionEpoch] = useState(0);
|
||||
const [followsVersion, setFollowsVersion] = useState(0);
|
||||
|
||||
// A new token means another session: the `welcome` resyncs votes, likes and
|
||||
// unread counts, but what the last session gathered (another account's
|
||||
@@ -196,7 +285,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setRecentDumps([]);
|
||||
setDeletedDumpIds(new Set());
|
||||
setLastVoteEvent(null);
|
||||
setLastLikeEvent(null);
|
||||
setLastDumpEvent(null);
|
||||
setLastPlaylistEvent(null);
|
||||
setDeletedPlaylistIds(new Set());
|
||||
@@ -274,64 +362,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
// By dump id, and by comment id for likes. One per item: a second click
|
||||
// before the answer replaces the first's timer, so only the latest change
|
||||
// can be rolled back.
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
new Map(),
|
||||
);
|
||||
const pendingLikesRef = useRef<Map<string, PendingLike>>(
|
||||
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]);
|
||||
|
||||
const clearPendingLike = useCallback((commentId: string) => {
|
||||
const pending = pendingLikesRef.current.get(commentId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timeout);
|
||||
pendingLikesRef.current.delete(commentId);
|
||||
}, []);
|
||||
|
||||
const clearAllPendingLikes = useCallback(() => {
|
||||
for (const pending of pendingLikesRef.current.values()) {
|
||||
clearTimeout(pending.timeout);
|
||||
}
|
||||
pendingLikesRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const schedulePendingLike = useCallback((
|
||||
commentId: string,
|
||||
rollback: () => void,
|
||||
) => {
|
||||
clearPendingLike(commentId);
|
||||
const timeout = setTimeout(() => {
|
||||
pendingLikesRef.current.delete(commentId);
|
||||
rollback();
|
||||
}, ACK_TIMEOUT);
|
||||
pendingLikesRef.current.set(commentId, { timeout, rollback });
|
||||
}, [clearPendingLike]);
|
||||
const votes = useOptimisticToggles(setMyVotes, setVoteCounts);
|
||||
const likes = useOptimisticToggles(setMyLikes, setLikeCounts);
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
@@ -387,6 +419,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "welcome":
|
||||
backoff = 500;
|
||||
setOnlineUsers(msg.users);
|
||||
// Toggles sent before a reconnect may or may not have landed: the
|
||||
// server's sets say which, and fix the counts shown for them.
|
||||
votes.reconcile(new Set(msg.myVotes));
|
||||
likes.reconcile(new Set(msg.myCommentLikes));
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setMyLikes(new Set(msg.myCommentLikes));
|
||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||
@@ -399,10 +435,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
readAt.getTime(),
|
||||
);
|
||||
}
|
||||
// The server's sets replace whatever was pending. The optimistic
|
||||
// counts stay, though, until a live count replaces them.
|
||||
clearAllPendingVotes();
|
||||
clearAllPendingLikes();
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) {
|
||||
setConnectionEpoch((n) => n + 1);
|
||||
@@ -445,12 +477,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
case "votes_update": {
|
||||
const { dumpId, voteCount, voterId, action } = msg;
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
setLastVoteEvent({ dumpId, voterId, action });
|
||||
// The user's own vote, from this tab or another: it settles the
|
||||
// pending one and keeps every tab's button in step.
|
||||
// While this tab has votes on it in flight, their acks settle it.
|
||||
if (votes.pending(dumpId)) break;
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
// The user's own vote from another tab keeps this tab's button in
|
||||
// step.
|
||||
if (voterId === userIdRef.current) {
|
||||
clearPendingVote(dumpId);
|
||||
setMyVotes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(dumpId);
|
||||
@@ -462,12 +495,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "comment_likes_update": {
|
||||
const { commentId, likeCount, likerId, action } = msg;
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
setLastLikeEvent({ commentId, likerId, action });
|
||||
const { commentId, likeCount, likerId } = msg;
|
||||
// As for votes.
|
||||
if (likes.pending(commentId)) break;
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
if (likerId === userIdRef.current) {
|
||||
clearPendingLike(commentId);
|
||||
const { action } = msg;
|
||||
setMyLikes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(commentId);
|
||||
@@ -494,6 +527,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
MAX_RECENT_DUMPS,
|
||||
)
|
||||
);
|
||||
// The profiles put a dump back where it was when it turns public.
|
||||
setLastDumpEvent(dump);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -511,7 +546,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "dump_deleted": {
|
||||
// Also sent when a dump goes private.
|
||||
// Also sent to others when someone's dump goes private.
|
||||
const { dumpId } = msg;
|
||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
@@ -520,7 +555,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
case "vote_ack": {
|
||||
const { dumpId, action, voteCount } = msg;
|
||||
clearPendingVote(dumpId);
|
||||
// With more clicks in flight, the last ack tells how it ended.
|
||||
if (!votes.ack(dumpId)) break;
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
setMyVotes((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -533,7 +569,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
case "comment_like_ack": {
|
||||
const { commentId, action, likeCount } = msg;
|
||||
clearPendingLike(commentId);
|
||||
if (!likes.ack(commentId)) break;
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
setMyLikes((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -669,6 +705,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setTypingUsers(msg.users);
|
||||
break;
|
||||
|
||||
case "follows_changed":
|
||||
setFollowsVersion((n) => n + 1);
|
||||
break;
|
||||
|
||||
case "chat_read_update": {
|
||||
// Sent to every socket of the user, whichever tab moved it.
|
||||
const readAt = new Date(msg.readAt);
|
||||
@@ -690,21 +730,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
// A failed vote or like names its dump or comment, so it is
|
||||
// rolled back at once. Other errors leave it to the timer.
|
||||
if (msg.dumpId) {
|
||||
const pending = pendingRef.current.get(msg.dumpId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingRef.current.delete(msg.dumpId);
|
||||
pending.rollback();
|
||||
}
|
||||
} else if (msg.commentId) {
|
||||
const pending = pendingLikesRef.current.get(msg.commentId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pendingLikesRef.current.delete(msg.commentId);
|
||||
pending.rollback();
|
||||
}
|
||||
}
|
||||
if (msg.dumpId) votes.fail(msg.dumpId);
|
||||
else if (msg.commentId) likes.fail(msg.commentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -743,172 +770,122 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
connect();
|
||||
|
||||
const pending = pendingRef.current;
|
||||
const pendingLikes = pendingLikesRef.current;
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (connectTimeout) clearTimeout(connectTimeout);
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
for (const pendingVote of pending.values()) {
|
||||
clearTimeout(pendingVote.timeout);
|
||||
}
|
||||
pending.clear();
|
||||
for (const pendingLike of pendingLikes.values()) {
|
||||
clearTimeout(pendingLike.timeout);
|
||||
}
|
||||
pendingLikes.clear();
|
||||
votes.clear();
|
||||
likes.clear();
|
||||
};
|
||||
}, [
|
||||
clearAllPendingVotes,
|
||||
clearPendingVote,
|
||||
clearAllPendingLikes,
|
||||
clearPendingLike,
|
||||
votes,
|
||||
likes,
|
||||
failPendingChat,
|
||||
isViewingChat,
|
||||
retirePendingChat,
|
||||
token,
|
||||
]);
|
||||
|
||||
const castVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
/**
|
||||
* Shows a vote or like toggled at once, records it with `toggles`, and sends
|
||||
* `type` for it. `on` is the state wanted; a click that asks for the state
|
||||
* already shown does nothing.
|
||||
*/
|
||||
const toggle = useCallback((
|
||||
toggles: OptimisticToggles,
|
||||
onRef: React.RefObject<Set<string>>,
|
||||
countsRef: React.RefObject<Record<string, number>>,
|
||||
setOn: React.Dispatch<React.SetStateAction<Set<string>>>,
|
||||
setCounts: React.Dispatch<React.SetStateAction<Record<string, number>>>,
|
||||
id: string,
|
||||
on: boolean,
|
||||
shownCount: number | undefined,
|
||||
message: ClientToServerMessage,
|
||||
) => {
|
||||
// Until the socket brings a count, the one on screen is the only one.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (prevVoted) return;
|
||||
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(dumpId);
|
||||
return n;
|
||||
const prevCount = countsRef.current[id] ?? shownCount ?? 0;
|
||||
const prevOn = onRef.current.has(id);
|
||||
if (prevOn === on) return;
|
||||
setOn((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
||||
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
});
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "vote_cast", dumpId } satisfies ClientToServerMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Nothing is sent while the socket is down, so the timer rolls it back.
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const removeVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (!prevVoted) return;
|
||||
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({
|
||||
setCounts((prev) => ({
|
||||
...prev,
|
||||
[dumpId]: Math.max(0, prevCount - 1),
|
||||
[id]: Math.max(0, prevCount + (on ? 1 : -1)),
|
||||
}));
|
||||
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
});
|
||||
|
||||
toggles.begin(id, prevCount, prevOn);
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{ type: "vote_remove", dumpId } satisfies ClientToServerMessage,
|
||||
),
|
||||
);
|
||||
socketRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
}, [schedulePendingVote]);
|
||||
}, []);
|
||||
|
||||
const castVote = useCallback(
|
||||
(dumpId: string, shownCount?: number) =>
|
||||
toggle(
|
||||
votes,
|
||||
myVotesRef,
|
||||
voteCountsRef,
|
||||
setMyVotes,
|
||||
setVoteCounts,
|
||||
dumpId,
|
||||
true,
|
||||
shownCount,
|
||||
{ type: "vote_cast", dumpId },
|
||||
),
|
||||
[toggle, votes],
|
||||
);
|
||||
|
||||
const removeVote = useCallback(
|
||||
(dumpId: string, shownCount?: number) =>
|
||||
toggle(
|
||||
votes,
|
||||
myVotesRef,
|
||||
voteCountsRef,
|
||||
setMyVotes,
|
||||
setVoteCounts,
|
||||
dumpId,
|
||||
false,
|
||||
shownCount,
|
||||
{ type: "vote_remove", dumpId },
|
||||
),
|
||||
[toggle, votes],
|
||||
);
|
||||
|
||||
const castCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (prevLiked) return;
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
|
||||
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
|
||||
});
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "comment_like_cast",
|
||||
commentId,
|
||||
} satisfies ClientToServerMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[schedulePendingLike],
|
||||
(commentId: string, shownCount?: number) =>
|
||||
toggle(
|
||||
likes,
|
||||
myLikesRef,
|
||||
likeCountsRef,
|
||||
setMyLikes,
|
||||
setLikeCounts,
|
||||
commentId,
|
||||
true,
|
||||
shownCount,
|
||||
{ type: "comment_like_cast", commentId },
|
||||
),
|
||||
[toggle, likes],
|
||||
);
|
||||
|
||||
const removeCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (!prevLiked) return;
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({
|
||||
...prev,
|
||||
[commentId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(commentId);
|
||||
return n;
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount }));
|
||||
});
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "comment_like_remove",
|
||||
commentId,
|
||||
} satisfies ClientToServerMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[schedulePendingLike],
|
||||
(commentId: string, shownCount?: number) =>
|
||||
toggle(
|
||||
likes,
|
||||
myLikesRef,
|
||||
likeCountsRef,
|
||||
setMyLikes,
|
||||
setLikeCounts,
|
||||
commentId,
|
||||
false,
|
||||
shownCount,
|
||||
{ type: "comment_like_remove", commentId },
|
||||
),
|
||||
[toggle, likes],
|
||||
);
|
||||
|
||||
const injectDump = useCallback((dump: Dump) => {
|
||||
@@ -1143,7 +1120,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
lastVoteEvent,
|
||||
lastLikeEvent,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
@@ -1152,6 +1128,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
connectionEpoch,
|
||||
followsVersion,
|
||||
castVote,
|
||||
removeVote,
|
||||
castCommentLike,
|
||||
@@ -1168,7 +1145,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
lastVoteEvent,
|
||||
lastLikeEvent,
|
||||
lastDumpEvent,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
@@ -1177,6 +1153,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
connectionEpoch,
|
||||
followsVersion,
|
||||
castVote,
|
||||
removeVote,
|
||||
castCommentLike,
|
||||
|
||||
@@ -10,29 +10,20 @@ import type { Dump } from "../../../shared/dump.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link useDumpListSync} by a profile's list of dumps. The feeds
|
||||
* pass none.
|
||||
* Passed to {@link useDumpListSync} by a profile's own list of dumps. The
|
||||
* feeds pass none.
|
||||
*/
|
||||
export interface DumpListSyncOptions {
|
||||
/** Keeps an edited dump that is now private, for its owner's own list. */
|
||||
isOwner?: boolean;
|
||||
/**
|
||||
* Prepends an edited dump missing from the list when `ownerId` posted it.
|
||||
* Without it, a missing dump is never added.
|
||||
*/
|
||||
ownerId?: string;
|
||||
/**
|
||||
* Never adds a missing dump, whatever `ownerId`. A list that restores
|
||||
* dumps at their old position, with `usePositionAwareSync`, sets it.
|
||||
*/
|
||||
skipReinsert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the socket's dump and comment events to the list that `setDumps`
|
||||
* updates. A deleted dump is removed, an edited one is replaced in place, and
|
||||
* a new or deleted comment moves its dump's `commentCount`. It doesn't add new
|
||||
* dumps: the feeds merge `WSContext`'s `recentDumps` themselves.
|
||||
* a new or deleted comment moves its dump's `commentCount`. It never adds a
|
||||
* dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
|
||||
* profiles put back a dump that returns with `usePositionAwareSync`.
|
||||
*/
|
||||
export function useDumpListSync(
|
||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||
@@ -57,25 +48,18 @@ export function useDumpListSync(
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
const { isOwner, ownerId, skipReinsert } = optionsRef.current ?? {};
|
||||
const isOwner = optionsRef.current?.isOwner;
|
||||
const dump = lastDumpEvent;
|
||||
|
||||
setDumpsRef.current((prev) => {
|
||||
const idx = prev.findIndex((d) => d.id === dump.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
if (dump.isPrivate && !isOwner) {
|
||||
return prev.filter((d) => d.id !== dump.id);
|
||||
}
|
||||
const next = [...prev];
|
||||
next[idx] = dump;
|
||||
return next;
|
||||
if (idx === -1) return prev;
|
||||
if (dump.isPrivate && !isOwner) {
|
||||
return prev.filter((d) => d.id !== dump.id);
|
||||
}
|
||||
|
||||
if (skipReinsert || !ownerId) return prev;
|
||||
if (dump.userId !== ownerId) return prev;
|
||||
if (dump.isPrivate && !isOwner) return prev;
|
||||
return [dump, ...prev];
|
||||
const next = [...prev];
|
||||
next[idx] = dump;
|
||||
return next;
|
||||
});
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
|
||||
import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
@@ -163,9 +163,8 @@ export function useUserDumpFeed(
|
||||
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
const newItems = items.map(deserializeDump);
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
DEFAULT_FEED_TAB,
|
||||
FEED_TABS,
|
||||
@@ -13,28 +13,53 @@ import {
|
||||
|
||||
const STORAGE_KEY = "default-feed-tab";
|
||||
|
||||
/** The components reading the tab, told when this tab changes it. */
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** The choice made while storage is blocked, which lasts until a reload. */
|
||||
let unstored: FeedTab | null = null;
|
||||
|
||||
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */
|
||||
function readStored(): FeedTab {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
let stored: string | null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
stored = unstored;
|
||||
}
|
||||
return stored && (FEED_TABS as readonly string[]).includes(stored)
|
||||
? stored as FeedTab
|
||||
: DEFAULT_FEED_TAB;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
// Another browser tab's choice arrives as a `storage` event.
|
||||
globalThis.addEventListener("storage", listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
globalThis.removeEventListener("storage", listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Stores `tab`, and tells every component that reads it. */
|
||||
function setDefaultFeedTab(tab: FeedTab): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, tab);
|
||||
} catch {
|
||||
unstored = tab;
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the feed tab the index opens on, and a setter that stores a new
|
||||
* choice. The profile's settings set it. The index's redirect, its tab bar and
|
||||
* `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is
|
||||
* "followed" and nobody is signed in. Each call reads the stored tab once, so
|
||||
* a change shows in other components only once they remount.
|
||||
* "followed" and nobody is signed in. A new choice reaches every reader at
|
||||
* once, in this browser tab and the others.
|
||||
*/
|
||||
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
|
||||
const [tab, setTabState] = useState<FeedTab>(readStored);
|
||||
|
||||
const setTab = useCallback((t: FeedTab) => {
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
setTabState(t);
|
||||
}, []);
|
||||
|
||||
return [tab, setTab];
|
||||
const tab = useSyncExternalStore(subscribe, readStored);
|
||||
return [tab, setDefaultFeedTab];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigationType } from "react-router";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
|
||||
/** Past this age, a saved feed is refetched rather than restored. */
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -30,7 +31,7 @@ export interface FeedCacheEntry<T> {
|
||||
export interface UseFeedCacheResult<T> {
|
||||
/**
|
||||
* The saved feed, when the page was reached by going back or forward and the
|
||||
* feed was saved less than 10 minutes ago. It doesn't change after mount.
|
||||
* feed was saved less than 10 minutes ago. It changes only with the key.
|
||||
*/
|
||||
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||
/** Usually called through `useScrollSave`, as the page is left. */
|
||||
@@ -42,38 +43,56 @@ export interface UseFeedCacheResult<T> {
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Returns the entry saved under `key`, or null if none is fresh enough. */
|
||||
function readEntry<T>(
|
||||
key: string,
|
||||
hydrateItem: (raw: T) => T,
|
||||
): Omit<FeedCacheEntry<T>, "savedAt"> | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
|
||||
if (Date.now() - entry.savedAt > TTL) return null;
|
||||
return { ...entry, items: entry.items.map(hydrateItem) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the feed saved under `key` and a way to save it. The feed pages and
|
||||
* `useUserDumpFeed` use it. `key` must tell apart every list whose items
|
||||
* differ, the viewer included when they see private items. `hydrateItem`
|
||||
* restores what JSON lost, such as dates. A full or blocked storage is
|
||||
* ignored.
|
||||
* differ; the viewer is added to it here, since they see their own private
|
||||
* items. `hydrateItem` restores what JSON lost, such as dates. A full or
|
||||
* blocked storage is ignored.
|
||||
*/
|
||||
export function useFeedCache<T>(
|
||||
key: string,
|
||||
hydrateItem: (raw: T) => T,
|
||||
): UseFeedCacheResult<T> {
|
||||
const navType = useNavigationType();
|
||||
const { user } = useAuth();
|
||||
const fullKey = `${key}:${user?.id ?? "guest"}`;
|
||||
|
||||
// Read once: a new navigation to the page must not pick up what it saves.
|
||||
const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||
if (navType !== "POP") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
|
||||
if (Date.now() - entry.savedAt > TTL) return null;
|
||||
return { ...entry, items: entry.items.map(hydrateItem) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
// Read per key, not on every render: a page that saves as it is left must
|
||||
// not pick up its own save. A new key (another profile, in the same page)
|
||||
// is read afresh.
|
||||
const [read, setRead] = useState(() => ({
|
||||
key: fullKey,
|
||||
cached: navType === "POP" ? readEntry(fullKey, hydrateItem) : null,
|
||||
}));
|
||||
if (read.key !== fullKey) {
|
||||
setRead({
|
||||
key: fullKey,
|
||||
cached: navType === "POP" ? readEntry(fullKey, hydrateItem) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const saveState = useCallback(
|
||||
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
key,
|
||||
fullKey,
|
||||
JSON.stringify({
|
||||
items,
|
||||
page,
|
||||
@@ -86,8 +105,11 @@ export function useFeedCache<T>(
|
||||
// Full or blocked storage: the page just refetches next time.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
[fullKey],
|
||||
);
|
||||
|
||||
return { cached, saveState };
|
||||
return {
|
||||
cached: read.key === fullKey ? read.cached : null,
|
||||
saveState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,45 +8,37 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Remembers the index of an item that `lastEvent` removes from `items`, and
|
||||
* puts the item back there when a later event makes it visible again. The
|
||||
* profile pages use it with `WSContext`'s latest dump or playlist edit.
|
||||
* `items` is the loaded list, or `[]` before it loads, and `setItems` the
|
||||
* setter also passed to `useDumpListSync` or `usePlaylistListSync`.
|
||||
* `isRemoving` tells an event that takes the item out, and `shouldReinsert`
|
||||
* one that brings it back.
|
||||
*
|
||||
* It must be called before the sync hook, so that its effects run first: it
|
||||
* saves the index before the sync hook removes the item. The sync hook must
|
||||
* get `skipReinsert`, or it would add the item a second time.
|
||||
* Remembers the index of every item that leaves `items`, whatever took it
|
||||
* out, and puts it back there when `lastEvent` shows it again and
|
||||
* `shouldReinsert` accepts it. The profile pages use it with `WSContext`'s
|
||||
* latest dump or playlist event, beside `useDumpListSync` or
|
||||
* `usePlaylistListSync`, which never re-add an item themselves. `items` is the
|
||||
* loaded list, or `[]` before it loads, and `setItems` its setter.
|
||||
*/
|
||||
export function usePositionAwareSync<T extends { id: string }>(
|
||||
items: T[],
|
||||
setItems: (fn: (prev: T[]) => T[]) => void,
|
||||
lastEvent: T | null,
|
||||
isRemoving: (item: T) => boolean,
|
||||
shouldReinsert: (item: T) => boolean,
|
||||
): void {
|
||||
const itemsRef = useRef<T[]>(items);
|
||||
const setItemsRef = useRef(setItems);
|
||||
const isRemovingRef = useRef(isRemoving);
|
||||
const shouldReinsertRef = useRef(shouldReinsert);
|
||||
useLayoutEffect(() => {
|
||||
itemsRef.current = items;
|
||||
setItemsRef.current = setItems;
|
||||
isRemovingRef.current = isRemoving;
|
||||
shouldReinsertRef.current = shouldReinsert;
|
||||
});
|
||||
|
||||
const previousRef = useRef<T[]>(items);
|
||||
const removedPositionsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
|
||||
const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
|
||||
if (idx !== -1) {
|
||||
removedPositionsRef.current.set(lastEvent.id, idx);
|
||||
}
|
||||
}, [lastEvent]);
|
||||
const present = new Set(items.map((item) => item.id));
|
||||
previousRef.current.forEach((item, idx) => {
|
||||
if (!present.has(item.id)) removedPositionsRef.current.set(item.id, idx);
|
||||
});
|
||||
for (const id of present) removedPositionsRef.current.delete(id);
|
||||
previousRef.current = items;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
|
||||
|
||||
@@ -24,19 +24,14 @@ export interface PlaylistListSyncOptions {
|
||||
* and go.
|
||||
*/
|
||||
noNewEntries?: boolean;
|
||||
/**
|
||||
* Leaves out a playlist that comes back into view, for a caller that puts it
|
||||
* back where it was with `usePositionAwareSync`.
|
||||
*/
|
||||
skipReinsert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the socket's playlist events to the list set through
|
||||
* `setPlaylists`. A created playlist goes first, an updated one changes in
|
||||
* place or leaves when it turns private (unless `isOwner`), a deleted one
|
||||
* leaves, and a
|
||||
* playlist's `dumpCount` follows its dumps.
|
||||
* leaves, and a playlist's `dumpCount` follows its dumps. An update never
|
||||
* adds a playlist the list doesn't hold.
|
||||
*/
|
||||
export function usePlaylistListSync(
|
||||
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
|
||||
@@ -67,15 +62,9 @@ export function usePlaylistListSync(
|
||||
|
||||
if (ev.type === "updated" && ev.playlist) {
|
||||
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
|
||||
if (idx === -1) {
|
||||
// Left the list when it turned private, and may be back. A followed
|
||||
// list can't tell whether the user still follows it.
|
||||
if (noNewEntries) return prev;
|
||||
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||
if (optionsRef.current?.skipReinsert) return prev;
|
||||
return [ev.playlist, ...prev];
|
||||
}
|
||||
// One that left when it turned private is put back by
|
||||
// `usePositionAwareSync`, where it was.
|
||||
if (idx === -1) return prev;
|
||||
if (!ev.playlist.isPublic && !isOwner) {
|
||||
return prev.filter((p) => p.id !== ev.playlist!.id);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
1118
src/locales/en.po
1118
src/locales/en.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
1118
src/locales/fr.po
1118
src/locales/fr.po
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,22 @@ export function Dump(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null;
|
||||
// Taken once: it stays in the history entry, where a reload or Back would
|
||||
// show that snapshot forever, so it is cleared from there below.
|
||||
const [handedOver] = useState(
|
||||
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
|
||||
);
|
||||
const preloaded = handedOver &&
|
||||
(handedOver.id === selectedDump || handedOver.slug === selectedDump)
|
||||
? handedOver
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (!(location.state as { dump?: Dump } | null)?.dump) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, {
|
||||
replace: true,
|
||||
state: null,
|
||||
});
|
||||
}, [location, navigate]);
|
||||
|
||||
const [dumpState, setDumpState] = useState<DumpState>(
|
||||
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
||||
@@ -91,7 +106,7 @@ export function Dump(): React.JSX.Element {
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState<string | null>(null);
|
||||
|
||||
const { user, token, mediaToken, authFetch } = useAuth();
|
||||
const { user, mediaToken, authFetch } = useAuth();
|
||||
const { byId } = useCategories();
|
||||
const {
|
||||
voteCounts,
|
||||
@@ -578,7 +593,6 @@ export function Dump(): React.JSX.Element {
|
||||
dumpId={dump.id}
|
||||
comments={comments}
|
||||
currentUser={user}
|
||||
token={token}
|
||||
onCommentCreated={(c) =>
|
||||
setComments((prev) =>
|
||||
prev.some((x) => x.id === c.id) ? prev : [...prev, c]
|
||||
|
||||
@@ -10,7 +10,10 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import {
|
||||
DUMP_MAX_FILE_SIZE_BYTES,
|
||||
VALIDATION,
|
||||
} from "../../shared/validation.ts";
|
||||
import type { Dump, UpdateDumpRequest } from "../../shared/dump.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import { deserializeDump } from "../utils/shared/deserialize.ts";
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
parseAPIResponse,
|
||||
} from "../utils/shared/api.ts";
|
||||
import { useAuth, useRequiredAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { formatBytes } from "../utils/shared/format.ts";
|
||||
import {
|
||||
@@ -50,17 +54,16 @@ type DumpEditState =
|
||||
| { status: "loaded"; dump: Dump };
|
||||
|
||||
/**
|
||||
* Renders `/dumps/:selectedDump/edit`, for a signed-in user. The page itself
|
||||
* doesn't check that the user may edit the dump: `Dump` links here only for
|
||||
* its poster and moderators, and the API refuses anyone else's changes. The
|
||||
* dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
|
||||
* Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators;
|
||||
* anyone else signed in gets an error rather than a form the API would refuse
|
||||
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
|
||||
* and the deletion apply at once, and the form's other fields on "Save", which
|
||||
* goes back to the dump's page. A deletion goes to the index.
|
||||
*/
|
||||
export function DumpEdit(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { authFetch, token, mediaToken } = useRequiredAuth();
|
||||
const { authFetch, token, mediaToken, user } = useRequiredAuth();
|
||||
|
||||
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
||||
|
||||
@@ -92,10 +95,16 @@ export function DumpEdit(): React.JSX.Element {
|
||||
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
|
||||
|
||||
if (apiResponse.success) {
|
||||
setState({
|
||||
status: "loaded",
|
||||
dump: deserializeDump(apiResponse.data),
|
||||
});
|
||||
const dump = deserializeDump(apiResponse.data);
|
||||
// The same rule as the API's.
|
||||
if (dump.userId !== user.id && !can(user, "dump:moderate")) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: t`Only its poster or a moderator can edit this dump.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState({ status: "loaded", dump });
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
}
|
||||
@@ -103,7 +112,7 @@ export function DumpEdit(): React.JSX.Element {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
})();
|
||||
}, [selectedDump, token]);
|
||||
}, [selectedDump, token, user]);
|
||||
|
||||
const handleRefreshMetadata = async () => {
|
||||
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
||||
@@ -173,8 +182,7 @@ export function DumpEdit(): React.JSX.Element {
|
||||
setDeleteError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
// Meant for Index, to hide the dump before `dump_deleted` arrives, but the
|
||||
// redirect from `/` doesn't pass the state on.
|
||||
// For Index, to hide the dump before `dump_deleted` arrives.
|
||||
navigate("/", { state: { deletedDumpId: state.dump.id } });
|
||||
};
|
||||
|
||||
@@ -471,6 +479,7 @@ function DumpEditForm({
|
||||
<FileField<EditValues>
|
||||
name="newFile"
|
||||
hint={t`Drop a replacement here`}
|
||||
maxBytes={DUMP_MAX_FILE_SIZE_BYTES}
|
||||
showLimit={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { PaginatedData, Wire } from "../../shared/api.ts";
|
||||
|
||||
import { friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
||||
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
@@ -127,7 +127,7 @@ export function Index(
|
||||
);
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`,
|
||||
`feed:index:${categorySlug ?? "all"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
||||
@@ -141,7 +141,6 @@ export function Index(
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
|
||||
// the redirect from `/` keeps. The query is dropped from the URL at once, so
|
||||
@@ -156,8 +155,7 @@ export function Index(
|
||||
}, [shareUrl, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainFetchDone.current || cached) return;
|
||||
mainFetchDone.current = true;
|
||||
if (cached) return;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
@@ -165,9 +163,9 @@ export function Index(
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
|
||||
const { items, hasMore } = await expectOk<
|
||||
PaginatedData<Wire<Dump>>
|
||||
>(res);
|
||||
setDumpsState({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
@@ -180,10 +178,7 @@ export function Index(
|
||||
setDumpsState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mainFetchDone.current = false;
|
||||
controller.abort();
|
||||
};
|
||||
return () => controller.abort();
|
||||
}, [cached, token, feedUrl]);
|
||||
|
||||
const setDumpsItems = useCallback(
|
||||
@@ -207,9 +202,8 @@ export function Index(
|
||||
signal: controller.signal,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items } = body.data as PaginatedData<Wire<Dump>>;
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items }) => {
|
||||
const fresh = items.map(deserializeDump);
|
||||
setDumpsState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
@@ -235,9 +229,8 @@ export function Index(
|
||||
fetch(feedUrl(nextPage), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<Wire<Dump>>;
|
||||
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setDumpsState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
@@ -300,8 +293,8 @@ export function Index(
|
||||
);
|
||||
|
||||
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||
// Dumps deleted since loading are dropped. `justDeletedId` never matches,
|
||||
// though: DumpEdit navigates to `/`, whose redirect drops the state.
|
||||
// Dumps deleted since loading are dropped, and the one the edit page just
|
||||
// deleted before its `dump_deleted` arrives.
|
||||
const combined = useMemo(
|
||||
() =>
|
||||
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
|
||||
@@ -358,7 +351,6 @@ export function Index(
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -299,6 +299,15 @@ export function Notifications(): React.JSX.Element {
|
||||
arrived: [],
|
||||
});
|
||||
|
||||
// The latest notification the read-all requests have covered, so that one
|
||||
// arriving while the page is open is marked read once, and the one the bell
|
||||
// already had isn't marked again after the first load.
|
||||
const lastNotificationRef = useRef(lastNotification);
|
||||
const markedReadRef = useRef(lastNotification);
|
||||
useLayoutEffect(() => {
|
||||
lastNotificationRef.current = lastNotification;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -330,6 +339,7 @@ export function Notifications(): React.JSX.Element {
|
||||
}
|
||||
// If this fails, the list is still worth showing, and they stay unread
|
||||
// for next time.
|
||||
markedReadRef.current = lastNotificationRef.current;
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
@@ -369,6 +379,8 @@ export function Notifications(): React.JSX.Element {
|
||||
const loaded = state.status === "loaded";
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
if (markedReadRef.current === lastNotification) return;
|
||||
markedReadRef.current = lastNotification;
|
||||
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.ok) clearUnreadNotifications();
|
||||
@@ -385,9 +397,8 @@ export function Notifications(): React.JSX.Element {
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const data = body.data as PaginatedData<Wire<Notification>>;
|
||||
.then((res) => expectOk<PaginatedData<Wire<Notification>>>(res))
|
||||
.then((data) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
|
||||
@@ -125,9 +125,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
// For the socket effects, which must not re-run when it changes.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// Every dump seen in the playlist, so that one hidden when it turned private
|
||||
// comes back when it turns public.
|
||||
// Every dump of the playlist seen so far, so that one hidden when it turned
|
||||
// private comes back when it turns public. A dump removed from the playlist
|
||||
// leaves it.
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// The removals still in flight, by dump, which an undo must wait for.
|
||||
const removalsRef = useRef<Map<string, Promise<unknown>>>(new Map());
|
||||
// The server's last order, where such a dump comes back.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
@@ -272,6 +275,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
// Shown until now and gone from the list: removed from the
|
||||
// playlist, not hidden (that comes as `dump_deleted`).
|
||||
knownDumpIdsRef.current.delete(id);
|
||||
setActiveDumpIds((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(id);
|
||||
@@ -480,17 +486,31 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
};
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId) return;
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
const index = state.playlist.dumps.findIndex((d) => d.id === dumpId);
|
||||
const dump = state.playlist.dumps[index];
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
// Removed on the server at once; the undo adds it back.
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "DELETE",
|
||||
}).then((res) => {
|
||||
const removal = authFetch(
|
||||
`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`,
|
||||
{ method: "DELETE" },
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
cancels.current.get(dumpId)?.();
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// The fade may have taken it out of the list already.
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || !dump) return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dumpId)) return s;
|
||||
const dumps = [...s.playlist.dumps];
|
||||
dumps.splice(Math.min(index, dumps.length), 0, dump);
|
||||
return { ...s, playlist: { ...s.playlist, dumps } };
|
||||
});
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
});
|
||||
}).finally(() => removalsRef.current.delete(dumpId));
|
||||
removalsRef.current.set(dumpId, removal);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
@@ -510,23 +530,41 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
.filter((d) => restoredIds.has(d.id))
|
||||
.map((d) => d.id);
|
||||
pendingUndoOrderRef.current = desiredOrder;
|
||||
knownDumpIdsRef.current.add(dumpId);
|
||||
// Added back only once the removal has landed, or the two could cross.
|
||||
// The server adds the dump first, so the order is restored after.
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
(removalsRef.current.get(dumpId) ?? Promise.resolve())
|
||||
.then(() =>
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
.then((res) => {
|
||||
// 409: the removal failed, so it never left.
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
|
||||
),
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
knownDumpIdsRef.current.delete(dumpId);
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFade(dumpId);
|
||||
setActionError(t`The dump couldn't be put back. Try again.`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingUndoOrderRef.current = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = () => setEditOpen(true);
|
||||
@@ -817,6 +855,12 @@ function PlaylistEditForm(
|
||||
) {
|
||||
const { authFetch, mediaToken } = useAuth();
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
// Each preview holds the file in memory until its URL is revoked: when the
|
||||
// next one replaces it, or when the form closes.
|
||||
useEffect(() => {
|
||||
if (!imagePreview) return;
|
||||
return () => URL.revokeObjectURL(imagePreview);
|
||||
}, [imagePreview]);
|
||||
const form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: playlist.title,
|
||||
|
||||
@@ -51,13 +51,10 @@ export function UserDumps(): React.JSX.Element {
|
||||
dumpItems,
|
||||
setItems,
|
||||
lastDumpEvent,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate && d.userId === profileUserId,
|
||||
);
|
||||
useDumpListSync(setItems, {
|
||||
ownerId: profileUserId ?? undefined,
|
||||
isOwner: isOwnProfile,
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
if (state.status === "loading") {
|
||||
|
||||
@@ -120,13 +120,11 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
createdItems,
|
||||
setCreated,
|
||||
lastPlaylistItem,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic && p.userId === profileUserId,
|
||||
);
|
||||
usePlaylistListSync(setCreated, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: profileUserId ?? undefined,
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
@@ -234,9 +232,8 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<Wire<Playlist>>;
|
||||
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
@@ -274,9 +271,8 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<Wire<Playlist>>;
|
||||
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
|
||||
.then(({ items, hasMore }) => {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
|
||||
@@ -337,13 +337,10 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
dumpItems,
|
||||
setDumps,
|
||||
lastDumpEvent,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate && d.userId === profileUserId,
|
||||
);
|
||||
useDumpListSync(setDumps, {
|
||||
ownerId: profileUserId ?? undefined,
|
||||
isOwner: isOwnProfile,
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
// A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade
|
||||
@@ -362,10 +359,9 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
voteItems,
|
||||
setVotes,
|
||||
lastDumpEvent,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
|
||||
);
|
||||
useDumpListSync(setVotes, { skipReinsert: true });
|
||||
useDumpListSync(setVotes);
|
||||
|
||||
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
@@ -382,13 +378,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
playlistItems,
|
||||
setPlaylists,
|
||||
lastPlaylistItem,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic && p.userId === profileUserId,
|
||||
);
|
||||
usePlaylistListSync(setPlaylists, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: profileUserId ?? undefined,
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -591,7 +585,10 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
withdrawnVoteIdsRef.current.add(dumpId);
|
||||
} else {
|
||||
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||
// Signed in, or a private dump the viewer owns can't be fetched.
|
||||
fetch(`${API_URL}/api/dumps/${dumpId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) return;
|
||||
@@ -613,7 +610,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [lastVoteEvent, profileUserId]);
|
||||
}, [lastVoteEvent, profileUserId, token]);
|
||||
|
||||
// Saves the lists and the scroll position, so that Back restores both.
|
||||
useEffect(() => {
|
||||
@@ -654,6 +651,16 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
);
|
||||
}, [playlistFeed, savePlaylists]);
|
||||
|
||||
// The two lazy tabs' fetches aren't aborted when the user switches tabs:
|
||||
// they finish in the background. Only another profile or leaving the page
|
||||
// aborts them, since their state is reset then anyway.
|
||||
const followedFetchRef = useRef<AbortController | null>(null);
|
||||
const inviteesFetchRef = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => {
|
||||
followedFetchRef.current?.abort();
|
||||
inviteesFetchRef.current?.abort();
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
tab !== "followed" || followedState !== null || state.status !== "loaded"
|
||||
@@ -663,6 +670,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
// Deferred, since the lint forbids a setState in an effect's body.
|
||||
queueMicrotask(() => setFollowedState({ status: "loading" }));
|
||||
const controller = new AbortController();
|
||||
followedFetchRef.current = controller;
|
||||
Promise.all([
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||
@@ -697,9 +705,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
if (err.name === "AbortError") return;
|
||||
setFollowedState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// followedState only guards the first fetch. As a dependency, its change
|
||||
// to "loading" would abort that fetch.
|
||||
// followedState only guards the first fetch; as a dependency, its change
|
||||
// to "loading" would run this again.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
@@ -712,6 +719,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
}
|
||||
queueMicrotask(() => setInviteTreeState({ status: "loading" }));
|
||||
const controller = new AbortController();
|
||||
inviteesFetchRef.current = controller;
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/invitees`,
|
||||
{ signal: controller.signal },
|
||||
@@ -727,7 +735,6 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
if (err.name === "AbortError") return;
|
||||
setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// inviteTreeState only guards the first fetch, as followedState above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
@@ -66,11 +66,18 @@ export function UserRegister(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
|
||||
// Aborted for a newer token, whose answer alone may land.
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => {
|
||||
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
|
||||
})
|
||||
.catch(() => setTokenState({ status: "invalid" }));
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") setTokenState({ status: "invalid" });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [token]);
|
||||
|
||||
const onSubmit = form.submit(async ({ username, email, password }) => {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { PageError } from "../components/layout/PageError.tsx";
|
||||
*/
|
||||
export function UserUpvoted(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
const { user: me } = useAuth();
|
||||
const { user: me, token } = useAuth();
|
||||
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||
|
||||
useDocumentTitle(username ?? null);
|
||||
@@ -124,7 +124,10 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
} else {
|
||||
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||
cancelFading(dumpId);
|
||||
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||
// Signed in, or a private dump the viewer owns can't be fetched.
|
||||
fetch(`${API_URL}/api/dumps/${dumpId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) return;
|
||||
@@ -138,7 +141,14 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [lastVoteEvent, profileUserId, startFading, cancelFading, setState]);
|
||||
}, [
|
||||
lastVoteEvent,
|
||||
profileUserId,
|
||||
startFading,
|
||||
cancelFading,
|
||||
setState,
|
||||
token,
|
||||
]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,6 @@ function setup(initial: Comment[], session: Session | null = me, ws = {}) {
|
||||
dumpId="d1"
|
||||
comments={comments}
|
||||
currentUser={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
|
||||
onCommentDeleted={(id) =>
|
||||
setComments((prev) =>
|
||||
@@ -67,7 +66,10 @@ describe("CommentThread", () => {
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reply" })).not
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Like" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("posting a comment", async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("FilePreview", () => {
|
||||
renderWithContexts(<FilePreview dump={file("application/zip")} />);
|
||||
const link = screen.getByRole("link", { name: /Download thing/ });
|
||||
expect(link).toHaveAttribute("download", "thing");
|
||||
expect(link).toHaveTextContent("(2.0 KB)");
|
||||
expect(link).toHaveTextContent("(2 kB)");
|
||||
});
|
||||
|
||||
test("compact audio plays in the global player", async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("ChatButton", () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<ChatContext.Provider
|
||||
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
|
||||
value={{ openChat }}
|
||||
>
|
||||
<ChatButton />
|
||||
</ChatContext.Provider>,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { renderWithContexts } from "../../render.tsx";
|
||||
function withChat(ui: React.ReactNode, openChat = vi.fn()) {
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{ isChatOpen: false, openChat, closeChat: () => {} }}
|
||||
value={{ openChat }}
|
||||
>
|
||||
{ui}
|
||||
</ChatContext.Provider>
|
||||
|
||||
@@ -40,6 +40,15 @@ describe("NotificationBell", () => {
|
||||
expect(bell()).toHaveClass("notification-bell--ringing");
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||
// Another in the middle of a ring doesn't leave the bell rung.
|
||||
rerender(withLast(makeNotification({ id: "n3" })));
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
rerender(withLast(makeNotification({ id: "n4" })));
|
||||
act(() => vi.advanceTimersByTime(700));
|
||||
expect(bell()).not.toHaveClass("notification-bell--ringing");
|
||||
rerender(withLast(makeNotification({ id: "n5" })));
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(bell()).toHaveClass("notification-bell--ringing");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { ThumbnailPlaceholder } from "../../../components/shared/ThumbnailPlaceholder.tsx";
|
||||
import { hueFromString } from "../../../utils/shared/thumbnailTint.ts";
|
||||
import { tintFor } from "../../../utils/shared/thumbnailTint.ts";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("ThumbnailPlaceholder", () => {
|
||||
@@ -22,7 +22,7 @@ describe("ThumbnailPlaceholder", () => {
|
||||
/>,
|
||||
);
|
||||
expect(box().style.getPropertyValue("--thumb-tint")).toBe(
|
||||
hueFromString("site.test"),
|
||||
tintFor({ seed: "site.test" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("UserListPopover", () => {
|
||||
await screen.findByText("b");
|
||||
});
|
||||
|
||||
test("closes on Escape, an outside press and a scroll — not on the anchor", async () => {
|
||||
test("closes on Escape, an outside press and a scroll — not on the anchor or its own scroll", async () => {
|
||||
route("GET /api/x/voters", () => page([]));
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
@@ -85,6 +85,9 @@ describe("UserListPopover", () => {
|
||||
await screen.findByText("No one yet.");
|
||||
await user.click(screen.getByText("anchor"));
|
||||
await user.click(screen.getByText("No one yet."));
|
||||
act(() => {
|
||||
screen.getByText("No one yet.").dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByText("outside"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { VoteButton } from "../../../components/social/VoteButton.tsx";
|
||||
import { route } from "../../fetch.ts";
|
||||
@@ -35,20 +35,31 @@ describe("VoteButton", () => {
|
||||
expect(onRemove).toHaveBeenCalledWith("d", 3);
|
||||
});
|
||||
|
||||
test("disabled for guests, with a hint", () => {
|
||||
test("disabled for guests, with a hint, but its count still opens", async () => {
|
||||
const onCast = vi.fn();
|
||||
renderWithContexts(
|
||||
<VoteButton
|
||||
dumpId="d"
|
||||
count={1}
|
||||
voted={false}
|
||||
disabled
|
||||
onCast={() => {}}
|
||||
onCast={onCast}
|
||||
onRemove={() => {}}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Upvote" });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute("aria-disabled", "true");
|
||||
expect(btn).toHaveAttribute("title", "Log in to vote");
|
||||
fireEvent.click(btn);
|
||||
expect(onCast).not.toHaveBeenCalled();
|
||||
// The count still opens who voted.
|
||||
route(
|
||||
"GET /api/dumps/:id/voters",
|
||||
() => page([makeUser({ username: "fan" })]),
|
||||
);
|
||||
fireEvent.click(screen.getByText("1"));
|
||||
expect(await screen.findByRole("menuitem", { name: /fan/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the count opens who voted, without voting", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { CategoriesProvider } from "../../../contexts/category/CategoriesProvider.tsx";
|
||||
@@ -30,10 +30,22 @@ describe("CategoriesProvider", () => {
|
||||
expect(result.current.categories).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a failure still ends loading", async () => {
|
||||
route("GET /api/categories", () => fail("x", 500));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.categories).toEqual([]);
|
||||
test("a failure isn't taken for an empty list, and is tried again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
route("GET /api/categories", () => fail("x", 500));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
route("GET /api/categories", () => wire([makeCategory({ id: "a" })]));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(true);
|
||||
expect(result.current.categories).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, renderHook, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { ChatProvider } from "../../../contexts/chat/ChatProvider.tsx";
|
||||
@@ -20,26 +20,20 @@ describe("ChatProvider", () => {
|
||||
),
|
||||
});
|
||||
act(() => guest.result.current.openChat());
|
||||
expect(guest.result.current.isChatOpen).toBe(false);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
test("?chat=open deep-links into it, and closing strips the param", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
renderApp(<></>, { session, route: "/somewhere?chat=open&x=1" });
|
||||
await screen.findByRole("dialog", { name: "Chat" });
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(currentPath()).toBe("/somewhere?x=1");
|
||||
});
|
||||
|
||||
test("openChat / closeChat", () => {
|
||||
test("openChat opens it, and its close button closes it", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
@@ -49,8 +43,8 @@ describe("ChatProvider", () => {
|
||||
}
|
||||
renderApp(<Probe />, { session });
|
||||
act(() => chat.openChat());
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
await screen.findByRole("dialog", { name: "Chat" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { FollowProvider } from "../../../contexts/social/FollowProvider.tsx";
|
||||
@@ -48,11 +48,18 @@ describe("FollowProvider", () => {
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
|
||||
route("POST /api/follows/users/:id", () => ({}));
|
||||
await act(() => result.current.followUser("u2"));
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.followUser("u2");
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("DELETE /api/follows/users/:id", () => fail("no", 500));
|
||||
await act(() => result.current.unfollowUser("u2"));
|
||||
await act(async () => {
|
||||
ok = await result.current.unfollowUser("u2");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
|
||||
@@ -68,4 +75,35 @@ describe("FollowProvider", () => {
|
||||
"/api/follows/playlists/p2",
|
||||
);
|
||||
});
|
||||
|
||||
test("a failed load keeps the buttons disabled, and is tried again", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/follows/status", () => fail("down", 500));
|
||||
const { result } = renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
route("GET /api/follows/status", () => ({
|
||||
followedUserIds: ["u1"],
|
||||
followedPlaylistIds: [],
|
||||
}));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(true);
|
||||
expect(result.current.followedUserIds.has("u1")).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,6 +275,43 @@ describe("votes", () => {
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
});
|
||||
|
||||
test("a welcome says a vote never landed, and its count goes back", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 4));
|
||||
expect(ws().voteCounts.d1).toBe(5);
|
||||
socket.welcome({ myVotes: [] });
|
||||
expect(ws().myVotes.has("d1")).toBe(false);
|
||||
expect(ws().voteCounts.d1).toBe(4);
|
||||
});
|
||||
|
||||
test("clicks before an ack share one rollback, and the last ack decides", () => {
|
||||
const { socket, ws } = setup();
|
||||
socket.welcome();
|
||||
act(() => ws().castVote("d1", 4));
|
||||
act(() => ws().removeVote("d1"));
|
||||
act(() => ws().castVote("d1"));
|
||||
expect(ws().voteCounts.d1).toBe(5);
|
||||
// The first ack doesn't flip the button back while others are in flight.
|
||||
socket.receive({
|
||||
type: "vote_ack",
|
||||
dumpId: "d1",
|
||||
action: "cast",
|
||||
voteCount: 5,
|
||||
});
|
||||
socket.receive({
|
||||
type: "vote_ack",
|
||||
dumpId: "d1",
|
||||
action: "remove",
|
||||
voteCount: 4,
|
||||
});
|
||||
expect(ws().myVotes.has("d1")).toBe(true);
|
||||
// The last one is lost: the timer restores the state before the clicks.
|
||||
act(() => vi.advanceTimersByTime(5_000));
|
||||
expect(ws().myVotes.has("d1")).toBe(false);
|
||||
expect(ws().voteCounts.d1).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment likes", () => {
|
||||
@@ -325,7 +362,6 @@ describe("comment likes", () => {
|
||||
});
|
||||
expect(ws().myLikes.has("c2")).toBe(true);
|
||||
expect(ws().likeCounts.c2).toBe(3);
|
||||
expect(ws().lastLikeEvent?.commentId).toBe("c2");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,27 +72,10 @@ describe("useDumpListSync", () => {
|
||||
expect(owner.hook.result.current.items[0].isPrivate).toBe(true);
|
||||
});
|
||||
|
||||
test("re-inserts only the owner's dumps, and only when asked", () => {
|
||||
const mine = makeDump({ id: "m", userId: "me" });
|
||||
const theirs = makeDump({ id: "t", userId: "them" });
|
||||
|
||||
const none = setup([]);
|
||||
none.emit({ lastDumpEvent: mine });
|
||||
expect(none.hook.result.current.items).toEqual([]);
|
||||
|
||||
const owned = setup([], { ownerId: "me" });
|
||||
owned.emit({ lastDumpEvent: theirs });
|
||||
expect(owned.hook.result.current.items).toEqual([]);
|
||||
owned.emit({ lastDumpEvent: mine });
|
||||
expect(ids(owned.hook.result.current.items)).toEqual(["m"]);
|
||||
|
||||
const skip = setup([], { ownerId: "me", skipReinsert: true });
|
||||
skip.emit({ lastDumpEvent: mine });
|
||||
expect(skip.hook.result.current.items).toEqual([]);
|
||||
|
||||
const privateOne = setup([], { ownerId: "me" });
|
||||
privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } });
|
||||
expect(privateOne.hook.result.current.items).toEqual([]);
|
||||
test("never adds a dump the list doesn't hold", () => {
|
||||
const { hook, emit } = setup([]);
|
||||
emit({ lastDumpEvent: makeDump({ id: "m", userId: "me" }) });
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("comment events move the count, never below zero; edits don't", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Dump } from "../../../../shared/dump.ts";
|
||||
import type { Playlist } from "../../../../shared/playlist.ts";
|
||||
import { useDumpListSync } from "../../../hooks/dump/useDumpListSync.ts";
|
||||
import { usePositionAwareSync } from "../../../hooks/feed/usePositionAwareSync.ts";
|
||||
import { useWS } from "../../../hooks/ws/useWS.ts";
|
||||
import { makeDump, makePlaylist } from "../../fixtures.ts";
|
||||
|
||||
/** A WS context whose value a test can push new events into. */
|
||||
@@ -42,14 +43,8 @@ describe("usePositionAwareSync", () => {
|
||||
const both = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Dump | null>(null);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(d) => d.isPrivate,
|
||||
(d) => !d.isPrivate,
|
||||
);
|
||||
useDumpListSync(setItems, { ownerId: "me", skipReinsert: true });
|
||||
usePositionAwareSync(items, setItems, event, (d) => !d.isPrivate);
|
||||
useDumpListSync(setItems);
|
||||
return { items, setEvent };
|
||||
}, { wrapper: ws.Wrapper });
|
||||
|
||||
@@ -63,30 +58,40 @@ describe("usePositionAwareSync", () => {
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("a dump that goes private for others, as a deletion, comes back where it was", () => {
|
||||
const ws = liveWS();
|
||||
const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" }));
|
||||
const both = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const { lastDumpEvent } = useWS();
|
||||
usePositionAwareSync(items, setItems, lastDumpEvent, (d) => !d.isPrivate);
|
||||
useDumpListSync(setItems);
|
||||
return { items };
|
||||
}, { wrapper: ws.Wrapper });
|
||||
ws.emit({ deletedDumpIds: new Set(["b"]) });
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "c"]);
|
||||
ws.emit({ deletedDumpIds: new Set(), lastDumpEvent: initial[1] });
|
||||
expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("a position is only used once, and clamps to the list end", () => {
|
||||
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
||||
const hook = renderHook(() => {
|
||||
const { items, setItems } = useList(initial);
|
||||
const [event, setEvent] = useState<Playlist | null>(null);
|
||||
usePositionAwareSync(
|
||||
items,
|
||||
setItems,
|
||||
event,
|
||||
(p) => !p.isPublic,
|
||||
(p) => p.isPublic,
|
||||
);
|
||||
usePositionAwareSync(items, setItems, event, (p) => p.isPublic);
|
||||
return { items, setItems, setEvent };
|
||||
});
|
||||
const c = initial[2];
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: false }));
|
||||
// It leaves, whatever takes it out, then the list shrinks further.
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 2)));
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
act(() => hook.result.current.setEvent({ ...c, isPublic: true }));
|
||||
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
|
||||
// Nothing saved any more: a second public event is left alone.
|
||||
act(() => hook.result.current.setItems((prev) => prev.slice(0, 1)));
|
||||
// Used once: the same event again adds nothing.
|
||||
act(() =>
|
||||
hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })
|
||||
);
|
||||
expect(ids(hook.result.current.items)).toEqual(["a"]);
|
||||
expect(ids(hook.result.current.items)).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("usePlaylistListSync", () => {
|
||||
expect(followed.hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("updated: in place, removed when private, re-added when public", () => {
|
||||
test("updated: in place, removed when private, never re-added by itself", () => {
|
||||
const p = makePlaylist({ id: "p", title: "one" });
|
||||
const { hook, emit } = setup([p]);
|
||||
emit({
|
||||
@@ -113,18 +113,8 @@ describe("usePlaylistListSync", () => {
|
||||
playlist: { ...p, title: "back" },
|
||||
},
|
||||
});
|
||||
expect(hook.result.current.items[0].title).toBe("back");
|
||||
});
|
||||
|
||||
test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => {
|
||||
const p = makePlaylist({ id: "p" });
|
||||
for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) {
|
||||
const { hook, emit } = setup([], opts);
|
||||
emit({
|
||||
lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p },
|
||||
});
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
}
|
||||
// usePositionAwareSync puts it back, where it was.
|
||||
expect(hook.result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("deleted and deletedPlaylistIds both remove", () => {
|
||||
|
||||
@@ -328,6 +328,16 @@ describe("DumpEdit page", () => {
|
||||
},
|
||||
});
|
||||
|
||||
test("someone else's dump gets no form", async () => {
|
||||
openEdit(urlDump(), other);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Only its poster or a moderator can edit this dump.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
|
||||
});
|
||||
|
||||
test("saves title, note, categories and visibility", async () => {
|
||||
route(
|
||||
"PUT /api/dumps/:id",
|
||||
@@ -466,6 +476,33 @@ describe("DumpEdit page", () => {
|
||||
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a replacement over the limit is refused before any upload", async () => {
|
||||
const fileDump = makeDump({
|
||||
id: "f1",
|
||||
kind: "file",
|
||||
title: "Song",
|
||||
userId: "owner",
|
||||
fileName: "song.mp3",
|
||||
fileMime: "audio/mpeg",
|
||||
fileSize: 10,
|
||||
url: undefined,
|
||||
});
|
||||
const user = openEdit(fileDump);
|
||||
await screen.findByText("song.mp3");
|
||||
const big = new File([new Uint8Array(3)], "big.mp3", {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
Object.defineProperty(big, "size", { value: 60 * 1024 * 1024 });
|
||||
await user.upload(
|
||||
document.querySelector<HTMLInputElement>('.fdz input[type="file"]')!,
|
||||
big,
|
||||
);
|
||||
expect(await screen.findByText("That file is over 50 MB."))
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("deleting asks, then goes home", async () => {
|
||||
route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 }));
|
||||
const user = openEdit(urlDump());
|
||||
|
||||
@@ -157,7 +157,10 @@ describe("Index feeds", () => {
|
||||
route("GET /api/dumps/", () => page([older]));
|
||||
setup("/~/new");
|
||||
await screen.findByText("Old but loved");
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Upvote" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("a reconnect backfills what was missed", async () => {
|
||||
|
||||
@@ -250,6 +250,12 @@ describe("Notifications", () => {
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent("bob upvoted Pic");
|
||||
expect(items[0]).toHaveClass("notification-item--unread");
|
||||
// The read-all after the load covered them: none is sent again.
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1)
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(calls("POST /api/notifications/read-all")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("load more", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
@@ -127,6 +127,19 @@ describe("UserPublicProfile", () => {
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("followed: leaving the tab while it loads still loads it", async () => {
|
||||
const users = deferred<unknown>();
|
||||
route("GET /api/users/:u/followed-users", () => users.promise);
|
||||
route("GET /api/users/:u/followed-playlists", () => page([]));
|
||||
const { user } = open({ url: "/users/ann/~/followed" });
|
||||
await screen.findByRole("heading", { name: "ann" });
|
||||
await user.click(screen.getByRole("button", { name: "Playlists" }));
|
||||
users.resolve(page([makeUser({ username: "bob" })]));
|
||||
await user.click(screen.getByRole("button", { name: "Followed" }));
|
||||
expect(await screen.findByRole("link", { name: /@bob/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("invitees as a tree", async () => {
|
||||
route("GET /api/users/:u/invitees", () =>
|
||||
wire([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { charCountClass, overLimit } from "../../../utils/shared/charCount.ts";
|
||||
import { charCountClass } from "../../../utils/shared/charCount.ts";
|
||||
|
||||
describe("character limits", () => {
|
||||
test("charCountClass warns from 85% and flags the limit", () => {
|
||||
@@ -7,8 +7,4 @@ describe("character limits", () => {
|
||||
expect(charCountClass(85, 100)).toBe(" text-editor-count--warn");
|
||||
expect(charCountClass(100, 100)).toBe(" text-editor-count--danger");
|
||||
});
|
||||
test("overLimit is strictly past it", () => {
|
||||
expect(overLimit("abc", 3)).toBe(false);
|
||||
expect(overLimit("abcd", 3)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
} from "../../../utils/shared/format.ts";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
test("B, KB and MB", () => {
|
||||
expect(formatBytes(512)).toBe("512 B");
|
||||
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||
test("bytes, kilobytes and megabytes, in the app's language", () => {
|
||||
expect(formatBytes(512)).toBe("512B");
|
||||
expect(formatBytes(1536)).toBe("1.5 kB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5 MB");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ async function load() {
|
||||
return await import("../../../utils/shared/sound.ts");
|
||||
}
|
||||
|
||||
// playChime claims the event asynchronously, under a lock shared by tabs.
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 3; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
const notes = () =>
|
||||
FakeAudioContext.instances.reduce((n, c) => n + c.oscillators, 0);
|
||||
|
||||
@@ -71,8 +76,10 @@ describe("playChime", () => {
|
||||
test("a notification plays two notes, a chat message one", async () => {
|
||||
const s = await load();
|
||||
s.playChime("notify", "n1");
|
||||
await settle();
|
||||
expect(notes()).toBe(2);
|
||||
s.playChime("chat", "c1");
|
||||
await settle();
|
||||
expect(notes()).toBe(3);
|
||||
});
|
||||
|
||||
@@ -80,13 +87,16 @@ describe("playChime", () => {
|
||||
const s = await load();
|
||||
s.writeSoundsEnabled(false);
|
||||
s.playChime("notify", "n1");
|
||||
await settle();
|
||||
expect(notes()).toBe(0);
|
||||
});
|
||||
|
||||
test("one chime per event across tabs", async () => {
|
||||
const s = await load();
|
||||
s.playChime("notify", "same");
|
||||
await settle();
|
||||
s.playChime("notify", "same");
|
||||
await settle();
|
||||
expect(notes()).toBe(2);
|
||||
expect(localStorage.getItem("sounds-last-claim")).toBe("notify:same");
|
||||
});
|
||||
@@ -94,19 +104,24 @@ describe("playChime", () => {
|
||||
test("chat blips are throttled", async () => {
|
||||
const s = await load();
|
||||
s.playChime("chat", "a");
|
||||
await settle();
|
||||
s.playChime("chat", "b");
|
||||
await settle();
|
||||
expect(notes()).toBe(1);
|
||||
vi.advanceTimersByTime(4_001);
|
||||
s.playChime("chat", "c");
|
||||
await settle();
|
||||
expect(notes()).toBe(2);
|
||||
});
|
||||
|
||||
test("a suspended context stays quiet until a gesture unlocks it", async () => {
|
||||
const s = await load();
|
||||
s.playChime("notify", "warmup"); // creates the context
|
||||
await settle();
|
||||
const ctx = FakeAudioContext.instances[0];
|
||||
ctx.state = "suspended";
|
||||
s.playChime("notify", "n2");
|
||||
await settle();
|
||||
expect(ctx.oscillators).toBe(2);
|
||||
globalThis.dispatchEvent(new Event("keydown"));
|
||||
expect(ctx.resume).toHaveBeenCalled();
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
hueFromString,
|
||||
initialsFor,
|
||||
isSafeHex,
|
||||
tintFor,
|
||||
} from "../../../utils/shared/thumbnailTint.ts";
|
||||
import { initialsFor, tintFor } from "../../../utils/shared/thumbnailTint.ts";
|
||||
|
||||
const hue = (tint: string) => Number(/hsl\((\d+)/.exec(tint)?.[1]);
|
||||
|
||||
describe("thumbnail tint", () => {
|
||||
test("isSafeHex accepts only canonical #rrggbb", () => {
|
||||
expect(isSafeHex("#a1b2c3")).toBe(true);
|
||||
expect(isSafeHex("#A1B2C3")).toBe(false);
|
||||
expect(isSafeHex("#abc")).toBe(false);
|
||||
expect(isSafeHex("red")).toBe(false);
|
||||
expect(isSafeHex("#000000;background:url(x)")).toBe(false);
|
||||
expect(isSafeHex(undefined)).toBe(false);
|
||||
test("tintFor takes only a canonical #rrggbb page colour", () => {
|
||||
const url = "https://a.b";
|
||||
expect(tintFor({ accentColor: "#a1b2c3", url })).toBe("#a1b2c3");
|
||||
for (
|
||||
const unsafe of [
|
||||
"#A1B2C3",
|
||||
"#abc",
|
||||
"red",
|
||||
"#000000;background:url(x)",
|
||||
undefined,
|
||||
]
|
||||
) {
|
||||
expect(tintFor({ accentColor: unsafe, url })).toMatch(/^hsl\(/);
|
||||
}
|
||||
});
|
||||
|
||||
test("hueFromString is stable and in range", () => {
|
||||
expect(hueFromString("example.com")).toBe(hueFromString("example.com"));
|
||||
const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]);
|
||||
expect(hue).toBeGreaterThanOrEqual(0);
|
||||
expect(hue).toBeLessThan(360);
|
||||
});
|
||||
|
||||
test("tintFor prefers the page colour, then the hostname sans www", () => {
|
||||
expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe(
|
||||
"#112233",
|
||||
);
|
||||
expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe(
|
||||
hueFromString("a.b"),
|
||||
);
|
||||
expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3"));
|
||||
expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url"));
|
||||
test("tintFor falls back to a stable hue of the seed or the hostname sans www", () => {
|
||||
const fromHost = tintFor({ url: "https://www.a.b/x" });
|
||||
expect(tintFor({ url: "https://a.b/y" })).toBe(fromHost);
|
||||
expect(tintFor({ seed: "a.b" })).toBe(fromHost);
|
||||
expect(tintFor({ seed: "file.mp3" })).toBe(tintFor({ seed: "file.mp3" }));
|
||||
expect(tintFor({ url: "not a url" })).toBe(tintFor({ seed: "not a url" }));
|
||||
expect(hue(fromHost)).toBeGreaterThanOrEqual(0);
|
||||
expect(hue(fromHost)).toBeLessThan(360);
|
||||
});
|
||||
|
||||
test("initialsFor takes the first letter or digit", () => {
|
||||
|
||||
@@ -24,6 +24,19 @@ describe("visited", () => {
|
||||
expect(isDumpVisited("a")).toBe(true);
|
||||
});
|
||||
|
||||
test("the old list format still counts, and week-old visits are dropped", () => {
|
||||
const day = 24 * 3600 * 1000;
|
||||
localStorage.setItem("visited_dumps", JSON.stringify(["old"]));
|
||||
expect(isDumpVisited("old")).toBe(true);
|
||||
localStorage.setItem(
|
||||
"visited_dumps",
|
||||
JSON.stringify({ stale: Date.now() - 8 * day, fresh: Date.now() }),
|
||||
);
|
||||
markDumpVisited("new");
|
||||
expect(Object.keys(JSON.parse(localStorage.getItem("visited_dumps")!)))
|
||||
.toEqual(["fresh", "new"]);
|
||||
});
|
||||
|
||||
test("isRecent is a seven-day window", () => {
|
||||
const day = 24 * 3600 * 1000;
|
||||
expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true);
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
[data-style="brutalist"] .modal-close-btn:hover,
|
||||
[data-style="brutalist"] .playlist-remove-btn:hover,
|
||||
[data-style="brutalist"] .playlist-card-delete-btn:hover,
|
||||
[data-style="brutalist"] .vote-btn:hover:not(:disabled),
|
||||
[data-style="brutalist"] .vote-btn:hover:not([aria-disabled="true"]),
|
||||
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
|
||||
[data-style="brutalist"] .emoji-picker-close-btn:hover,
|
||||
[data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled),
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
--journal-title-hover-shadow: inset 2px 0 0 var(--kngw-accent-on-photo);
|
||||
}
|
||||
|
||||
[data-style="kanagawa"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) {
|
||||
[data-style="kanagawa"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) {
|
||||
color: var(--kngw-accent-on-photo);
|
||||
border-color: var(--kngw-accent-on-photo);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { APIResponse } from "../../../shared/api.ts";
|
||||
|
||||
/**
|
||||
@@ -17,19 +18,20 @@ export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
|
||||
if (raw !== null && typeof raw === "object" && "success" in raw) {
|
||||
return raw as APIResponse<T>;
|
||||
}
|
||||
throw new Error("Unexpected response format");
|
||||
throw new Error(t`Unexpected response format`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message to show for `err`, an error caught around a request. A
|
||||
* `TypeError` means the request never reached the server (no network, CORS),
|
||||
* and gets a message saying so. The messages are not translated.
|
||||
* and gets a message saying so. The server's own messages come through as
|
||||
* they are.
|
||||
*/
|
||||
export function friendlyFetchError(err: unknown): string {
|
||||
if (err instanceof TypeError) {
|
||||
return "Could not connect to the server. Check your connection and try again.";
|
||||
return t`Could not connect to the server. Check your connection and try again.`;
|
||||
}
|
||||
return err instanceof Error ? err.message : "An unexpected error occurred.";
|
||||
return err instanceof Error ? err.message : t`An unexpected error occurred.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,11 +15,3 @@ export function charCountClass(len: number, max: number): string {
|
||||
if (len >= max * 0.85) return " text-editor-count--warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `value` is longer than `max`, which the server would refuse.
|
||||
* Only the tests call it.
|
||||
*/
|
||||
export function overLimit(value: string, max: number): boolean {
|
||||
return value.length > max;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,26 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
/**
|
||||
* Returns `bytes` as a size in B, KB or MB, where a KB is 1024 bytes. The units
|
||||
* are not translated.
|
||||
* Returns `bytes` as a size in bytes, kilobytes or megabytes, where a
|
||||
* kilobyte is 1024 bytes, in the app's language ("1.5 kB", "1,5 ko").
|
||||
* `Intl` names the units, so they follow the language without a catalog.
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
const [value, unit] = bytes < 1024
|
||||
? [bytes, "byte"]
|
||||
: bytes < 1024 * 1024
|
||||
? [bytes / 1024, "kilobyte"]
|
||||
: [bytes / (1024 * 1024), "megabyte"];
|
||||
return new Intl.NumberFormat(i18n.locale, {
|
||||
style: "unit",
|
||||
unit,
|
||||
// English's short form of a byte is "byte", even for 512.
|
||||
unitDisplay: unit === "byte" ? "narrow" : "short",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,12 +10,13 @@ import { i18n } from "@lingui/core";
|
||||
/**
|
||||
* Returns how long ago `date` was, as "3 minutes ago", in the current locale.
|
||||
* It is computed at render, so the text only moves on when the caller
|
||||
* re-renders. A month counts 30 days and a year 365.
|
||||
* re-renders. A month counts 30 days and a year 365. Every date shown is of
|
||||
* something that happened, so one in the future, which only a clock ahead of
|
||||
* this one can give, reads as "now".
|
||||
*/
|
||||
export function relativeTime(date: Date): string {
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
|
||||
const diff = date.getTime() - Date.now();
|
||||
const abs = Math.abs(diff) / 1000;
|
||||
const abs = Math.max(0, Date.now() - date.getTime()) / 1000;
|
||||
|
||||
if (abs < 60) return rtf.format(-Math.round(abs), "second");
|
||||
if (abs < 3600) return rtf.format(-Math.round(abs / 60), "minute");
|
||||
|
||||
@@ -75,11 +75,8 @@ function unlock() {
|
||||
globalThis.addEventListener("pointerdown", unlock);
|
||||
globalThis.addEventListener("keydown", unlock);
|
||||
|
||||
/**
|
||||
* Claims `eventId` for this tab, and returns whether no other tab had. Two tabs
|
||||
* checking at the same instant can both win.
|
||||
*/
|
||||
function claim(eventId: string): boolean {
|
||||
/** Checks `CLAIM_KEY` and sets it to `eventId`, in one step or not at all. */
|
||||
function checkAndSet(eventId: string): boolean {
|
||||
try {
|
||||
if (localStorage.getItem(CLAIM_KEY) === eventId) return false;
|
||||
localStorage.setItem(CLAIM_KEY, eventId);
|
||||
@@ -89,6 +86,17 @@ function claim(eventId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims `eventId` for this tab, and resolves to whether no other tab had. The
|
||||
* check and the write run under a lock shared by the site's tabs, so two tabs
|
||||
* can't both win; a browser without Web Locks goes without it.
|
||||
*/
|
||||
function claim(eventId: string): Promise<boolean> {
|
||||
const locks = globalThis.navigator?.locks;
|
||||
if (!locks) return Promise.resolve(checkAndSet(eventId));
|
||||
return locks.request(CLAIM_KEY, () => checkAndSet(eventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays one note of `freq` Hz on `ac`, from `start` for `duration` seconds,
|
||||
* peaking at gain `peak`.
|
||||
@@ -141,8 +149,9 @@ export function playChime(kind: ChimeKind, eventId: string): void {
|
||||
if (now - lastChatAt < CHAT_THROTTLE) return;
|
||||
lastChatAt = now;
|
||||
}
|
||||
if (!claim(`${kind}:${eventId}`)) return;
|
||||
render(kind);
|
||||
void claim(`${kind}:${eventId}`).then((won) => {
|
||||
if (won) render(kind);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* property is substituted verbatim wherever it is used, unparsed. So the form
|
||||
* is checked here before it reaches a style.
|
||||
*/
|
||||
export function isSafeHex(value: string | undefined): value is string {
|
||||
function isSafeHex(value: string | undefined): value is string {
|
||||
return !!value && /^#[0-9a-f]{6}$/.test(value);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function hashString(seed: string): number {
|
||||
* Returns a colour of a hue drawn from `seed`, so that the same site always
|
||||
* gets the same tint.
|
||||
*/
|
||||
export function hueFromString(seed: string): string {
|
||||
function hueFromString(seed: string): string {
|
||||
return `hsl(${hashString(seed) % 360} 55% 55%)`;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,32 +10,65 @@ const PLAYLIST_KEY = "visited_playlists";
|
||||
/** The age past which an item shows no unread dot, visited or not. */
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function loadSet(key: string): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
// Each key's visits, id to epoch ms, with the stored string they were parsed
|
||||
// from: reading the string is cheap, and it is parsed again only when it
|
||||
// changed (another tab, or cleared storage).
|
||||
const cache = new Map<
|
||||
string,
|
||||
{ raw: string | null; visits: Map<string, number> }
|
||||
>();
|
||||
|
||||
function saveSet(key: string, set: Set<string>): void {
|
||||
function load(key: string): Map<string, number> {
|
||||
let raw: string | null;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...set]));
|
||||
} catch { /* With storage full or blocked, the visit is forgotten. */ }
|
||||
raw = localStorage.getItem(key);
|
||||
} catch {
|
||||
// Blocked storage: whatever this page recorded.
|
||||
return cache.get(key)?.visits ?? new Map();
|
||||
}
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.raw === raw) return cached.visits;
|
||||
let visits = new Map<string, number>();
|
||||
try {
|
||||
const parsed = JSON.parse(raw ?? "{}") as Record<string, number> | string[];
|
||||
// A list from before visits had times counts as visited now.
|
||||
visits = Array.isArray(parsed)
|
||||
? new Map(parsed.map((id) => [id, Date.now()]))
|
||||
: new Map(Object.entries(parsed));
|
||||
} catch {
|
||||
// Unreadable: nothing visited.
|
||||
}
|
||||
cache.set(key, { raw, visits });
|
||||
return visits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether dump `id` was opened in this browser. It parses the whole
|
||||
* stored list on every call.
|
||||
* Records `id` as visited under `key`, and drops the visits older than 7 days:
|
||||
* an item was at most that old when visited, so past then it shows no dot
|
||||
* anyway, and the list stays small.
|
||||
*/
|
||||
function record(key: string, id: string): void {
|
||||
const visits = load(key);
|
||||
const now = Date.now();
|
||||
visits.set(id, now);
|
||||
for (const [visitedId, at] of visits) {
|
||||
if (now - at > MAX_AGE_MS) visits.delete(visitedId);
|
||||
}
|
||||
const raw = JSON.stringify(Object.fromEntries(visits));
|
||||
cache.set(key, { raw, visits });
|
||||
try {
|
||||
localStorage.setItem(key, raw);
|
||||
} catch { /* With storage full or blocked, the visit lasts until reload. */ }
|
||||
}
|
||||
|
||||
/** Checks whether dump `id` was opened in this browser. */
|
||||
export function isDumpVisited(id: string): boolean {
|
||||
return loadSet(DUMP_KEY).has(id);
|
||||
return load(DUMP_KEY).has(id);
|
||||
}
|
||||
|
||||
/** Checks whether playlist `id` was opened in this browser. */
|
||||
export function isPlaylistVisited(id: string): boolean {
|
||||
return loadSet(PLAYLIST_KEY).has(id);
|
||||
return load(PLAYLIST_KEY).has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,16 +76,12 @@ export function isPlaylistVisited(id: string): boolean {
|
||||
* keeps its dot until it renders again.
|
||||
*/
|
||||
export function markDumpVisited(id: string): void {
|
||||
const set = loadSet(DUMP_KEY);
|
||||
set.add(id);
|
||||
saveSet(DUMP_KEY, set);
|
||||
record(DUMP_KEY, id);
|
||||
}
|
||||
|
||||
/** Records playlist `id` as opened, as {@link markDumpVisited} does a dump. */
|
||||
export function markPlaylistVisited(id: string): void {
|
||||
const set = loadSet(PLAYLIST_KEY);
|
||||
set.add(id);
|
||||
saveSet(PLAYLIST_KEY, set);
|
||||
record(PLAYLIST_KEY, id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user