Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions frontend/app/components/videos/ChatMessage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
grid-template-columns: 52px minmax(0, 1fr);
column-gap: 8px;
align-items: baseline;
border-left: 2px solid transparent;
border-radius: 4px;
padding: 2px 4px 2px 2px;
}
.chatMessageNoTimestamp {
display: block;
Expand Down Expand Up @@ -43,6 +46,90 @@
line-height: 20px;
font-size: 14px;
}
.eventMessage {
background: color-mix(in srgb, var(--mantine-color-violet-6) 12%, transparent);
border-left-color: var(--mantine-color-violet-5);
}
.highlightedMessage {
background: color-mix(in srgb, var(--mantine-color-yellow-6) 14%, transparent);
border-left-color: var(--mantine-color-yellow-5);
}
.actionMessage .message {
font-style: italic;
}
.replyPreview {
align-items: center;
color: var(--mantine-color-dimmed);
display: flex;
font-size: 12px;
gap: 4px;
line-height: 16px;
margin-bottom: 1px;
min-width: 0;
}
.replyAuthor {
color: var(--mantine-color-gray-5);
flex: 0 1 auto;
font-size: 12px;
font-weight: 700;
line-height: 16px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.replyBody {
color: var(--mantine-color-dimmed);
flex: 1 1 auto;
font-size: 12px;
line-height: 16px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eventLabel,
.bitsLabel {
align-items: center;
border-radius: 4px;
display: inline-flex;
font-size: 11px;
font-weight: 700;
gap: 2px;
line-height: 16px;
margin-right: 4px;
padding: 0 4px;
vertical-align: middle;
}
.eventLabel {
background: color-mix(in srgb, var(--mantine-color-violet-5) 20%, transparent);
color: var(--mantine-color-violet-2);
}
.bitsLabel {
background: color-mix(in srgb, var(--mantine-color-yellow-5) 22%, transparent);
color: var(--mantine-color-yellow-2);
}
.eventBody {
display: block;
margin-top: 2px;
}
.eventSystemMessage {
background: color-mix(in srgb, var(--mantine-color-violet-5) 14%, transparent);
border-radius: 4px;
color: var(--mantine-color-violet-1);
display: inline-block;
font-size: 13px;
font-weight: 700;
line-height: 18px;
margin-right: 4px;
padding: 1px 5px;
}
.eventUserMessage {
color: var(--mantine-color-text);
display: inline;
font-size: 14px;
line-height: 20px;
}
.emoteImage {
padding-right: 2px;
vertical-align: middle;
Expand Down
143 changes: 101 additions & 42 deletions frontend/app/components/videos/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/* eslint-disable @next/next/no-img-element */
import { Comment, GanymedeFormattedBadge, GanymedeFormattedMessageFragment, GanymedeFormattedMessageType } from "@/app/hooks/useChat";
import { Comment, GanymedeChatMessageKind, GanymedeFormattedBadge, GanymedeFormattedMessageFragment, GanymedeFormattedMessageType } from "@/app/hooks/useChat";
import { durationToTime } from "@/app/util/util";
import classes from "./ChatMessage.module.css"
import { Text, Tooltip } from "@mantine/core"
import { useTranslations } from "next-intl";
import { IconBolt, IconMessageCircle, IconStar } from "@tabler/icons-react";

interface Params {
comment: Comment;
Expand All @@ -19,9 +20,70 @@ const ChatMessage = ({ comment, showTimestamp, timestampSeconds, onTimestampClic
const timestampActionLabel = hasTimestamp
? t("chatJumpToTimestamp", { timestamp: timestampLabel })
: "";
const messageKind = comment.ganymede_chat_message_kind ?? GanymedeChatMessageKind.Normal;
const isEvent = messageKind === GanymedeChatMessageKind.UserNotice;
const isHighlighted = messageKind === GanymedeChatMessageKind.Highlighted;
const isAction = messageKind === GanymedeChatMessageKind.Action;
const bitsSpent = comment.message.bits_spent ?? 0;
const eventSystemMessage = isEvent ? comment.message.user_notice_params?.system_msg?.trim() : "";
const eventUserMessageFromParams = isEvent ? comment.message.user_notice_params?.params?.["user-message"]?.trim() : "";
const eventUserMessageFromBody = isEvent && eventSystemMessage && comment.message.body.startsWith(eventSystemMessage)
? comment.message.body.slice(eventSystemMessage.length).trim()
: "";
const eventUserMessage = eventUserMessageFromParams || eventUserMessageFromBody;
const rowClassName = [
classes.chatMessage,
!showTimestamp ? classes.chatMessageNoTimestamp : "",
isEvent ? classes.eventMessage : "",
isHighlighted ? classes.highlightedMessage : "",
isAction ? classes.actionMessage : "",
].filter(Boolean).join(" ");

const renderFormattedMessage = () => (
comment.ganymede_formatted_message && comment.ganymede_formatted_message.map(
(fragment: GanymedeFormattedMessageFragment, index: number) => {
switch (fragment.type) {
case GanymedeFormattedMessageType.Text:
return (
<Text key={`${comment._id}-text-${index}`} className={classes.message} span>
{fragment.text}
</Text>
)
case GanymedeFormattedMessageType.Emote: {
const emoteName = fragment.emote?.name || fragment.text;
// some emotes include a height, use the provided height or hardcode a standard height if not included
if ((fragment.emote?.height ?? 0) !== 0 && (fragment.emote?.width ?? 0) !== 0) {
return (
<Tooltip key={`${comment._id}-emote-${index}`} label={emoteName} position="top">
<img
src={fragment.url}
className={classes.emoteImage}
height={fragment.emote?.height}
alt={emoteName}
/>
</Tooltip>
);
} else {
return (
<Tooltip key={`${comment._id}-emote-${index}`} label={emoteName} position="top">
<img
src={fragment.url}
className={classes.emoteImage}
alt={emoteName}
height={28}
/>
</Tooltip>
);
}
}

}
}
)
);

return (
<div key={comment._id} className={`${classes.chatMessage} ${!showTimestamp ? classes.chatMessageNoTimestamp : ""}`}>
<div key={comment._id} className={rowClassName}>
{showTimestamp && (
hasTimestamp ? (
<button
Expand All @@ -38,6 +100,23 @@ const ChatMessage = ({ comment, showTimestamp, timestampSeconds, onTimestampClic
)
)}
<span className={classes.content}>
{comment.message.reply && (
<span className={classes.replyPreview}>
<IconMessageCircle size={13} stroke={1.8} aria-hidden="true" />
<Text className={classes.replyAuthor} span>
{comment.message.reply.parent_display_name || comment.message.reply.parent_user_login}
</Text>
<Text className={classes.replyBody} span>
{comment.message.reply.parent_msg_body}
</Text>
</span>
)}
{isEvent && (
<span className={classes.eventLabel}>
<IconStar size={13} stroke={1.9} aria-hidden="true" />
{comment.ganymede_event_label || "Event"}
</span>
)}
{/* badges */}
<span>
{comment.ganymede_formatted_badges &&
Expand All @@ -56,6 +135,12 @@ const ChatMessage = ({ comment, showTimestamp, timestampSeconds, onTimestampClic
)
)}
</span>
{bitsSpent > 0 && (
<span className={classes.bitsLabel}>
<IconBolt size={12} stroke={2} aria-hidden="true" />
{bitsSpent.toLocaleString()} bits
</span>
)}
{/* username */}
<Text
fw={700}
Expand All @@ -67,48 +152,22 @@ const ChatMessage = ({ comment, showTimestamp, timestampSeconds, onTimestampClic
{comment.commenter.display_name}
</Text>
<Text className={classes.message} span>
:{" "}
{isAction ? " " : ": "}
</Text>
{/* message */}
{comment.ganymede_formatted_message && comment.ganymede_formatted_message.map(
(fragment: GanymedeFormattedMessageFragment, index: number) => {
switch (fragment.type) {
case GanymedeFormattedMessageType.Text:
return (
<Text key={`${comment._id}-text-${index}`} className={classes.message} span>
{fragment.text}
</Text>
)
case GanymedeFormattedMessageType.Emote: {
const emoteName = fragment.emote?.name || fragment.text;
// some emotes include a height, use the provided height or hardcode a standard height if not included
if ((fragment.emote?.height ?? 0) !== 0 && (fragment.emote?.width ?? 0) !== 0) {
return (
<Tooltip key={`${comment._id}-emote-${index}`} label={emoteName} position="top">
<img
src={fragment.url}
className={classes.emoteImage}
height={fragment.emote?.height}
alt={emoteName}
/>
</Tooltip>
);
} else {
return (
<Tooltip key={`${comment._id}-emote-${index}`} label={emoteName} position="top">
<img
src={fragment.url}
className={classes.emoteImage}
alt={emoteName}
height={28}
/>
</Tooltip>
);
}
}

}
}
{isEvent && eventSystemMessage ? (
<span className={classes.eventBody}>
<Text className={classes.eventSystemMessage} span>
{eventSystemMessage}
</Text>
{eventUserMessage && (
<Text className={classes.eventUserMessage} span>
{eventUserMessage}
</Text>
)}
</span>
) : (
renderFormattedMessage()
)}
</span>

Expand Down
44 changes: 43 additions & 1 deletion frontend/app/components/videos/ChatPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useGetEmotesForVideo,
useGetBadgesForVideo,
Comment,
GanymedeChatMessageKind,
GanymedeFormattedMessageFragment,
GanymedeFormattedMessageType,
getChatForVideo,
Expand All @@ -28,6 +29,17 @@ const TIME_SKIP_THRESHOLD = 2;
const IGNORED_BADGES = new Set(['no_audio', 'no_video', 'predictions']);
const SUBSCRIPTION_BADGES = new Set(['subscriber', 'sub-gifter', 'sub_gifter', 'bits']);
const SCROLL_THRESHOLD = 100; // px from bottom to trigger auto-scroll
const EVENT_LABELS: Record<string, string> = {
sub: "Sub",
resub: "Resub",
subgift: "Gift Sub",
anonsubgift: "Gift Sub",
submysterygift: "Gift Bomb",
raid: "Raid",
unraid: "Raid",
ritual: "Ritual",
announcement: "Announcement",
};
Comment on lines +32 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize event labels instead of storing English display text in classification.

EVENT_LABELS currently hardcodes English strings (Line 32), and classifyComment writes those directly into comment.ganymede_event_label (Line 296). This bypasses next-intl, so event chips stay English in localized UIs (same downstream effect where ChatMessage renders "Event" / "bits" literals). Prefer mapping msg_id -> translation key here and resolving text via t(...) at render time.

Suggested direction
-const EVENT_LABELS: Record<string, string> = {
-  sub: "Sub",
-  resub: "Resub",
+const EVENT_LABEL_KEYS: Record<string, string> = {
+  sub: "chat.event.sub",
+  resub: "chat.event.resub",
   ...
 };

- comment.ganymede_event_label = EVENT_LABELS[noticeID] ?? "Event";
+ comment.ganymede_event_label = EVENT_LABEL_KEYS[noticeID] ?? "chat.event.default";

Then in ChatMessage.tsx, render with t(comment.ganymede_event_label) and localize bits label via translations too.

Also applies to: 296-297

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/components/videos/ChatPlayer.tsx` around lines 32 - 42, The
EVENT_LABELS object hardcodes English display text instead of translation keys,
causing event labels to remain in English even in localized UIs. Change
EVENT_LABELS to map event types (sub, resub, subgift, etc.) to translation key
strings instead of English display text. Update the classifyComment function
(around line 296-297 where it assigns from EVENT_LABELS to
comment.ganymede_event_label) to store the translation key directly. Then in
ChatMessage.tsx, render the event label using t(comment.ganymede_event_label) to
resolve the localized text at render time, ensuring event chips display in the
user's language.


interface ChatMaps {
emoteMap: Map<string, Emote>;
Expand Down Expand Up @@ -275,6 +287,35 @@ const ChatPlayer = ({ video, playerRef }: Params) => {
}
}, [handleError]);

const classifyComment = useCallback((comment: Comment) => {
const msgID = comment.message.user_notice_params?.msg_id;
const noticeID = typeof msgID === "string" ? msgID : "";

if (noticeID && noticeID !== "highlighted-message") {
comment.ganymede_chat_message_kind = GanymedeChatMessageKind.UserNotice;
comment.ganymede_event_label = EVENT_LABELS[noticeID] ?? "Event";
return comment;
}

if (noticeID === "highlighted-message") {
comment.ganymede_chat_message_kind = GanymedeChatMessageKind.Highlighted;
return comment;
}

if (comment.message.is_action) {
comment.ganymede_chat_message_kind = GanymedeChatMessageKind.Action;
return comment;
}

if ((comment.message.bits_spent ?? 0) > 0) {
comment.ganymede_chat_message_kind = GanymedeChatMessageKind.Bits;
return comment;
}

comment.ganymede_chat_message_kind = GanymedeChatMessageKind.Normal;
return comment;
}, []);

const enqueueComments = useCallback((comments?: Comment[]) => {
if (!comments?.length) return;

Expand Down Expand Up @@ -398,6 +439,7 @@ const ChatPlayer = ({ video, playerRef }: Params) => {
// Process the message (e.g. add badges and emotes)
const processedComment = addBadgesToFormattedComment(comment);
processedComment.ganymede_formatted_message = addEmotesToFormattedComment(processedComment);
classifyComment(processedComment);

// Add to batch and mark as processed
newMessagesToAdd.push(processedComment);
Expand All @@ -415,7 +457,7 @@ const ChatPlayer = ({ video, playerRef }: Params) => {
} catch (error) {
handleError(error as Error, "Chat processing");
}
}, [addBadgesToFormattedComment, addEmotesToFormattedComment, addProcessedId, handleError, setMessagesWithScroll]);
}, [addBadgesToFormattedComment, addEmotesToFormattedComment, addProcessedId, classifyComment, handleError, setMessagesWithScroll]);

// Initialize chat data
useEffect(() => {
Expand Down
Loading
Loading