v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
@@ -19,6 +19,8 @@ import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
@@ -114,10 +116,9 @@ export function UserPublicProfile() {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
lastVoteEvent,
|
||||
lastDumpEvent,
|
||||
castVote,
|
||||
removeVote,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
} = useWS();
|
||||
|
||||
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
|
||||
@@ -136,11 +137,104 @@ export function UserPublicProfile() {
|
||||
);
|
||||
|
||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const prev = s.dumps.items;
|
||||
const next = fn(prev);
|
||||
if (next.length < prev.length) {
|
||||
const nextIds = new Set(next.map((d) => d.id));
|
||||
prev.forEach((d, idx) => {
|
||||
if (!nextIds.has(d.id)) removedDumpPositionsRef.current.set(d.id, idx);
|
||||
});
|
||||
}
|
||||
return { ...s, dumps: { ...s.dumps, items: next } };
|
||||
});
|
||||
}, []);
|
||||
// No addFilter — insertion at correct position is handled by the effect below.
|
||||
useDumpListSync(setDumps);
|
||||
|
||||
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Tracks the list index of each dump at the moment it was removed from the
|
||||
// votes list, so we can re-insert it at the correct position when it becomes
|
||||
// public again (instead of always prepending at position 0).
|
||||
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
|
||||
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
||||
// a future dump_updated event (that would only be for private→public transitions).
|
||||
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const prev = s.votes.items;
|
||||
const next = fn(prev);
|
||||
if (next.length < prev.length) {
|
||||
const nextIds = new Set(next.map((d) => d.id));
|
||||
prev.forEach((d, idx) => {
|
||||
if (!nextIds.has(d.id)) removedVotePositionsRef.current.set(d.id, idx);
|
||||
});
|
||||
}
|
||||
return { ...s, votes: { ...s.votes, items: next } };
|
||||
});
|
||||
}, []);
|
||||
useDumpListSync(setVotes);
|
||||
|
||||
// Re-insert a vote-list dump at its original position after private→public.
|
||||
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally).
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
||||
const dump = lastDumpEvent;
|
||||
if (withdrawnVoteIdsRef.current.has(dump.id)) return;
|
||||
const savedIdx = removedVotePositionsRef.current.get(dump.id);
|
||||
if (savedIdx === undefined) return;
|
||||
removedVotePositionsRef.current.delete(dump.id);
|
||||
setVotes((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
||||
return next;
|
||||
});
|
||||
}, [lastDumpEvent, setVotes]);
|
||||
|
||||
// Re-insert a dumps-column dump at its original position after private→public.
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
||||
const dump = lastDumpEvent;
|
||||
if (dump.userId !== profileUserId) return;
|
||||
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
|
||||
if (savedIdx === undefined) return;
|
||||
removedDumpPositionsRef.current.delete(dump.id);
|
||||
setDumps((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
||||
return next;
|
||||
});
|
||||
}, [lastDumpEvent, profileUserId, setDumps]);
|
||||
|
||||
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||
setState((s) =>
|
||||
s.status !== "loaded"
|
||||
? s
|
||||
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
|
||||
);
|
||||
}, []);
|
||||
usePlaylistListSync(setPlaylists, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: profileUserId ?? undefined,
|
||||
});
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
@@ -260,8 +354,6 @@ export function UserPublicProfile() {
|
||||
})();
|
||||
}, [username]);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
|
||||
// Own profile: keep profileVotedIds in sync with myVotes
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
@@ -301,7 +393,10 @@ export function UserPublicProfile() {
|
||||
return n;
|
||||
});
|
||||
}
|
||||
withdrawnVoteIdsRef.current.add(dumpId);
|
||||
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
} else {
|
||||
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||
if (!isOwnProfile) {
|
||||
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
||||
}
|
||||
@@ -327,65 +422,6 @@ export function UserPublicProfile() {
|
||||
}
|
||||
}, [lastVoteEvent, me, profileUserId]);
|
||||
|
||||
// Real-time playlist updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
||||
const profileUserId = state.user.id;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
const ev = lastPlaylistEvent;
|
||||
|
||||
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
|
||||
if (ev.playlist.isPublic || isOwnProfile) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: [ev.playlist!, ...s.playlists.items],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: s.playlists.items
|
||||
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
|
||||
.filter((p) => p.isPublic || isOwnProfile),
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [lastPlaylistEvent, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const filtered = s.playlists.items.filter((p) =>
|
||||
!deletedPlaylistIds.has(p.id)
|
||||
);
|
||||
if (filtered.length === s.playlists.items.length) return s;
|
||||
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
||||
});
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
// Save scroll position + loaded state to sessionStorage on scroll
|
||||
useEffect(() => {
|
||||
@@ -506,7 +542,6 @@ export function UserPublicProfile() {
|
||||
}
|
||||
|
||||
const { user: profileUser, dumps, votes, playlists } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
Reference in New Issue
Block a user