mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
feat: add support for custom emojis and membership badges in chat display
This commit is contained in:
@@ -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<IngestionContext> {
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
)}
|
||||
<span className="chatItem__author">{message.author}</span>
|
||||
{message.badges && message.badges.map((badge, i) => (
|
||||
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
|
||||
{badge.type === 'moderator' && '🛡️'}
|
||||
{badge.type === 'member' && '⭐'}
|
||||
{badge.type === 'verified' && '✓'}
|
||||
</span>
|
||||
badge.imageUrl ? (
|
||||
<img key={i} src={badge.imageUrl} alt={badge.label} className="badge badge--image" title={badge.label} />
|
||||
) : (
|
||||
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
|
||||
{badge.type === 'moderator' && '🛡️'}
|
||||
{badge.type === 'member' && '⭐'}
|
||||
{badge.type === 'verified' && '✓'}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
{message.superChat && (
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -485,7 +503,17 @@ function MemberItem({ message, isSelected, onSelect }: ChatItemProps) {
|
||||
<time className="memberItem__time">{new Date(message.publishedAt).toLocaleTimeString()}</time>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,7 +79,17 @@ export default function OverlayPage() {
|
||||
{message.superChat.currency}{message.superChat.currency ? ' ' : ''}{message.superChat.amount}
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
@@ -99,7 +109,17 @@ export default function OverlayPage() {
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
@@ -113,16 +133,30 @@ export default function OverlayPage() {
|
||||
<div className="overlay__authorLine">
|
||||
<span className="overlay__author">{message.author}</span>
|
||||
{message.badges && message.badges.map((badge, i) => (
|
||||
<span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}>
|
||||
{badge.type === 'moderator' && '🛡️'}
|
||||
{badge.type === 'member' && '⭐'}
|
||||
{badge.type === 'verified' && '✓'}
|
||||
</span>
|
||||
badge.imageUrl ? (
|
||||
<img key={i} src={badge.imageUrl} alt={badge.label} className="overlay__badge overlay__badge--image" title={badge.label} />
|
||||
) : (
|
||||
<span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}>
|
||||
{badge.type === 'moderator' && '🛡️'}
|
||||
{badge.type === 'member' && '⭐'}
|
||||
{badge.type === 'verified' && '✓'}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user