diff --git a/backend/src/ingestion/youtubei.ts b/backend/src/ingestion/youtubei.ts index aa80054..b45a368 100644 --- a/backend/src/ingestion/youtubei.ts +++ b/backend/src/ingestion/youtubei.ts @@ -22,6 +22,25 @@ export type ChatEventEmitter = EventEmitter<{ const defaultTimeout = 1500; +function resolveMessageRuns(item: any) { + const runs: { text?: string; emojiUrl?: string; emojiAlt?: string }[] = []; + const rawRuns = item?.message?.runs; + if (Array.isArray(rawRuns)) { + for (const run of rawRuns) { + if (run?.emoji) { + const url = run.emoji?.image?.[0]?.url; + runs.push({ + emojiUrl: url, + emojiAlt: run.emoji?.shortcuts?.[0] || run.emoji?.emoji_id || '' + }); + } else if (run?.text) { + runs.push({ text: String(run.text) }); + } + } + } + return runs; +} + export async function bootstrapInnertube(videoId: string): Promise { if (!videoId) { throw new Error('YOUTUBE_LIVE_ID is required to bootstrap Innertube'); @@ -114,7 +133,18 @@ function resolveMessageText(item: any): string { } if (Array.isArray(item.message?.runs)) { - return item.message.runs.map((run: any) => run.text ?? '').join(''); + return item.message.runs.map((run: any) => { + // Handle emoji objects - for custom emojis, use the shortcut text + if (run.emoji) { + // For custom emojis, use the first shortcut (e.g., ":_heçkır:") + if (run.emoji.is_custom && run.emoji.shortcuts?.[0]) { + return run.emoji.shortcuts[0]; + } + // For standard emojis, use emoji_id (e.g., "❤") + return run.emoji.emoji_id || run.text || ''; + } + return run.text ?? ''; + }).join(''); } return ''; @@ -141,15 +171,16 @@ function extractBadges(item: any): Badge[] { for (const badge of item.author.badges) { const label = badge.tooltip ?? badge.label ?? ''; + const imageUrl = badge.custom_thumbnail?.[0]?.url; if (label.toLowerCase().includes('moderator')) { - badges.push({ type: 'moderator', label }); + badges.push({ type: 'moderator', label, imageUrl }); } else if (label.toLowerCase().includes('member')) { - badges.push({ type: 'member', label }); + badges.push({ type: 'member', label, imageUrl }); } else if (label.toLowerCase().includes('verified')) { - badges.push({ type: 'verified', label }); + badges.push({ type: 'verified', label, imageUrl }); } else if (label) { - badges.push({ type: 'custom', label }); + badges.push({ type: 'custom', label, imageUrl }); } } @@ -164,17 +195,18 @@ function extractBadgesFromHeader(header: any): Badge[] { for (const badge of header.author_badges) { const label = badge.tooltip ?? ''; const iconType = badge.icon_type ?? ''; + const imageUrl = badge.custom_thumbnail?.[0]?.url; if (label.toLowerCase().includes('moderator') || iconType === 'MODERATOR') { - badges.push({ type: 'moderator', label }); + badges.push({ type: 'moderator', label, imageUrl }); } else if (label.toLowerCase().includes('member')) { - badges.push({ type: 'member', label }); + badges.push({ type: 'member', label, imageUrl }); } else if (label.toLowerCase().includes('verified') || iconType === 'VERIFIED') { - badges.push({ type: 'verified', label }); + badges.push({ type: 'verified', label, imageUrl }); } else if (iconType === 'OWNER') { - badges.push({ type: 'custom', label: label || 'Owner' }); + badges.push({ type: 'custom', label: label || 'Owner', imageUrl }); } else if (label) { - badges.push({ type: 'custom', label }); + badges.push({ type: 'custom', label, imageUrl }); } } @@ -300,13 +332,25 @@ function normalizeAction(action: any): ChatMessage | null { const isMember = badges.some(b => b.type === 'member'); const isVerified = badges.some(b => b.type === 'verified'); - // Extract membership level for new members and milestones + // Extract membership level for new members, upgrades, and milestones let membershipLevel: string | undefined; if (isMembership) { - // For membership items, header_primary_text contains milestone info like "Member for 9 months" - membershipLevel = item.header_primary_text?.text || - item.header_primary_text?.toString() || - 'New member'; + const subtext: string = item.header_subtext?.text || ''; + const primaryText: string = item.header_primary_text?.text || ''; + + // Try to capture level name from common phrases + // e.g., "Welcome to User!" => "User" + // e.g., "Upgraded membership to Superuser!" => "Superuser" + let levelFromSub: string | undefined; + const welcomeMatch = subtext.match(/Welcome to\s+(.+?)!/i); + const upgradeMatch = subtext.match(/Upgraded membership to\s+(.+?)!/i); + if (welcomeMatch?.[1]) { + levelFromSub = welcomeMatch[1].trim(); + } else if (upgradeMatch?.[1]) { + levelFromSub = upgradeMatch[1].trim(); + } + + membershipLevel = levelFromSub || primaryText || 'New member'; } else if (isGiftPurchase) { // For gift purchases, we don't need membership level membershipLevel = undefined; @@ -339,12 +383,19 @@ function normalizeAction(action: any): ChatMessage | null { ? item.header?.author_photo?.[0]?.url : item.author?.thumbnails?.[0]?.url; + // Build text with fallback: if no user message, show header subtext/primary for membership events + const resolvedText = resolveMessageText(item); + const membershipFallbackText = isMembership + ? (item.header_subtext?.text || item.header_primary_text?.text || '') + : ''; + return { id: String(item.id ?? item.timestamp_usec ?? Date.now()), author: authorName, authorPhoto, authorChannelId: authorChannelId ? String(authorChannelId) : undefined, - text: resolveMessageText(item), + text: resolvedText || membershipFallbackText, + runs: (() => { const r = resolveMessageRuns(item); return r.length ? r : undefined; })(), publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec), badges: badges.length > 0 ? badges : undefined, isModerator, diff --git a/client/app/dashboard/page.tsx b/client/app/dashboard/page.tsx index 44eecbe..0c728e4 100644 --- a/client/app/dashboard/page.tsx +++ b/client/app/dashboard/page.tsx @@ -414,10 +414,14 @@ function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps) useEffect(() => { const node = scrollRef.current; if (node && wasAtBottomRef.current) { - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - node.scrollTop = node.scrollHeight; - }); + // Use setTimeout with requestAnimationFrame for more reliable scrolling + setTimeout(() => { + requestAnimationFrame(() => { + if (node && wasAtBottomRef.current) { + node.scrollTop = node.scrollHeight; + } + }); + }, 0); } }, [messages]); @@ -445,11 +449,15 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) { )} {message.author} {message.badges && message.badges.map((badge, i) => ( - - {badge.type === 'moderator' && '🛡️'} - {badge.type === 'member' && '⭐'} - {badge.type === 'verified' && '✓'} - + badge.imageUrl ? ( + {badge.label} + ) : ( + + {badge.type === 'moderator' && '🛡️'} + {badge.type === 'member' && '⭐'} + {badge.type === 'verified' && '✓'} + + ) ))} {message.superChat && ( @@ -460,7 +468,17 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) { -

{message.text}

+ {message.runs?.length ? ( +

+ {message.runs.map((r, i) => + r.emojiUrl ? ( + {r.emojiAlt + ) : ( + {r.text} + ) + )} +

+ ) : message.text &&

{message.text}

} ); } @@ -485,7 +503,17 @@ function MemberItem({ message, isSelected, onSelect }: ChatItemProps) { - {message.text &&

{message.text}

} + {message.runs?.length ? ( +

+ {message.runs.map((r, i) => + r.emojiUrl ? ( + {r.emojiAlt + ) : ( + {r.text} + ) + )} +

+ ) : message.text &&

{message.text}

} ); } diff --git a/client/app/globals.css b/client/app/globals.css index 4de4bf8..2ee0f0b 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -729,11 +729,19 @@ main { font-size: 0.85rem; padding: 0.15rem 0.4rem; border-radius: 4px; + font-weight: 600; display: inline-flex; align-items: center; gap: 0.25rem; } +.badge--image { + width: 16px; + height: 16px; + padding: 0; + border-radius: 2px; +} + .badge--moderator { background: rgba(239, 68, 68, 0.2); color: #fca5a5; @@ -964,6 +972,30 @@ main { font-size: 0.9rem; } +.overlay__badge--image { + width: 20px; + height: 20px; + border-radius: 2px; +} + +/* Inline emoji images */ +.overlay__emoji { + width: 1.2em; + height: 1.2em; + vertical-align: -0.2em; + margin: 0 0.08em; + display: inline-block; +} + +.chatItem__emoji, +.memberItem__emoji { + width: 1.1em; + height: 1.1em; + vertical-align: -0.2em; + margin: 0 0.06em; + display: inline-block; +} + .overlay__superchat, .overlay__membership { padding: 0.6rem 1rem; diff --git a/client/app/overlay/page.tsx b/client/app/overlay/page.tsx index 0854ba4..9db5c1c 100644 --- a/client/app/overlay/page.tsx +++ b/client/app/overlay/page.tsx @@ -79,7 +79,17 @@ export default function OverlayPage() { {message.superChat.currency}{message.superChat.currency ? ' ' : ''}{message.superChat.amount}
- {message.text && ( + {message.runs?.length ? ( +

+ {message.runs.map((r, i) => + r.emojiUrl ? ( + {r.emojiAlt + ) : ( + {r.text} + ) + )} +

+ ) : message.text && (

{message.text}

)} @@ -99,7 +109,17 @@ export default function OverlayPage() { - {message.text && ( + {message.runs?.length ? ( +

+ {message.runs.map((r, i) => + r.emojiUrl ? ( + {r.emojiAlt + ) : ( + {r.text} + ) + )} +

+ ) : message.text && (

{message.text}

)} @@ -113,16 +133,30 @@ export default function OverlayPage() {
{message.author} {message.badges && message.badges.map((badge, i) => ( - - {badge.type === 'moderator' && '🛡️'} - {badge.type === 'member' && '⭐'} - {badge.type === 'verified' && '✓'} - + badge.imageUrl ? ( + {badge.label} + ) : ( + + {badge.type === 'moderator' && '🛡️'} + {badge.type === 'member' && '⭐'} + {badge.type === 'verified' && '✓'} + + ) ))}
- {message.text && ( + {message.runs?.length ? ( +

+ {message.runs.map((r, i) => + r.emojiUrl ? ( + {r.emojiAlt + ) : ( + {r.text} + ) + )} +

+ ) : message.text && (

{message.text}

)} diff --git a/shared/chat.ts b/shared/chat.ts index 597e692..519033a 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -2,6 +2,7 @@ export type Badge = { type: 'moderator' | 'member' | 'verified' | 'custom'; label?: string; icon?: string; + imageUrl?: string; // For custom membership badges }; export type SuperChatInfo = { @@ -10,12 +11,19 @@ export type SuperChatInfo = { color: string; }; +export type MessageRun = { + text?: string; + emojiUrl?: string; + emojiAlt?: string; +}; + export type ChatMessage = { id: string; author: string; authorPhoto?: string; authorChannelId?: string; text: string; + runs?: MessageRun[]; // structured runs for emojis publishedAt: string; badges?: Badge[]; isModerator?: boolean;