SignalMod / frontend /src /pages /WatchPage.tsx
Ruperth's picture
feat: pin team-posted comments to the top of the watch page
73c3af5
Raw
History Blame Contribute Delete
12.1 kB
import { useCallback, useEffect, useState } from "react";
import {
getSuggestedVideos,
listPredictions,
predict,
predictVideo,
} from "../api/client";
import { CommentRow } from "../components/CommentRow";
import { SuggestedRail } from "../components/SuggestedRail";
import { useApp } from "../context/AppContext";
import { useDebouncedPredict } from "../hooks/useDebouncedPredict";
import { useI18n } from "../i18n/I18nContext";
import type {
CommentItem,
PredictionRecord,
SuggestedVideo,
} from "../types/api";
import {
formatPct,
newId,
randomTeamMember,
randomUsername,
relativeTime,
toxicityColor,
truncate,
} from "../utils/toxicity";
const DEFAULT_EMBED_VIDEO_ID = "A1uxPRUgimk";
function isPlaceholderTitle(title: string, id: string): boolean {
return title === `Video ${id}`;
}
export function WatchPage() {
const { t } = useI18n();
const { threshold, addHubEntry } = useApp();
const [draft, setDraft] = useState("");
const [sessionComments, setSessionComments] = useState<CommentItem[]>([]);
const [suggested, setSuggested] = useState<SuggestedVideo[]>([]);
const [maxComments, setMaxComments] = useState(15);
const [activeVideo, setActiveVideo] = useState<SuggestedVideo | null>(null);
const [youtubeComments, setYoutubeComments] = useState<CommentItem[]>([]);
const [loadingVideoId, setLoadingVideoId] = useState<string | null>(null);
const [fetchError, setFetchError] = useState<string | null>(null);
const [demoBanner, setDemoBanner] = useState(false);
const [dismissDemoBanner, setDismissDemoBanner] = useState(false);
const [posting, setPosting] = useState(false);
const [recentActivity, setRecentActivity] = useState<PredictionRecord[]>([]);
const [recentLoading, setRecentLoading] = useState(false);
const { result, loading, error } = useDebouncedPredict(draft, threshold);
const refreshRecent = useCallback(async (videoId?: string) => {
if (!videoId) {
setRecentActivity([]);
return;
}
setRecentLoading(true);
try {
const res = await listPredictions(videoId, 200, "user_comment");
setRecentActivity(Array.isArray(res?.predictions) ? res.predictions : []);
} catch {
// Degrade gracefully if endpoint is missing or DB not configured
setRecentActivity([]);
} finally {
setRecentLoading(false);
}
}, []);
useEffect(() => {
void refreshRecent(activeVideo?.id);
}, [activeVideo?.id, refreshRecent]);
useEffect(() => {
getSuggestedVideos()
.then((r) => {
setSuggested(r.videos);
setMaxComments(r.max_comments);
// Auto-load first video so the user sees comments on initial render.
if (r.videos.length > 0) {
void loadVideo(r.videos[0]);
}
})
.catch(() => setFetchError(t.watch.couldNotLoadVideos));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handlePost = useCallback(async () => {
const text = draft.trim();
if (!text || posting) return;
setPosting(true);
try {
const author = randomTeamMember();
const analysis = await predict(text, threshold, {
videoId: activeVideo?.id,
author,
persist: true,
});
const item: CommentItem = {
id: newId(),
user: author,
text,
time: t.watch.justNow,
is_toxic: analysis.is_toxic,
probability: analysis.probability,
labels: analysis.labels,
source: "manual",
};
setSessionComments((prev) => [...prev, item]);
addHubEntry({
user: `@${author}`,
snippet: text.slice(0, 45),
score: analysis.probability,
action: analysis.is_toxic
? `${t.watch.posted} (${t.badges.toxic.toLowerCase()})`
: t.badges.safe,
});
setDraft("");
void refreshRecent(activeVideo?.id);
} finally {
setPosting(false);
}
}, [draft, posting, threshold, addHubEntry, refreshRecent, activeVideo?.id, t]);
const loadVideo = async (video: SuggestedVideo) => {
setActiveVideo(video);
setYoutubeComments([]);
setSessionComments([]);
setFetchError(null);
setDismissDemoBanner(false);
setLoadingVideoId(video.id);
try {
const res = await predictVideo(video.watch_url, maxComments, threshold);
setDemoBanner(res.source === "demo");
setYoutubeComments(
res.results.map((r, i) => ({
id: `yt-${video.id}-${i}`,
user: randomUsername(`yt-${video.id}-${i}`),
text: r.text,
time: t.watch.fromYoutube,
is_toxic: r.is_toxic,
probability: r.probability,
labels: r.labels,
source: "youtube" as const,
}))
);
} catch (e) {
setFetchError(e instanceof Error ? e.message : t.watch.failedToLoadComments);
setYoutubeComments([]);
setDemoBanner(false);
} finally {
setLoadingVideoId(null);
}
};
const toxicManual = sessionComments.filter((c) => c.is_toxic).length;
const toxicYt = youtubeComments.filter((c) => c.is_toxic).length;
const totalComments = sessionComments.length + youtubeComments.length;
const channelInitial = activeVideo?.channel_title?.charAt(0).toUpperCase() ?? "Y";
return (
<div className="watch-page">
<div className="watch-grid">
<section className="primary-column">
<div className="staged-player">
{activeVideo && !activeVideo.embeddable ? (
<a
className="player-fallback"
href={activeVideo.watch_url}
target="_blank"
rel="noopener noreferrer"
>
<img
src={activeVideo.thumbnail_url}
alt=""
className="player-fallback-thumb"
/>
<span className="player-fallback-cta">{t.watch.watchOnYoutube}</span>
</a>
) : (
<iframe
className="player-iframe"
src={`https://www.youtube.com/embed/${
activeVideo?.id ?? DEFAULT_EMBED_VIDEO_ID
}?rel=0`}
title={activeVideo?.title ?? "YouTube video player"}
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
loading="lazy"
/>
)}
</div>
<h1 className="video-title">
{activeVideo?.title ?? t.watch.defaultTitle}
</h1>
<p className="video-meta">
{activeVideo
? activeVideo.channel_title
: t.watch.defaultMeta}
</p>
{activeVideo && isPlaceholderTitle(activeVideo.title, activeVideo.id) && (
<p className="info-banner">{t.watch.placeholderTitleBanner}</p>
)}
<div className="channel-row">
<div className="channel-avatar">{channelInitial}</div>
<div>
<p className="channel-name">{activeVideo?.channel_title ?? t.watch.channelFallback}</p>
{activeVideo && <p className="video-meta">{t.watch.suggestedVideo}</p>}
</div>
</div>
<div className="comments-header">
<span>
{t.watch.commentsCount(totalComments)}
{toxicManual + toxicYt > 0 && (
<span className="toxic-count">{t.watch.toxicDetected(toxicManual + toxicYt)}</span>
)}
</span>
</div>
<div className="comment-compose">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handlePost();
}
}}
placeholder={t.watch.composePlaceholder}
rows={3}
aria-label={t.watch.composeAriaLabel}
/>
{draft.trim() && (
<div className="live-analysis">
<span>{loading ? t.watch.analyzing : t.watch.liveScore}</span>
{result && (
<>
<span className={`badge ${result.is_toxic ? "badge-toxic" : "badge-safe"}`}>
{result.is_toxic ? t.badges.toxic : t.badges.safe}
</span>
<span style={{ color: toxicityColor(result.probability) }}>
{`${t.watch.toxicity}: ${formatPct(result.probability)}`}
</span>
</>
)}
{error && <span className="error-text">{error}</span>}
</div>
)}
<div className="compose-actions">
<button
type="button"
className="btn-secondary"
onClick={() => setDraft("")}
disabled={posting}
>
{t.watch.cancel}
</button>
<button
type="button"
className="btn-primary"
onClick={() => void handlePost()}
disabled={posting || !draft.trim()}
>
{posting ? t.watch.analyzing : t.watch.comment}
</button>
</div>
</div>
{fetchError && <p className="error-banner">{fetchError}</p>}
{demoBanner && !dismissDemoBanner && (
<div className="info-banner dismissible">
<span>{t.watch.demoBanner}</span>
<button
type="button"
className="btn-dismiss"
onClick={() => setDismissDemoBanner(true)}
aria-label={t.watch.dismiss}
>
×
</button>
</div>
)}
{loadingVideoId && youtubeComments.length === 0 && (
<p className="loading-comments">{t.watch.loadingComments}</p>
)}
<div className="comment-list">
{/* Persisted user comments for this video — always at the top (newest first) */}
{recentActivity
.filter((rec) => rec.source === "user_comment")
.map((rec, idx) => (
<CommentRow
key={`recent-${rec.id ?? idx}`}
comment={{
id: `recent-${rec.id ?? idx}`,
user: rec.author ?? randomUsername(`supa-${rec.id ?? idx}`),
text: truncate(rec.text, 140),
time: relativeTime(rec.created_at),
is_toxic: rec.is_toxic,
probability: rec.probability,
labels: rec.labels ?? [],
source: "recent",
}}
/>
))}
{/* Optimistic local additions until refresh from Supabase completes */}
{[...sessionComments]
.reverse()
.filter(
(c) =>
!recentActivity.some(
(r) => r.text === c.text && r.author === c.user,
),
)
.map((c) => (
<CommentRow key={c.id} comment={c} />
))}
{/* YouTube fetched comments — below */}
{youtubeComments.map((c) => (
<CommentRow key={c.id} comment={c} />
))}
</div>
{recentLoading && recentActivity.length === 0 && youtubeComments.length === 0 && (
<p className="loading-comments">{t.watch.loadingRecent}</p>
)}
</section>
<SuggestedRail
videos={suggested}
activeId={activeVideo?.id ?? null}
loadingId={loadingVideoId}
onSelect={(v) => void loadVideo(v)}
/>
</div>
</div>
);
}