feat: add support for custom emojis and membership badges in chat display

This commit is contained in:
Yusuf İpek
2025-10-09 01:05:13 +03:00
parent 5a33b01a11
commit fce6ca5708
5 changed files with 188 additions and 35 deletions
+67 -16
View File
@@ -22,6 +22,25 @@ export type ChatEventEmitter = EventEmitter<{
const defaultTimeout = 1500; 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<IngestionContext> { export async function bootstrapInnertube(videoId: string): Promise<IngestionContext> {
if (!videoId) { if (!videoId) {
throw new Error('YOUTUBE_LIVE_ID is required to bootstrap Innertube'); 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)) { 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 ''; return '';
@@ -141,15 +171,16 @@ function extractBadges(item: any): Badge[] {
for (const badge of item.author.badges) { for (const badge of item.author.badges) {
const label = badge.tooltip ?? badge.label ?? ''; const label = badge.tooltip ?? badge.label ?? '';
const imageUrl = badge.custom_thumbnail?.[0]?.url;
if (label.toLowerCase().includes('moderator')) { if (label.toLowerCase().includes('moderator')) {
badges.push({ type: 'moderator', label }); badges.push({ type: 'moderator', label, imageUrl });
} else if (label.toLowerCase().includes('member')) { } else if (label.toLowerCase().includes('member')) {
badges.push({ type: 'member', label }); badges.push({ type: 'member', label, imageUrl });
} else if (label.toLowerCase().includes('verified')) { } else if (label.toLowerCase().includes('verified')) {
badges.push({ type: 'verified', label }); badges.push({ type: 'verified', label, imageUrl });
} else if (label) { } 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) { for (const badge of header.author_badges) {
const label = badge.tooltip ?? ''; const label = badge.tooltip ?? '';
const iconType = badge.icon_type ?? ''; const iconType = badge.icon_type ?? '';
const imageUrl = badge.custom_thumbnail?.[0]?.url;
if (label.toLowerCase().includes('moderator') || iconType === 'MODERATOR') { if (label.toLowerCase().includes('moderator') || iconType === 'MODERATOR') {
badges.push({ type: 'moderator', label }); badges.push({ type: 'moderator', label, imageUrl });
} else if (label.toLowerCase().includes('member')) { } 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') { } else if (label.toLowerCase().includes('verified') || iconType === 'VERIFIED') {
badges.push({ type: 'verified', label }); badges.push({ type: 'verified', label, imageUrl });
} else if (iconType === 'OWNER') { } else if (iconType === 'OWNER') {
badges.push({ type: 'custom', label: label || 'Owner' }); badges.push({ type: 'custom', label: label || 'Owner', imageUrl });
} else if (label) { } 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 isMember = badges.some(b => b.type === 'member');
const isVerified = badges.some(b => b.type === 'verified'); 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; let membershipLevel: string | undefined;
if (isMembership) { if (isMembership) {
// For membership items, header_primary_text contains milestone info like "Member for 9 months" const subtext: string = item.header_subtext?.text || '';
membershipLevel = item.header_primary_text?.text || const primaryText: string = item.header_primary_text?.text || '';
item.header_primary_text?.toString() ||
'New member'; // 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) { } else if (isGiftPurchase) {
// For gift purchases, we don't need membership level // For gift purchases, we don't need membership level
membershipLevel = undefined; membershipLevel = undefined;
@@ -339,12 +383,19 @@ function normalizeAction(action: any): ChatMessage | null {
? item.header?.author_photo?.[0]?.url ? item.header?.author_photo?.[0]?.url
: item.author?.thumbnails?.[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 { return {
id: String(item.id ?? item.timestamp_usec ?? Date.now()), id: String(item.id ?? item.timestamp_usec ?? Date.now()),
author: authorName, author: authorName,
authorPhoto, authorPhoto,
authorChannelId: authorChannelId ? String(authorChannelId) : undefined, 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), publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec),
badges: badges.length > 0 ? badges : undefined, badges: badges.length > 0 ? badges : undefined,
isModerator, isModerator,
+39 -11
View File
@@ -414,10 +414,14 @@ function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps)
useEffect(() => { useEffect(() => {
const node = scrollRef.current; const node = scrollRef.current;
if (node && wasAtBottomRef.current) { if (node && wasAtBottomRef.current) {
// Use requestAnimationFrame to ensure DOM has updated // Use setTimeout with requestAnimationFrame for more reliable scrolling
requestAnimationFrame(() => { setTimeout(() => {
node.scrollTop = node.scrollHeight; requestAnimationFrame(() => {
}); if (node && wasAtBottomRef.current) {
node.scrollTop = node.scrollHeight;
}
});
}, 0);
} }
}, [messages]); }, [messages]);
@@ -445,11 +449,15 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) {
)} )}
<span className="chatItem__author">{message.author}</span> <span className="chatItem__author">{message.author}</span>
{message.badges && message.badges.map((badge, i) => ( {message.badges && message.badges.map((badge, i) => (
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}> badge.imageUrl ? (
{badge.type === 'moderator' && '🛡️'} <img key={i} src={badge.imageUrl} alt={badge.label} className="badge badge--image" title={badge.label} />
{badge.type === 'member' && '⭐'} ) : (
{badge.type === 'verified' && '✓'} <span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
</span> {badge.type === 'moderator' && '🛡️'}
{badge.type === 'member' && '⭐'}
{badge.type === 'verified' && '✓'}
</span>
)
))} ))}
{message.superChat && ( {message.superChat && (
<span className="chatItem__superchat-inline" style={{ backgroundColor: message.superChat.color }}> <span className="chatItem__superchat-inline" style={{ backgroundColor: message.superChat.color }}>
@@ -460,7 +468,17 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) {
<time className="chatItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time> <time className="chatItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time>
</div> </div>
</div> </div>
<p className="chatItem__text">{message.text}</p> {message.runs?.length ? (
<p className="chatItem__text">
{message.runs.map((r, i) =>
r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="chatItem__emoji" />
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
) : message.text && <p className="chatItem__text">{message.text}</p>}
</button> </button>
); );
} }
@@ -485,7 +503,17 @@ function MemberItem({ message, isSelected, onSelect }: ChatItemProps) {
<time className="memberItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time> <time className="memberItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time>
</div> </div>
</div> </div>
{message.text && <p className="memberItem__text">{message.text}</p>} {message.runs?.length ? (
<p className="memberItem__text">
{message.runs.map((r, i) =>
r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="memberItem__emoji" />
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
) : message.text && <p className="memberItem__text">{message.text}</p>}
</button> </button>
); );
} }
+32
View File
@@ -729,11 +729,19 @@ main {
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
border-radius: 4px; border-radius: 4px;
font-weight: 600;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
} }
.badge--image {
width: 16px;
height: 16px;
padding: 0;
border-radius: 2px;
}
.badge--moderator { .badge--moderator {
background: rgba(239, 68, 68, 0.2); background: rgba(239, 68, 68, 0.2);
color: #fca5a5; color: #fca5a5;
@@ -964,6 +972,30 @@ main {
font-size: 0.9rem; 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__superchat,
.overlay__membership { .overlay__membership {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
+42 -8
View File
@@ -79,7 +79,17 @@ export default function OverlayPage() {
{message.superChat.currency}{message.superChat.currency ? ' ' : ''}{message.superChat.amount} {message.superChat.currency}{message.superChat.currency ? ' ' : ''}{message.superChat.amount}
</span> </span>
</div> </div>
{message.text && ( {message.runs?.length ? (
<p className="overlay__superchat-text">
{message.runs.map((r, i) =>
r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
) : message.text && (
<p className="overlay__superchat-text">{message.text}</p> <p className="overlay__superchat-text">{message.text}</p>
)} )}
</> </>
@@ -99,7 +109,17 @@ export default function OverlayPage() {
</span> </span>
</div> </div>
</div> </div>
{message.text && ( {message.runs?.length ? (
<p className="overlay__membership-text">
{message.runs.map((r, i) =>
r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
) : message.text && (
<p className="overlay__membership-text">{message.text}</p> <p className="overlay__membership-text">{message.text}</p>
)} )}
</> </>
@@ -113,16 +133,30 @@ export default function OverlayPage() {
<div className="overlay__authorLine"> <div className="overlay__authorLine">
<span className="overlay__author">{message.author}</span> <span className="overlay__author">{message.author}</span>
{message.badges && message.badges.map((badge, i) => ( {message.badges && message.badges.map((badge, i) => (
<span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}> badge.imageUrl ? (
{badge.type === 'moderator' && '🛡️'} <img key={i} src={badge.imageUrl} alt={badge.label} className="overlay__badge overlay__badge--image" title={badge.label} />
{badge.type === 'member' && '⭐'} ) : (
{badge.type === 'verified' && '✓'} <span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}>
</span> {badge.type === 'moderator' && '🛡️'}
{badge.type === 'member' && '⭐'}
{badge.type === 'verified' && '✓'}
</span>
)
))} ))}
</div> </div>
</div> </div>
</div> </div>
{message.text && ( {message.runs?.length ? (
<p className="overlay__text">
{message.runs.map((r, i) =>
r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
) : message.text && (
<p className="overlay__text">{message.text}</p> <p className="overlay__text">{message.text}</p>
)} )}
</> </>
+8
View File
@@ -2,6 +2,7 @@ export type Badge = {
type: 'moderator' | 'member' | 'verified' | 'custom'; type: 'moderator' | 'member' | 'verified' | 'custom';
label?: string; label?: string;
icon?: string; icon?: string;
imageUrl?: string; // For custom membership badges
}; };
export type SuperChatInfo = { export type SuperChatInfo = {
@@ -10,12 +11,19 @@ export type SuperChatInfo = {
color: string; color: string;
}; };
export type MessageRun = {
text?: string;
emojiUrl?: string;
emojiAlt?: string;
};
export type ChatMessage = { export type ChatMessage = {
id: string; id: string;
author: string; author: string;
authorPhoto?: string; authorPhoto?: string;
authorChannelId?: string; authorChannelId?: string;
text: string; text: string;
runs?: MessageRun[]; // structured runs for emojis
publishedAt: string; publishedAt: string;
badges?: Badge[]; badges?: Badge[];
isModerator?: boolean; isModerator?: boolean;