v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -0,0 +1,21 @@
import { createContext } from "react";
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>;
isLoaded: boolean;
}
export const FollowContext = createContext<FollowContextValue>({
followedUserIds: new Set(),
followedPlaylistIds: new Set(),
followUser: async () => {},
unfollowUser: async () => {},
followPlaylist: async () => {},
unfollowPlaylist: async () => {},
isLoaded: false,
});

View File

@@ -0,0 +1,125 @@
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { FollowStatus } from "../model.ts";
export function FollowProvider({ children }: { children: ReactNode }) {
const { token, authFetch } = useAuth();
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
new Set(),
);
const [followedPlaylistIds, setFollowedPlaylistIds] = useState<Set<string>>(
new Set(),
);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (!token) {
setFollowedUserIds(new Set());
setFollowedPlaylistIds(new Set());
setIsLoaded(false);
return;
}
let cancelled = false;
fetch(`${API_URL}/api/follows/status`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((body) => {
if (cancelled || !body.success) return;
const status = body.data as FollowStatus;
setFollowedUserIds(new Set(status.followedUserIds));
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
setIsLoaded(true);
})
.catch(() => {
if (!cancelled) setIsLoaded(true);
});
return () => {
cancelled = true;
};
}, [token]);
const followUser = useCallback(async (userId: string) => {
setFollowedUserIds((prev) => new Set([...prev, userId]));
try {
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
method: "POST",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch {
setFollowedUserIds((prev) => {
const n = new Set(prev);
n.delete(userId);
return n;
});
}
}, [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 value: FollowContextValue = {
followedUserIds,
followedPlaylistIds,
followUser,
unfollowUser,
followPlaylist,
unfollowPlaylist,
isLoaded,
};
return (
<FollowContext.Provider value={value}>
{children}
</FollowContext.Provider>
);
}

View File

@@ -1,5 +1,11 @@
import { createContext } from "react";
import type { Comment, Dump, OnlineUser, Playlist } from "../model.ts";
import type {
Comment,
Dump,
Notification,
OnlineUser,
Playlist,
} from "../model.ts";
export interface VoteEvent {
dumpId: string;
@@ -33,8 +39,12 @@ export interface WSContextValue {
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null;
unreadNotificationCount: number;
lastNotification: Notification | null;
castVote: (dumpId: string) => void;
removeVote: (dumpId: string) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
}
export const WSContext = createContext<WSContextValue>({
@@ -48,6 +58,10 @@ export const WSContext = createContext<WSContextValue>({
lastPlaylistEvent: null,
deletedPlaylistIds: new Set(),
lastCommentEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
castVote: () => {},
removeVote: () => {},
injectDump: () => {},
clearUnreadNotifications: () => {},
});

View File

@@ -16,14 +16,17 @@ import {
import { WS_URL } from "../config/api.ts";
import type {
Dump,
Notification,
OnlineUser,
RawComment,
RawDump,
RawNotification,
RawPlaylist,
} from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializeNotification,
deserializePlaylist,
} from "../model.ts";
@@ -52,6 +55,10 @@ export function WSProvider({ children, token }: WSProviderProps) {
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
null,
);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const [lastNotification, setLastNotification] = useState<Notification | null>(
null,
);
// Refs to avoid stale closures in event handlers
const voteCountsRef = useRef(voteCounts);
@@ -100,6 +107,9 @@ export function WSProvider({ children, token }: WSProviderProps) {
const votes = msg.myVotes as string[];
setOnlineUsers(users);
setMyVotes(new Set(votes));
setUnreadNotificationCount(
(msg.unreadNotificationCount as number) ?? 0,
);
break;
}
@@ -217,6 +227,15 @@ export function WSProvider({ children, token }: WSProviderProps) {
break;
}
case "notification_created": {
const notification = deserializeNotification(
msg.notification as RawNotification,
);
setLastNotification(notification);
setUnreadNotificationCount((prev) => prev + 1);
break;
}
case "error":
// On error, revert any pending optimistic update for the affected dump
// (the revert timeout will handle it)
@@ -309,6 +328,17 @@ export function WSProvider({ children, token }: WSProviderProps) {
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
}, []);
const injectDump = useCallback((dump: Dump) => {
setRecentDumps((prev) => {
if (prev.some((d) => d.id === dump.id)) return prev;
return [dump, ...prev];
});
}, []);
const clearUnreadNotifications = useCallback(() => {
setUnreadNotificationCount(0);
}, []);
const value: WSContextValue = {
onlineUsers,
voteCounts,
@@ -320,8 +350,12 @@ export function WSProvider({ children, token }: WSProviderProps) {
lastPlaylistEvent,
deletedPlaylistIds,
lastCommentEvent,
unreadNotificationCount,
lastNotification,
castVote,
removeVote,
injectDump,
clearUnreadNotifications,
};
return (