feat: pin team-posted comments to the top of the watch page
Browse filesPosting a comment now sends the random team-member name and the active video id to the API so the comment is persisted under that video. The page refetches only user_comment rows for the current video which guarantees the team posts stay above the YouTube thread on reload. Optimistic session items are deduped against the refreshed Supabase rows to avoid a flicker.
- frontend/src/pages/WatchPage.tsx +41 -23
- frontend/src/types/api.ts +2 -0
frontend/src/pages/WatchPage.tsx
CHANGED
|
@@ -51,9 +51,13 @@ export function WatchPage() {
|
|
| 51 |
const { result, loading, error } = useDebouncedPredict(draft, threshold);
|
| 52 |
|
| 53 |
const refreshRecent = useCallback(async (videoId?: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
setRecentLoading(true);
|
| 55 |
try {
|
| 56 |
-
const res = await listPredictions(videoId,
|
| 57 |
setRecentActivity(Array.isArray(res?.predictions) ? res.predictions : []);
|
| 58 |
} catch {
|
| 59 |
// Degrade gracefully if endpoint is missing or DB not configured
|
|
@@ -86,8 +90,12 @@ export function WatchPage() {
|
|
| 86 |
if (!text || posting) return;
|
| 87 |
setPosting(true);
|
| 88 |
try {
|
| 89 |
-
const analysis = result ?? (await predict(text, threshold));
|
| 90 |
const author = randomTeamMember();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const item: CommentItem = {
|
| 92 |
id: newId(),
|
| 93 |
user: author,
|
|
@@ -112,7 +120,7 @@ export function WatchPage() {
|
|
| 112 |
} finally {
|
| 113 |
setPosting(false);
|
| 114 |
}
|
| 115 |
-
}, [draft, posting,
|
| 116 |
|
| 117 |
const loadVideo = async (video: SuggestedVideo) => {
|
| 118 |
setActiveVideo(video);
|
|
@@ -286,26 +294,36 @@ export function WatchPage() {
|
|
| 286 |
)}
|
| 287 |
|
| 288 |
<div className="comment-list">
|
| 289 |
-
{/*
|
| 290 |
-
{recentActivity
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
{/* YouTube fetched comments — below */}
|
| 310 |
{youtubeComments.map((c) => (
|
| 311 |
<CommentRow key={c.id} comment={c} />
|
|
|
|
| 51 |
const { result, loading, error } = useDebouncedPredict(draft, threshold);
|
| 52 |
|
| 53 |
const refreshRecent = useCallback(async (videoId?: string) => {
|
| 54 |
+
if (!videoId) {
|
| 55 |
+
setRecentActivity([]);
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
setRecentLoading(true);
|
| 59 |
try {
|
| 60 |
+
const res = await listPredictions(videoId, 200, "user_comment");
|
| 61 |
setRecentActivity(Array.isArray(res?.predictions) ? res.predictions : []);
|
| 62 |
} catch {
|
| 63 |
// Degrade gracefully if endpoint is missing or DB not configured
|
|
|
|
| 90 |
if (!text || posting) return;
|
| 91 |
setPosting(true);
|
| 92 |
try {
|
|
|
|
| 93 |
const author = randomTeamMember();
|
| 94 |
+
const analysis = await predict(text, threshold, {
|
| 95 |
+
videoId: activeVideo?.id,
|
| 96 |
+
author,
|
| 97 |
+
persist: true,
|
| 98 |
+
});
|
| 99 |
const item: CommentItem = {
|
| 100 |
id: newId(),
|
| 101 |
user: author,
|
|
|
|
| 120 |
} finally {
|
| 121 |
setPosting(false);
|
| 122 |
}
|
| 123 |
+
}, [draft, posting, threshold, addHubEntry, refreshRecent, activeVideo?.id, t]);
|
| 124 |
|
| 125 |
const loadVideo = async (video: SuggestedVideo) => {
|
| 126 |
setActiveVideo(video);
|
|
|
|
| 294 |
)}
|
| 295 |
|
| 296 |
<div className="comment-list">
|
| 297 |
+
{/* Persisted user comments for this video — always at the top (newest first) */}
|
| 298 |
+
{recentActivity
|
| 299 |
+
.filter((rec) => rec.source === "user_comment")
|
| 300 |
+
.map((rec, idx) => (
|
| 301 |
+
<CommentRow
|
| 302 |
+
key={`recent-${rec.id ?? idx}`}
|
| 303 |
+
comment={{
|
| 304 |
+
id: `recent-${rec.id ?? idx}`,
|
| 305 |
+
user: rec.author ?? randomUsername(`supa-${rec.id ?? idx}`),
|
| 306 |
+
text: truncate(rec.text, 140),
|
| 307 |
+
time: relativeTime(rec.created_at),
|
| 308 |
+
is_toxic: rec.is_toxic,
|
| 309 |
+
probability: rec.probability,
|
| 310 |
+
labels: rec.labels ?? [],
|
| 311 |
+
source: "recent",
|
| 312 |
+
}}
|
| 313 |
+
/>
|
| 314 |
+
))}
|
| 315 |
+
{/* Optimistic local additions until refresh from Supabase completes */}
|
| 316 |
+
{[...sessionComments]
|
| 317 |
+
.reverse()
|
| 318 |
+
.filter(
|
| 319 |
+
(c) =>
|
| 320 |
+
!recentActivity.some(
|
| 321 |
+
(r) => r.text === c.text && r.author === c.user,
|
| 322 |
+
),
|
| 323 |
+
)
|
| 324 |
+
.map((c) => (
|
| 325 |
+
<CommentRow key={c.id} comment={c} />
|
| 326 |
+
))}
|
| 327 |
{/* YouTube fetched comments — below */}
|
| 328 |
{youtubeComments.map((c) => (
|
| 329 |
<CommentRow key={c.id} comment={c} />
|
frontend/src/types/api.ts
CHANGED
|
@@ -54,6 +54,8 @@ export type PredictionRecord = {
|
|
| 54 |
video_id?: string | null;
|
| 55 |
created_at: string;
|
| 56 |
labels?: string[];
|
|
|
|
|
|
|
| 57 |
};
|
| 58 |
|
| 59 |
export type PredictionsListResponse = {
|
|
|
|
| 54 |
video_id?: string | null;
|
| 55 |
created_at: string;
|
| 56 |
labels?: string[];
|
| 57 |
+
source?: string | null;
|
| 58 |
+
author?: string | null;
|
| 59 |
};
|
| 60 |
|
| 61 |
export type PredictionsListResponse = {
|