beeta/frontend/src/lib/components/chat/ChatMessage.svelte
doomtube 7f56f19e94
All checks were successful
Build and Push / build-all (push) Successful in 8m52s
Fix: Force pull images in deploy workflow
2026-01-06 23:20:31 -05:00

1020 lines
28 KiB
Svelte

<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ttsEnabled, isUserMuted, toggleUserMute, ttsMutedUsers } from '$lib/chat/ttsStore';
import { stickerFavorites } from '$lib/chat/stickerFavorites';
import { stickersMap as sharedStickersMap, ensureLoaded } from '$lib/stores/stickers';
export let message;
export let currentUserId;
export let currentUsername = '';
export let isModerator = false;
export let isAdmin = false;
export let terminalMode = false; // When true, stickers render as small or text based on renderStickers
export let renderStickers = true; // Only used when terminalMode=true: false shows :stickername: as text
export let showHeader = true; // Show username/avatar header (false for consecutive messages from same user)
export let currentRealmId = null; // Current realm being viewed (to hide redundant realm tags)
export let headerBelow = false; // When true, show header below message content (for messages-from-bottom layout)
// Reactive check if this user is TTS muted
$: isTtsMuted = $ttsMutedUsers.has(message.userId);
const dispatch = createEventDispatcher();
// Admins and mods have moderation powers
$: hasModPowers = isModerator || isAdmin;
$: isOwnMessage = message.userId === currentUserId;
$: canModerate = hasModPowers && !isOwnMessage;
$: canDeleteOwn = hasModPowers && isOwnMessage; // Mods/admins can delete their own messages
$: showRealmTag = message.realmId && message.realmId !== 'null' && currentRealmId && currentRealmId !== 'null' && String(message.realmId) !== String(currentRealmId);
let showModMenu = false;
let parsedContent = '';
// Sticker context menu
let stickerContextMenu = null; // { x, y, stickerName }
// Self-destruct countdown
let countdownSeconds = 0;
let countdownInterval = null;
$: hasSelfDestruct = message.selfDestructAt && message.selfDestructAt > 0;
function updateCountdown() {
if (!message.selfDestructAt) return;
const now = Date.now();
const remaining = Math.max(0, Math.ceil((message.selfDestructAt - now) / 1000));
countdownSeconds = remaining;
if (remaining <= 0 && countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
function formatCountdown(seconds) {
if (seconds >= 60) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
return `${seconds}s`;
}
// Configure marked.js
marked.setOptions({
breaks: true,
gfm: true
});
onMount(async () => {
// Set up self-destruct countdown if applicable
if (message.selfDestructAt && message.selfDestructAt > 0) {
updateCountdown();
countdownInterval = setInterval(updateCountdown, 1000);
}
// Ensure stickers are loaded (uses shared store - only fetches once across all components)
await ensureLoaded();
});
onDestroy(() => {
if (countdownInterval) {
clearInterval(countdownInterval);
}
});
// Track if current user is mentioned
let isMentioned = false;
let hasFiredMentionEvent = false;
// Re-run when message.content, $sharedStickersMap, OR currentUsername changes
// The currentUsername dependency is critical for guests - their username is set async via WebSocket
$: {
// Pass currentUsername as parameter to ensure reactive updates when it changes
// Also pass terminalMode and renderStickers for terminal sticker handling
// Use $sharedStickersMap to trigger re-render when stickers are loaded
const result = $sharedStickersMap && parseMessage(message.content, currentUsername, terminalMode, renderStickers, $sharedStickersMap);
parsedContent = result.html;
// If user is mentioned, highlight and play honk (only fire event once)
if (result.mentioned) {
isMentioned = true;
console.log('[MENTION] Detected mention for', currentUsername, 'in message:', message.content);
if (!hasFiredMentionEvent) {
hasFiredMentionEvent = true;
console.log('[MENTION] Dispatching mentioned event:', { messageId: message.messageId, timestamp: message.timestamp });
// Use setTimeout to dispatch event outside reactive block (Svelte quirk)
setTimeout(() => {
console.log('[MENTION] Actually dispatching now...');
dispatch('mentioned', { messageId: message.messageId, timestamp: message.timestamp });
}, 0);
} else {
console.log('[MENTION] Already fired event for this message, skipping');
}
}
}
function parseMessage(content, username, isTerminal = false, showStickers = true, stickersMap = {}) {
if (!content) return { html: '', mentioned: false };
// Limit message length to 500 characters
let msg = content.slice(0, 500);
let mentioned = false;
// Check if current user is mentioned (case-insensitive)
if (username) {
const mentionPattern = new RegExp(`@${username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
mentioned = mentionPattern.test(msg);
}
// Handle [graffiti] tags - user pixel art signatures (88x33, displayed at 1:1 scale)
msg = msg.replace(/\[graffiti\](\/uploads\/graffiti\/[a-f0-9]+\.png)\[\/graffiti\]/gi, (match, url) => {
return `<img src="${url}" alt="graffiti" class="graffiti-img" style="image-rendering: pixelated; width: 88px; height: 33px;" />`;
});
// Step 1: Replace stickers with img tags BEFORE markdown parsing
// In terminal mode with stickers disabled, skip all sticker replacement (show as :name: text)
// In terminal mode with stickers enabled, only do inline replacement (small stickers only)
if (!isTerminal || showStickers) {
// Normal mode OR terminal with stickers enabled
if (!isTerminal) {
// Only do h1/h2 sticker expansion in normal chat mode (not terminal)
// Replace # :sticker: with h1 containing img
msg = msg.replace(/^#\s+:(\w+):$/gm, (match, stickerName) => {
const stickerKey = stickerName.toLowerCase();
if (stickersMap[stickerKey]) {
return `# <img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
}
return match;
});
// Replace ## :sticker: with h2 containing img
msg = msg.replace(/^##\s+:(\w+):$/gm, (match, stickerName) => {
const stickerKey = stickerName.toLowerCase();
if (stickersMap[stickerKey]) {
return `## <img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
}
return match;
});
}
// Replace inline :sticker: with img tags (all modes with stickers enabled)
// In terminal mode, # :sticker: stays as text since we skip the h1/h2 processing above
msg = msg.replace(/:(\w+):/g, (match, stickerName) => {
const stickerKey = stickerName.toLowerCase();
if (stickersMap[stickerKey]) {
return `<img src="${stickersMap[stickerKey]}" alt="${stickerName}" title="${stickerName}" data-sticker="${stickerName}" class="sticker-img" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
}
return match;
});
}
// If isTerminal && !showStickers, we skip all sticker replacement - they stay as :name: text
// Step 2: Run marked.js to parse markdown (handles >greentext via blockquotes)
let html = marked.parse(msg);
// Step 3: Process redtext syntax (after markdown, before sanitization)
// Handle both single line and with newline - match entire paragraph content
html = html.replace(/<p>&lt;&lt;(.+?)<\/p>/gs, '<p class="redtext">&lt;&lt;$1</p>');
// Step 4: Highlight @mentions (before sanitization)
// Replace @username with highlighted span
html = html.replace(/@(\w+)/g, '<span class="mention">@$1</span>');
// Step 5: Sanitize with DOMPurify - allow img tags and safe CSS
html = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'img', 'br', 'strong', 'em', 'code', 'pre', 'del', 'span'],
ALLOWED_ATTR: ['src', 'alt', 'title', 'class', 'style', 'data-sticker', 'onerror'],
FORBID_TAGS: ['a', 'button', 'script'],
ALLOW_DATA_ATTR: false
});
return { html, mentioned };
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
function formatEpoch(timestamp) {
const epoch = new Date(timestamp).getTime();
// Get last 8 digits of epoch for maximum randomness
const last8 = String(epoch).slice(-8);
return `No.${last8}`;
}
function handleDelete() {
dispatch('delete');
showModMenu = false;
}
function handleModAction(action, duration = 0) {
dispatch('modAction', {
action,
userId: message.userId,
duration,
reason: ''
});
showModMenu = false;
}
function handleTtsMute() {
toggleUserMute(message.userId, message.username);
}
function handleUsernameClick(event) {
event.stopPropagation();
dispatch('showProfile', {
username: message.username,
userId: message.userId,
isGuest: message.isGuest,
messageId: message.id,
position: {
x: event.clientX,
y: event.clientY
}
});
}
function handleUsernameDoubleClick(event) {
event.stopPropagation();
dispatch('mention', { username: message.username });
}
/**
* Sanitize color to prevent CSS injection
* Only allows valid hex colors (#RGB or #RRGGBB format)
*/
function sanitizeColor(color) {
if (!color || typeof color !== 'string') {
return '#FFFFFF';
}
// Match #RGB or #RRGGBB format only
const hexPattern = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
if (hexPattern.test(color)) {
return color;
}
return '#FFFFFF';
}
$: safeUserColor = sanitizeColor(message.userColor);
function handleContentDblClick(event) {
// Check if double-clicked element is a sticker image
const target = event.target;
if (target.tagName === 'IMG' && target.dataset.sticker) {
const stickerName = target.dataset.sticker;
dispatch('copySticker', { stickerName: `:${stickerName}:` });
}
}
function handleContentContextMenu(event) {
const target = event.target;
if (target.tagName === 'IMG' && target.dataset.sticker) {
event.preventDefault();
stickerContextMenu = {
x: event.clientX,
y: event.clientY,
stickerName: target.dataset.sticker
};
}
}
function closeStickerContextMenu() {
stickerContextMenu = null;
}
function openStickerInNewTab() {
if (stickerContextMenu) {
const url = $sharedStickersMap[stickerContextMenu.stickerName.toLowerCase()];
if (url) {
window.open(url, '_blank');
}
}
stickerContextMenu = null;
}
function copyStickerLink() {
if (stickerContextMenu) {
const url = $sharedStickersMap[stickerContextMenu.stickerName.toLowerCase()];
if (url) {
const fullUrl = window.location.origin + url;
navigator.clipboard.writeText(fullUrl);
}
}
stickerContextMenu = null;
}
function addStickerToFavorites() {
if (stickerContextMenu) {
stickerFavorites.toggle(stickerContextMenu.stickerName);
}
stickerContextMenu = null;
}
// Check if current context menu sticker is already in favorites
$: isStickerInFavorites = stickerContextMenu ? $stickerFavorites.includes(stickerContextMenu.stickerName) : false;
</script>
<div class="chat-message" class:own-message={isOwnMessage} class:mentioned={isMentioned} class:compact={!showHeader} class:header-below={headerBelow && showHeader}>
{#if showHeader}
<div class="message-header">
{#if message.avatarUrl}
<img src={message.avatarUrl} alt={message.username} class="user-avatar" />
{:else}
<div class="user-avatar-placeholder" style="background: {safeUserColor}">
{message.username.charAt(0).toUpperCase()}
</div>
{/if}
<button class="username-btn" style="color: {safeUserColor}" on:click={handleUsernameClick} on:dblclick={handleUsernameDoubleClick}>
{message.username}
</button>
{#if message.usedRoll}
<span class="cmd-tag roll-tag">R</span>
{/if}
{#if message.usedRtd}
<span class="cmd-tag rtd-tag">D</span>
{/if}
{#if showRealmTag}
<a href="/{message.realmId}/live" class="realm-link" title="Go to {message.realmId}'s stream">
{message.realmId}
</a>
{/if}
{#if hasSelfDestruct && countdownSeconds > 0}
<span class="self-destruct-indicator" title="Self-destructing message">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
</svg>
{formatCountdown(countdownSeconds)}
</span>
{/if}
{#if $ttsEnabled && !isOwnMessage}
<button
class="tts-mute-btn"
class:muted={isTtsMuted}
on:click={handleTtsMute}
title={isTtsMuted ? `Unmute ${message.username} TTS` : `Mute ${message.username} TTS`}
>
{#if isTtsMuted}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z"/>
<path d="M10.707 4.293a.5.5 0 0 1 0 .707L9.414 6.293l1.293 1.293a.5.5 0 0 1-.707.707L8.707 7 7.414 8.293a.5.5 0 0 1-.707-.707L8 6.293 6.707 5a.5.5 0 0 1 .707-.707L8.707 5.586l1.293-1.293a.5.5 0 0 1 .707 0z"/>
</svg>
{:else}
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z"/>
<path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z"/>
<path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z"/>
</svg>
{/if}
</button>
{/if}
<div class="header-meta">
{#if canModerate}
<button class="mod-button" on:click={() => (showModMenu = !showModMenu)}>⋮</button>
{:else if canDeleteOwn}
<button class="self-delete-btn" on:click={handleDelete} title="Delete your message">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
{/if}
<span class="timestamp">{formatTime(message.timestamp)}</span>
<span class="epoch">{formatEpoch(message.timestamp)}</span>
</div>
</div>
{/if}
<div class="message-row">
<div class="message-content" on:dblclick={handleContentDblClick} on:contextmenu={handleContentContextMenu}>
{@html parsedContent}
</div>
{#if !showHeader}
<div class="compact-meta">
{#if canModerate}
<button class="mod-button" on:click={() => (showModMenu = !showModMenu)}>⋮</button>
{:else if canDeleteOwn}
<button class="self-delete-btn" on:click={handleDelete} title="Delete your message">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
{/if}
<span class="timestamp">{formatTime(message.timestamp)}</span>
<span class="epoch">{formatEpoch(message.timestamp)}</span>
</div>
{/if}
</div>
{#if showModMenu}
<div class="mod-menu">
<button on:click={handleDelete}>Delete Message</button>
<button on:click={() => handleModAction('mute', 0)}>Mute</button>
<button on:click={() => handleModAction('ban')}>Ban</button>
</div>
{/if}
</div>
<svelte:window on:click={closeStickerContextMenu} />
{#if stickerContextMenu}
<div
class="sticker-context-menu"
style="left: {stickerContextMenu.x}px; top: {stickerContextMenu.y}px;"
on:click|stopPropagation
>
<button on:click={openStickerInNewTab}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/>
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/>
</svg>
Open in new tab
</button>
<button on:click={copyStickerLink}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
Copy image link
</button>
{#if !isStickerInFavorites}
<button on:click={addStickerToFavorites}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2v13.5a.5.5 0 0 0 .74.439L8 13.069l5.26 2.87A.5.5 0 0 0 14 15.5V2a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2zm2-1h8a1 1 0 0 1 1 1v12.566l-4.723-2.576a.5.5 0 0 0-.554 0L3 14.566V2a1 1 0 0 1 1-1z"/>
<path d="M8 4a.5.5 0 0 1 .5.5V6H10a.5.5 0 0 1 0 1H8.5v1.5a.5.5 0 0 1-1 0V7H6a.5.5 0 0 1 0-1h1.5V4.5A.5.5 0 0 1 8 4z"/>
</svg>
Add to favorites
</button>
{/if}
</div>
{/if}
<style>
.chat-message {
display: flex;
flex-direction: column;
padding: 0.1rem 0.2rem;
border-radius: 2px;
background: #000;
transition: background 0.2s;
position: relative;
}
.chat-message:hover {
background: #111;
}
.chat-message.own-message {
background: #000;
}
.chat-message.compact {
padding-top: 0.05rem;
padding-bottom: 0.05rem;
}
/* Header below message content (for messages-from-bottom layout) */
.chat-message.header-below {
flex-direction: column-reverse;
}
.chat-message.header-below .message-header {
margin-bottom: 0;
margin-top: 0;
}
.message-header {
display: flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0;
font-size: 0.75rem;
}
.user-avatar {
width: 18px;
height: 18px;
object-fit: cover;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
z-index: 1;
}
.user-avatar:hover {
transform: scale(2.0);
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.user-avatar-placeholder {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.55rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
background: #666;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
z-index: 1;
}
.user-avatar-placeholder:hover {
transform: scale(2.0);
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.username-btn {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: inherit;
font-family: inherit;
transition: filter 0.15s ease;
}
.username-btn:hover {
filter: invert(1);
}
.badge {
font-size: 0.55rem;
padding: 0.05rem 0.25rem;
border-radius: 2px;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.5px;
}
.badge.streamer {
background: #9b59b6;
color: #fff;
}
.badge.moderator {
background: #27ae60;
color: #fff;
}
.badge.guest {
background: #666;
color: #fff;
}
.cmd-tag {
font-size: 0.55rem;
padding: 0.05rem 0.25rem;
border-radius: 2px;
font-weight: 700;
background: #888;
color: #000;
}
.realm-link {
color: #888;
font-size: 0.6rem;
text-decoration: none;
padding: 0.05rem 0.25rem;
background: rgba(145, 70, 255, 0.15);
border-radius: 2px;
transition: all 0.15s ease;
margin-left: 0.15rem;
}
.realm-link:hover {
color: #9146ff;
background: rgba(145, 70, 255, 0.3);
text-decoration: none;
}
.header-meta {
display: flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
}
.timestamp {
color: #666;
font-size: 0.65rem;
}
.epoch {
color: #555;
font-size: 0.6rem;
font-family: monospace;
}
.self-destruct-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
color: #ff9800;
font-size: 0.7rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
background: rgba(255, 152, 0, 0.15);
border-radius: 3px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.mod-button {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 0 0.25rem;
font-size: 1rem;
line-height: 1;
opacity: 0;
transition: all 0.2s;
}
.chat-message:hover .mod-button {
opacity: 1;
}
.mod-button:hover {
color: #fff;
}
.message-row {
display: flex;
align-items: flex-start;
gap: 0.3rem;
}
.compact-meta {
display: flex;
align-items: center;
gap: 0.2rem;
flex-shrink: 0;
margin-left: auto;
}
.message-content {
color: #ddd;
line-height: 1.25;
word-wrap: break-word;
flex: 1;
min-width: 0;
font-size: 0.8rem;
}
/* Heading styles */
.message-content :global(h1),
.message-content :global(h2),
.message-content :global(h3),
.message-content :global(h4),
.message-content :global(h5),
.message-content :global(h6) {
line-height: 1.0;
margin-bottom: 0 !important;
font-weight: 700;
}
.message-content :global(h1) {
font-size: 1.375rem;
}
.message-content :global(h2) {
font-size: 1.25rem;
}
.message-content :global(h3) {
font-size: 1rem;
}
.message-content :global(h4) {
font-size: 0.875rem;
font-weight: 500;
}
.message-content :global(h5) {
font-size: 0.75rem;
font-weight: 500;
}
.message-content :global(h6) {
font-size: 0.5rem;
font-weight: 500;
}
/* Greentext - markdown blockquotes */
.message-content :global(blockquote) {
margin: 0;
margin-inline-start: 0;
margin-inline-end: 0;
padding: 0;
display: block;
background-repeat: no-repeat;
overflow-wrap: break-word;
}
.message-content :global(blockquote p) {
font-weight: 500;
color: #789922;
margin: 0;
padding: 0;
display: block;
background-repeat: no-repeat;
overflow-wrap: break-word;
}
.message-content :global(blockquote p::before) {
content: '>';
}
.message-content :global(blockquote h1) {
font-weight: bold;
color: #789922;
margin: 0;
padding: 0;
display: block;
background-repeat: no-repeat;
overflow-wrap: break-word;
}
.message-content :global(blockquote h1::before) {
content: '>';
}
/* Redtext - custom syntax */
.message-content :global(.redtext) {
font-weight: 500;
color: #cc1105;
display: block;
}
/* Default size for all images (inline stickers) */
.message-content :global(img) {
max-height: 33px;
max-width: 200px;
height: auto;
width: auto;
vertical-align: middle;
}
/* Inline stickers in paragraphs */
.message-content :global(p img) {
max-height: 33px;
max-width: 200px;
height: auto;
width: auto;
vertical-align: middle;
}
.message-content :global(.sticker-inline) {
max-height: 33px;
max-width: 200px;
height: auto;
width: auto;
vertical-align: middle;
margin: 0 2px;
}
/* Full-size stickers in h1 */
.message-content :global(h1 img) {
max-width: 475px;
max-height: none;
height: auto;
width: auto;
vertical-align: bottom;
}
@media (max-width: 768px) {
.message-content :global(h1 img) {
max-width: 33vw;
max-height: none;
height: auto;
width: auto;
}
}
/* Horizontally flipped stickers in h2 */
.message-content :global(h2 img) {
max-width: 475px;
max-height: none;
height: auto;
width: auto;
transform: scaleX(-1);
vertical-align: bottom;
}
@media (max-width: 768px) {
.message-content :global(h2 img) {
max-width: 33vw;
max-height: none;
height: auto;
width: auto;
}
}
/* h3 stickers - small flipped */
.message-content :global(h3 img) {
max-height: 41px;
height: auto;
width: auto;
transform: scaleX(-1);
vertical-align: bottom;
}
/* h4 stickers - medium size */
.message-content :global(h4 img) {
max-height: 82px;
width: auto;
vertical-align: bottom;
}
/* h5 stickers - full size */
.message-content :global(h5 img) {
max-width: 475px;
max-height: none;
height: auto;
width: auto;
vertical-align: bottom;
}
/* h6 stickers - tiny */
.message-content :global(h6 img) {
width: auto;
height: 20.5px;
vertical-align: bottom;
}
.mod-menu {
position: absolute;
right: 0;
top: 100%;
background: #333;
border: 1px solid #444;
border-radius: 4px;
padding: 0.25rem;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 150px;
}
.mod-menu button {
background: #444;
border: none;
color: #fff;
padding: 0.5rem;
border-radius: 3px;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.mod-menu button:hover {
background: #555;
}
/* TTS mute button */
.tts-mute-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s;
border-radius: 3px;
}
.chat-message:hover .tts-mute-btn {
opacity: 1;
}
.tts-mute-btn:hover {
color: #999;
background: rgba(255, 255, 255, 0.1);
}
.tts-mute-btn.muted {
color: #f44336;
opacity: 1;
}
.tts-mute-btn.muted:hover {
color: #ff6659;
}
/* Self-delete button for mods */
.self-delete-btn {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.2s;
border-radius: 3px;
}
.chat-message:hover .self-delete-btn {
opacity: 1;
}
.self-delete-btn:hover {
color: #f44336;
background: rgba(244, 67, 54, 0.1);
}
/* Sticker context menu - must be global since it's outside the component div */
:global(.sticker-context-menu) {
position: fixed;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
}
:global(.sticker-context-menu button) {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: none;
border: none;
color: #ccc;
font-size: 0.85rem;
cursor: pointer;
text-align: left;
white-space: nowrap;
}
:global(.sticker-context-menu button:hover) {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Mentioned message styling */
.chat-message.mentioned {
background: rgba(255, 215, 0, 0.1);
border-left: 2px solid #ffd700;
}
.chat-message.mentioned:hover {
background: rgba(255, 215, 0, 0.15);
}
.chat-message.mentioned .message-content {
color: #fff;
font-weight: 600;
}
/* @mention highlighting */
.message-content :global(.mention) {
color: #ffd700;
font-weight: 700;
background: rgba(255, 215, 0, 0.2);
padding: 0 0.25rem;
border-radius: 3px;
}
/* Graffiti images - override generic img rules to maintain exact size */
.message-content :global(.graffiti-img) {
width: 88px !important;
height: 33px !important;
max-width: none !important;
max-height: none !important;
image-rendering: pixelated;
vertical-align: middle;
}
</style>