v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
21
src/contexts/FollowContext.ts
Normal file
21
src/contexts/FollowContext.ts
Normal 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,
|
||||
});
|
||||
125
src/contexts/FollowProvider.tsx
Normal file
125
src/contexts/FollowProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user