v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
145
src/contexts/social/FollowProvider.tsx
Normal file
145
src/contexts/social/FollowProvider.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/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) {
|
||||
// Deferred to a microtask (rather than called directly) so this
|
||||
// doesn't fire synchronously within the effect body — same timing
|
||||
// for the user, just not in the same tick.
|
||||
queueMicrotask(() => {
|
||||
setFollowedUserIds(new Set());
|
||||
setFollowedPlaylistIds(new Set());
|
||||
setIsLoaded(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_URL}/api/follows/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) return;
|
||||
const status = body.data as FollowStatus;
|
||||
setFollowedUserIds(new Set(status.followedUserIds));
|
||||
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") setIsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [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 = useMemo(() => ({
|
||||
followedUserIds,
|
||||
followedPlaylistIds,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
followPlaylist,
|
||||
unfollowPlaylist,
|
||||
isLoaded,
|
||||
}), [
|
||||
followedUserIds,
|
||||
followedPlaylistIds,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
followPlaylist,
|
||||
unfollowPlaylist,
|
||||
isLoaded,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FollowContext.Provider value={value}>
|
||||
{children}
|
||||
</FollowContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user