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;
|
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user