Skip to content
Merged
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
2 changes: 2 additions & 0 deletions WEBUI/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ def N_(text):
JS_MESSAGES = (
N_("Points"),
N_("Redeemed “%(title)s”"),
N_("Replying to @%(user)s: %(msg)s"),
N_("First message"),
N_("Page %(page)s / %(pages)s (%(count)s VODs)"),
N_("Page %(page)s of %(pages)s"),
N_("%(count)s VODs in quick access"),
Expand Down
161 changes: 155 additions & 6 deletions WEBUI/static/chat_emotes.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,44 @@
return promise;
}

function renderTextSegment(text) {
// Twitch cheer tiers: 1 grey, 100 purple, 1000 teal, 5000 blue, 10000 red.
const cheerTokenPattern = /^[A-Za-z]+\d+$/;
const bitsIconPath = '<path d="m12 2.4 7.4 9.1L12 21.6 4.6 11.5 12 2.4Zm-2.5 9.2 2.5 5.6 2.5-5.6H9.5Zm.4-1.9h4.2L12 5.5 9.9 9.7Z"/>';

function cheerTier(amount) {
if (amount >= 10000) return 10000;
if (amount >= 5000) return 5000;
if (amount >= 1000) return 1000;
if (amount >= 100) return 100;
return 1;
}

function renderCheerHtml(token, amount) {
const title = escapeHtml(token);
return `<span class="chat-cheer chat-cheer-tier-${cheerTier(amount)}" title="${title}">` +
`<svg class="chat-cheer-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${bitsIconPath}</svg>` +
`${escapeHtml(String(amount))}</span>`;
}

function cheerStateFor(item) {
const meta = item && typeof item.meta === 'object' && item.meta ? item.meta : null;
const bits = meta ? Number(meta.bits) : 0;
return Number.isFinite(bits) && bits > 0 ? {left: Math.floor(bits)} : null;
}

function renderTextSegment(text, cheer) {
return String(text || '').split(/(\s+)/u).map(part => {
if (!part) return '';
if (/^\s+$/u.test(part)) return escapeHtml(part);
if (cheer && cheer.left > 0 && cheerTokenPattern.test(part)) {
const amount = parseInt(part.replace(/^[A-Za-z]+/, ''), 10);
// Only tokens covered by the message's bits tag are cheers; "abc123" in
// a normal sentence stays text.
if (amount > 0 && amount <= cheer.left) {
cheer.left -= amount;
return renderCheerHtml(part, amount);
}
}
const mapped = emoteMap.get(part);
if (mapped) return emoteImg(mapped, part, 'chat-emote-provider');
return escapeHtml(part);
Expand Down Expand Up @@ -496,37 +530,149 @@
const data = item && typeof item === 'object' ? item : {msg: item};
const text = String(data.msg || '');
if (!text) return '';
const cheer = cheerStateFor(data);
const native = normalizeNativeEmotes(data.emotes, text);
if (!native) return renderTextSegment(text);
if (!native) return renderTextSegment(text, cheer);

let html = '';
let cursor = 0;
native.emotes.forEach(emote => {
const before = native.mode === 'codeunit'
? text.slice(cursor, emote.start)
: native.chars.slice(cursor, emote.start).join('');
html += renderTextSegment(before);
html += renderTextSegment(before, cheer);
const label = rangeText(text, emote.start, emote.end, native.mode, native.chars);
html += emoteImg(twitchEmoteUrl(emote.id), label, 'chat-emote-native');
cursor = emote.end + 1;
});
const rest = native.mode === 'codeunit'
? text.slice(cursor)
: native.chars.slice(cursor).join('');
return html + renderTextSegment(rest);
return html + renderTextSegment(rest, cheer);
}

// --- USERNOTICE (subs, gifts, raids, announcements) ----------------------
const noticeGroups = {
sub: 'sub', resub: 'sub', primepaidupgrade: 'sub', giftpaidupgrade: 'sub',
anongiftpaidupgrade: 'sub', extendsub: 'sub',
subgift: 'gift', anonsubgift: 'gift', submysterygift: 'gift', anonsubmysterygift: 'gift',
standardpayforward: 'gift', communitypayforward: 'gift', onetapgiftredeemed: 'gift',
raid: 'raid', unraid: 'raid',
announcement: 'announcement',
bitsbadgetier: 'bits'
};
const noticeIcons = {
sub: '<path d="m12 2.8 2.7 5.4 5.9.9-4.3 4.2 1 5.9-5.3-2.8-5.3 2.8 1-5.9-4.3-4.2 5.9-.9L12 2.8Z"/>',
gift: '<path d="M4.5 10.2h15v7.1a2.5 2.5 0 0 1-2.5 2.5H7a2.5 2.5 0 0 1-2.5-2.5v-7.1Zm6.4 0v9.6h2.2v-9.6h-2.2ZM3.4 7.1A1.9 1.9 0 0 1 5.3 5.2h3.1A2.3 2.3 0 0 1 12 3.5a2.3 2.3 0 0 1 3.6 1.7h3.1a1.9 1.9 0 0 1 1.9 1.9v1.6H3.4V7.1Zm5.4-1.8c-.8 0-.9 1.1-.1 1.1h2.1c-.4-.6-1-1.1-2-1.1Zm6.4 0c-1 0-1.6.5-2 1.1h2.1c.8 0 .7-1.1-.1-1.1Z"/>',
raid: '<path d="M8.5 4a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm7.5 1a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM2.5 18.5c0-3.3 2.7-6 6-6s6 2.7 6 6V20h-12v-1.5Zm13.2 1.5v-1.5c0-1.8-.6-3.5-1.7-4.8a4.8 4.8 0 0 1 7.5 4V20h-5.8Z"/>',
announcement: '<path d="M3 9.5h3.2L13.5 5v14l-7.3-4.5H3v-5Zm4.2 6.2 1.4 4.3h2.3l-1.2-3.6-2.5-.7ZM16 8.6a4.5 4.5 0 0 1 0 6.8V8.6Zm2.3-2.4a7.8 7.8 0 0 1 0 11.6l-1.1-1.1a6.2 6.2 0 0 0 0-9.4l1.1-1.1Z"/>',
bits: bitsIconPath,
other: '<path d="M12 2.5a9.5 9.5 0 1 1 0 19 9.5 9.5 0 0 1 0-19Zm1.1 8h-2.2v7h2.2v-7Zm0-4h-2.2v2.2h2.2V6.5Z"/>'
};
const announceColors = {PRIMARY: 1, BLUE: 1, GREEN: 1, ORANGE: 1, PURPLE: 1};

function classPart(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 40);
}

function noticeEvent(item) {
const event = item && typeof item === 'object' && item.event && typeof item.event === 'object' ? item.event : null;
return event && event.type === 'notice' ? event : null;
}

function noticeGroup(kind) {
return noticeGroups[classPart(kind)] || 'other';
}

function renderNoticeHtml(item) {
const event = noticeEvent(item);
if (!event) return '';
const group = noticeGroup(event.kind);
let html = '<span class="chat-notice-head">' +
`<svg class="chat-notice-icon chat-notice-icon-${group}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${noticeIcons[group]}</svg>` +
`<span class="chat-notice-text">${escapeHtml(String(event.text || ''))}</span></span>`;
const body = renderBodyHtml(item);
if (body) {
const color = userColor(item.color);
const style = color ? ` style="color:${color}"` : '';
const badges = renderBadgesHtml(item);
html += '<span class="chat-notice-body">' +
(badges ? `<span class="chat-badges">${badges}</span>` : '') +
`<span class="chat-notice-user"${style}>${escapeHtml(String(item.user || ''))}</span>` +
`<span class="chat-notice-sep">: </span><span class="chat-notice-msg">${body}</span></span>`;
}
return html;
}

// Event part of the client-side duplicate keys. Notices add kind + text so
// several gift subs from one gifter are not collapsed into one line.
function chatEventKey(item) {
const event = item && typeof item === 'object' && item.event && typeof item.event === 'object' ? item.event : null;
if (!event || !event.type) return '';
let key = 'event|' + String(event.type) + '|';
if (event.type === 'notice') key += String(event.kind || '') + '|' + String(event.text || '') + '|';
return key;
}

function chatLineClassName(item) {
const data = item && typeof item === 'object' ? item : null;
const notice = noticeEvent(data);
if (notice) {
const kind = classPart(notice.kind) || 'other';
const classes = ['chat-line', 'chat-line-notice', `chat-line-notice-${noticeGroup(kind)}`, `chat-line-notice-kind-${kind}`];
const color = String(notice.color || '').toUpperCase();
if (kind === 'announcement') {
classes.push(`chat-line-announce-${announceColors[color] ? color.toLowerCase() : 'primary'}`);
}
return classes.join(' ');
}
if (data && data.event && data.event.type) {
const type = String(data.event.type || '').replace(/[^a-z0-9_-]/gi, '') || 'mod';
return `chat-line chat-line-mod chat-line-mod-${type}`;
}
if (data && data.reward) return 'chat-line chat-line-redeem';
return 'chat-line';
const classes = ['chat-line'];
if (data && data.reward) classes.push('chat-line-redeem');
const meta = data && data.meta && typeof data.meta === 'object' ? data.meta : null;
if (meta) {
if (meta.highlight === true) classes.push('chat-line-highlight');
if (meta.first === true) classes.push('chat-line-first');
if (Number(meta.bits) > 0) classes.push('chat-line-cheer');
if (meta.reply && typeof meta.reply === 'object') classes.push('chat-line-reply');
const power = classPart(meta.power);
if (power) classes.push(`chat-line-power-${power}`);
}
return classes.join(' ');
}

// Twitch shows "Replying to @x: ..." / "First message" above the line.
// Call after .chat-msg is filled; text goes in via textContent (no HTML).
function decorateLine(line, item) {
if (!line || !item || typeof item !== 'object' || item.event) return;
const meta = item.meta && typeof item.meta === 'object' ? item.meta : null;
if (!meta) return;
const host = line.querySelector('.chat-text') || line;
if (host.querySelector('.chat-context')) return;
const contexts = [];
const reply = meta.reply && typeof meta.reply === 'object' ? meta.reply : null;
if (reply && (reply.user || reply.msg)) {
const label = svT('Replying to @%(user)s: %(msg)s')
.replace('%(user)s', () => String(reply.user || ''))
.replace('%(msg)s', () => String(reply.msg || ''));
contexts.push(['reply', label]);
}
if (meta.first === true) contexts.push(['first', svT('First message')]);
contexts.reverse().forEach(([kind, label]) => {
const ctx = document.createElement('span');
ctx.className = `chat-context chat-context-${kind}`;
ctx.textContent = label;
ctx.title = label;
host.insertBefore(ctx, host.firstChild);
});
}

function renderMessageHtml(item) {
const notice = renderNoticeHtml(item);
if (notice) return notice;
const moderation = renderModerationHtml(item);
if (moderation) return moderation;
const redeem = renderRedeemHtml(item);
Expand All @@ -547,8 +693,11 @@
renderBadgesHtml,
userColor,
chatLineClassName,
chatEventKey,
decorateLine,
renderRedeemHtml,
renderModerationHtml,
renderNoticeHtml,
renderMessageHtml
};
}());
Loading
Loading