1020 lines
28 KiB
Svelte
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><<(.+?)<\/p>/gs, '<p class="redtext"><<$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>
|