mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-30 02:25:38 +00:00
1297 lines
61 KiB
JavaScript
1297 lines
61 KiB
JavaScript
// What goes inside a message: formatting (markdown, mentions, emoji, links,
|
||
// timestamps), poll and reaction widgets, the reply banner and quoting,
|
||
// editing in place, and the link policy that guards DMs.
|
||
|
||
export default {
|
||
|
||
_formatContent(str) {
|
||
if (typeof str !== 'string') str = String(str ?? '');
|
||
// Control characters have no business in a message, and NUL in particular
|
||
// is the delimiter of the placeholders below; strip them (keeping line
|
||
// breaks and tabs) so typed text can never pose as a placeholder.
|
||
str = str.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '');
|
||
|
||
// Spoiler image: spoiler-img:<payload> where payload is an /uploads URL or
|
||
// an e2e-img: marker. The sender marked this image as a spoiler, so render
|
||
// it blurred behind a "Spoiler" overlay; a click reveals it (handled by the
|
||
// delegated reveal listener). Only treat as a spoiler when the payload is
|
||
// actually media so a plain text message that happens to start with the
|
||
// marker isn't blurred unexpectedly. One line only: pictures sent together
|
||
// (#5799) can start with a spoiler and are laid out further down.
|
||
if (typeof str === 'string' && str.startsWith('spoiler-img:') && !str.includes('\n')) {
|
||
const rest = str.slice('spoiler-img:'.length);
|
||
if (this._isImageUrl(rest) || /^\/uploads\//i.test(rest) || rest.startsWith('e2e-img:')) {
|
||
const inner = this._formatContent(rest);
|
||
const label = this._escapeHtml(t('app.messages.spoiler'));
|
||
return `<div class="spoiler-media" role="button" tabindex="0" title="${label}"><span class="spoiler-media-tag">\u{1F441}️ ${label}</span>${inner}</div>`;
|
||
}
|
||
}
|
||
|
||
// E2E encrypted image: e2e-img:<mime>:<url>
|
||
const e2eImgMatch = str.match(/^e2e-img:(image\/(?:jpeg|png|gif|webp|svg\+xml)):(\/uploads\/[\w\-.]+)$/i);
|
||
if (e2eImgMatch) {
|
||
const mime = this._escapeHtml(e2eImgMatch[1]);
|
||
const url = this._escapeHtml(e2eImgMatch[2]);
|
||
return `<img data-e2e-src="${url}" data-e2e-mime="${mime}" class="chat-image e2e-img-pending" alt="${t('app.messages.e2e_image_alt')}" title="${t('app.messages.e2e_image_title')}">`;
|
||
}
|
||
|
||
// E2E encrypted file: e2e-file:{"mime":...,"size":N,"url":"/uploads/...","name":"..."}
|
||
// (#5310, #5308): non-image DM uploads, plus paste-into-PiP, are encrypted
|
||
// before upload and the metadata is wrapped in this marker.
|
||
if (str.startsWith('e2e-file:') && !str.includes('\n')) {
|
||
try {
|
||
const meta = JSON.parse(str.slice(9));
|
||
if (meta && typeof meta.url === 'string' && meta.url.startsWith('/uploads/')) {
|
||
const name = this._escapeHtml(typeof meta.name === 'string' ? meta.name : t('app.messages.file'));
|
||
const url = this._escapeHtml(meta.url);
|
||
const mime = this._escapeHtml(typeof meta.mime === 'string' ? meta.mime : 'application/octet-stream');
|
||
const size = Number(meta.size) || 0;
|
||
const sizeStr = this._escapeHtml(this._formatFileSize ? this._formatFileSize(size) : (size + ' B'));
|
||
// A voice message in a DM shows as one, with its length, and a click
|
||
// decrypts it into a player (#5665).
|
||
const voiceDur = this._voiceMessageLength(name);
|
||
const label = voiceDur !== null ? t('app.messages.voice_message') : name;
|
||
return `<div class="file-attachment e2e-file-pending${voiceDur !== null ? ' voice-message' : ''}" data-e2e-url="${url}" data-e2e-mime="${mime}" data-e2e-name="${name}" title="${t('app.messages.e2e_file_title')}">
|
||
<button type="button" class="file-download-link e2e-file-download">
|
||
<span class="file-icon">${voiceDur !== null ? '🎤' : '🔒'}</span>
|
||
<span class="file-name">${label}</span>
|
||
<span class="file-size">(${voiceDur !== null ? voiceDur : sizeStr})</span>
|
||
<span class="file-download-arrow">⬇</span>
|
||
</button>
|
||
</div>`;
|
||
}
|
||
} catch { /* unreadable attachment data: show the error line below */ }
|
||
return `<span class="muted-text">${t('app.messages.e2e_file_parse_error')}</span>`;
|
||
}
|
||
|
||
// A post can carry pictures and files on lines of their own between its
|
||
// text: a forum topic sent with a picture, or one written in New Post. Each
|
||
// of those lines shows as the picture or file, and the text around them
|
||
// formats as usual; the link used to print as plain text (#5690, #5689).
|
||
// Only this server's uploads, and never inside a code block.
|
||
// Pictures and files sent together use the same form (#5799), encrypted DM
|
||
// ones included, and two or more pictures or videos in a row show as one
|
||
// grid (app-media-grid.js). A blog post keeps one piece per file, so it
|
||
// turns the grid off while its post is drawn.
|
||
if (typeof str === 'string' && str.includes('\n') && !str.includes('```')) {
|
||
const mediaLine = (l) => /^(?:spoiler-img:)?\/uploads\/(?:[\w\-]+\/)?[\w\-.]+\.(jpg|jpeg|png|gif|webp|svg)$/i.test(l) ||
|
||
/^\[file:[^\]\n]+\]\(\/uploads\/[^\s|)]+\|[^)\n]+\)$/.test(l) ||
|
||
this._mediaGridKind?.(l) === 'image' || /^e2e-file:\{.*\}$/.test(l);
|
||
const lines = str.split('\n');
|
||
if (lines.some(l => mediaLine(l.trim()))) {
|
||
const parts = [];
|
||
let text = [];
|
||
const flush = () => {
|
||
const chunk = text.join('\n');
|
||
text = [];
|
||
if (chunk.trim()) parts.push(`<div class="content-text-part">${this._formatContent(chunk)}</div>`);
|
||
};
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const l = lines[i];
|
||
if (!mediaLine(l.trim())) { text.push(l); continue; }
|
||
flush();
|
||
const run = this._mediaGridOff ? [] : (this._mediaGridRun?.(lines, i) || []);
|
||
if (run.length >= 2) {
|
||
parts.push(this._mediaGridHtml(run));
|
||
i += run.length - 1;
|
||
continue;
|
||
}
|
||
parts.push(`<div class="content-media-part">${this._formatContent(l.trim())}</div>`);
|
||
}
|
||
flush();
|
||
return parts.join('');
|
||
}
|
||
}
|
||
|
||
// Decode legacy HTML entities from old server-side sanitization.
|
||
// The server no longer entity-encodes, but older messages in the DB
|
||
// may still contain entities like ' & < etc.
|
||
const emojiOnly = this._isEmojiOnly(str);
|
||
str = this._decodeHtmlEntities(str);
|
||
|
||
// Render file attachments [file:name](url|size)
|
||
const fileMatch = str.match(/^\[file:(.+?)\]\((.+?)\|(.+?)\)$/);
|
||
if (fileMatch) {
|
||
// Real attachments always live in this server's /uploads. Any other URL
|
||
// would make every viewer fetch it directly (leaking their IP past the
|
||
// media proxy) or dress a foreign link up as an attachment, so it stays
|
||
// plain text.
|
||
if (!/^\/uploads\//.test(fileMatch[2])) return this._escapeHtml(str);
|
||
const fileName = this._escapeHtml(fileMatch[1]);
|
||
const fileUrl = this._escapeHtml(fileMatch[2]);
|
||
const fileSize = this._escapeHtml(fileMatch[3]);
|
||
const ext = fileName.split('.').pop().toLowerCase();
|
||
const icon = { pdf: '📄', zip: '📦', '7z': '📦', rar: '📦', tar: '📦', gz: '📦',
|
||
mp3: '🎵', ogg: '🎵', oga: '🎵', wav: '🎵', flac: '🎵', aac: '🎵', wma: '🎵',
|
||
m4a: '🎵', opus: '🎵', weba: '🎵',
|
||
mp4: '🎬', webm: '🎬', mkv: '🎬', avi: '🎬', mov: '🎬', flv: '🎬',
|
||
m4v: '🎬', ogv: '🎬',
|
||
doc: '📝', docx: '📝', xls: '📊', xlsx: '📊', ppt: '📊', pptx: '📊',
|
||
txt: '📄', csv: '📄', json: '📄', md: '📄', log: '📄',
|
||
exe: '⚙️', msi: '⚙️', bat: '⚙️', cmd: '⚙️', ps1: '⚙️', sh: '⚙️',
|
||
dll: '⚙️', iso: '💿', dmg: '💿', img: '💿',
|
||
apk: '📱', deb: '📦', rpm: '📦',
|
||
py: '🐍', js: '📜', ts: '📜', html: '🌐', css: '🎨', svg: '🖼️' }[ext] || '📎';
|
||
const RISKY_EXTS = new Set([
|
||
'exe','bat','cmd','com','scr','pif','msi','msp','mst',
|
||
'ps1','vbs','vbe','js','jse','wsf','wsh','hta',
|
||
'cpl','inf','reg','dll','ocx','sys','drv',
|
||
'sh','app','dmg','pkg','deb','rpm','appimage',
|
||
]);
|
||
// A voice message from the mic button: a small player with its length
|
||
// rather than a file name and size (#5665).
|
||
const voiceDur = this._voiceMessageLength(fileName);
|
||
if (voiceDur !== null) {
|
||
return `<div class="file-attachment voice-message">
|
||
<div class="file-info"><span class="file-type-icon" aria-hidden="true">🎤</span> <span class="file-name">${t('app.messages.voice_message')}</span> <span class="file-size">(${voiceDur})</span></div>
|
||
<audio controls preload="metadata" src="${fileUrl}" class="file-audio"></audio>
|
||
</div>`;
|
||
}
|
||
// Audio/video get inline players. The extension lists are optimistic:
|
||
// a container being playable depends on the codecs inside it, not just the
|
||
// extension (a .mov holding ProRes or HEVC won't decode in most browsers).
|
||
// _setupVideos swaps the player back out for a download link
|
||
// if the element fires `error`, so listing a format here is safe.
|
||
if (['mp3', 'ogg', 'oga', 'wav', 'm4a', 'aac', 'flac', 'opus', 'weba'].includes(ext)) {
|
||
return `<div class="file-attachment">
|
||
<div class="file-info"><span class="file-type-icon" aria-hidden="true">${icon}</span> <span class="file-name">${fileName}</span> <span class="file-size">(${fileSize})</span></div>
|
||
<audio controls preload="none" src="${fileUrl}" class="file-audio"></audio>
|
||
</div>`;
|
||
}
|
||
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) {
|
||
return `<div class="file-attachment">
|
||
<div class="file-info"><span class="file-type-icon" aria-hidden="true">${icon}</span> <span class="file-name">${fileName}</span> <span class="file-size">(${fileSize})</span></div>
|
||
<div class="file-video-wrap">
|
||
<video controls preload="none" src="${fileUrl}" class="file-video"></video>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return `<div class="file-attachment">
|
||
<a href="${fileUrl}" target="_blank" rel="noopener noreferrer" class="file-download-link${RISKY_EXTS.has(ext) ? ' risky-file' : ''}" download="${fileName}"${RISKY_EXTS.has(ext) ? ' data-risky="true"' : ''}>
|
||
<span class="file-icon">${icon}</span>
|
||
<span class="file-name">${fileName}</span>
|
||
<span class="file-size">(${fileSize})</span>
|
||
<span class="file-download-arrow">⬇</span>
|
||
</a>
|
||
</div>`;
|
||
}
|
||
|
||
// Render server-hosted stickers inline at sticker dimensions (CSS-controlled)
|
||
if (/^\/uploads\/stickers\/[\w\-.]+\.(jpg|jpeg|png|gif|webp|svg)$/i.test(str.trim())) {
|
||
return `<img ${this._lazySrcAttr(`src="${this._escapeHtml(str.trim())}"`)} class="sticker-img" alt="sticker">`;
|
||
}
|
||
|
||
// Render server-hosted images inline (early return)
|
||
// Inline images go through the lazy media queue (app-media.js): the loader
|
||
// fetches them near the viewport, closest first, and pins their box so
|
||
// scrolling history never jumps.
|
||
// SVG is included: browsers render SVGs in <img> tags safely (no script execution). (#5309)
|
||
// Basename allows dots (`photo.edit.jpg`) and one extra path segment so this
|
||
// matches `_isImageUrl` / Haven Mobile. The previous `[\w\-]+` pattern
|
||
// classified those as images then emitted no <img>, so the bubble was blank.
|
||
if (/^\/uploads\/(?:[\w\-]+\/)?[\w\-.]+\.(jpg|jpeg|png|gif|webp|svg)$/i.test(str.trim())) {
|
||
const u = str.trim();
|
||
if (this._isImageHidden && this._isImageHidden(u)) return this._hiddenImagePlaceholder(u);
|
||
return `<img ${this._lazySrcAttr(`src="${this._escapeHtml(u)}"`)} class="chat-image" alt="image">`;
|
||
}
|
||
|
||
// Remote image-only messages (Ferry Discord attachments, pasted CDN URLs).
|
||
// Must run on the unescaped string so signed query params keep their `&`.
|
||
{
|
||
const u = str.trim();
|
||
if (this._isImageUrl(u) && /^https?:\/\//i.test(u)) {
|
||
if (this._isImageHidden && this._isImageHidden(u)) return this._hiddenImagePlaceholder(u);
|
||
return `<img ${this._lazySrcAttr(this._imgSrcAttr(u))} class="chat-image" alt="image">`;
|
||
}
|
||
}
|
||
|
||
// ── Extract fenced code blocks before escaping ──
|
||
// A word right after the opening fence is a language name only when the
|
||
// line ends there, as in Discord: "```js" then a new line. On one line,
|
||
// "```chmod +x start.sh```" is all code; reading "chmod" as the language
|
||
// used to drop it from the block.
|
||
// Placeholders carry a random nonce per call as a second guard, so even
|
||
// text that slipped past the strip above could not match one.
|
||
const nonceBytes = new Uint8Array(6);
|
||
if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(nonceBytes);
|
||
else nonceBytes.forEach((_, i) => { nonceBytes[i] = Math.floor(Math.random() * 256); });
|
||
const nonce = Array.from(nonceBytes, b => b.toString(16).padStart(2, '0')).join('');
|
||
const ph = (kind, idx) => `\x00${nonce}${kind}_${idx}\x00`;
|
||
const endColor = `\x00${nonce}ENDCOLOR\x00`;
|
||
|
||
const codeBlocks = [];
|
||
const withPlaceholders = str.replace(/```(?:(\w+)[ \t]*\n|\n)?([\s\S]*?)```/g, (_, lang, code) => {
|
||
const idx = codeBlocks.length;
|
||
codeBlocks.push({ lang: lang || '', code });
|
||
return ph('CODEBLOCK', idx);
|
||
});
|
||
|
||
// ── Timestamps: <t:1780853820> / <t:1780853820:R> ──
|
||
// Extracted before escaping (the token has angle brackets) and after the
|
||
// code fences above, so a token inside ``` stays literal.
|
||
const timestamps = [];
|
||
const withTimestamps = withPlaceholders.replace(/<t:(-?\d{1,15})(?::([tTdDfFR]))?>/g, (full, secs, style) => {
|
||
const rendered = this._formatTimestampToken(Number(secs), style || 'f');
|
||
if (!rendered) return full;
|
||
const idx = timestamps.length;
|
||
timestamps.push(rendered);
|
||
return ph('TIMESTAMP', idx);
|
||
});
|
||
|
||
// ── Discord custom emotes: <:name:id> / <a:name:id> ──
|
||
// Relayed by Ferry, or typed by someone who wants the emote to show on the
|
||
// Discord side of a bridge. Pulled out before escaping like the timestamps,
|
||
// and before the :name: pass below so the shortcode inside the token is not
|
||
// resolved on its own. A Haven emoji of the same name wins; otherwise the
|
||
// picture comes from the server's emote cache (/api/ferry/emote/), which
|
||
// answers 404 on a server without the bridge, and the :name: text stays.
|
||
const emotes = [];
|
||
const withEmotes = withTimestamps.replace(/<(a?):([A-Za-z0-9_]{2,32}):(\d{15,25})>/g, (full, anim, name, id) => {
|
||
const idx = emotes.length;
|
||
emotes.push(this._discordEmoteHtml(name, id, !!anim));
|
||
return ph('DEMOTE', idx);
|
||
});
|
||
|
||
let html = this._escapeHtml(withEmotes);
|
||
|
||
// ── Colour spans: c#RRGGBB…#c and c#(R,G,B)…#c ──
|
||
// Marked out before the link pass, so a closing #c is never swallowed into
|
||
// the URL in front of it, and restored last, so the colour reaches text
|
||
// inside a quote or a spoiler as well (#5661).
|
||
const colorOpens = [];
|
||
html = html.replace(/c#([0-9a-fA-F]{6})([\s\S]+?)#c/g, (full, hex, inner) => {
|
||
const idx = colorOpens.length;
|
||
colorOpens.push(`<span style="color:#${hex}">`);
|
||
return `${ph('COLOR', idx)}${inner}${endColor}`;
|
||
});
|
||
html = html.replace(/c#\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)([\s\S]+?)#c/g, (full, r, g, b, inner) => {
|
||
const [rr, gg, bb] = [r, g, b].map(v => Math.min(255, parseInt(v, 10)));
|
||
const idx = colorOpens.length;
|
||
colorOpens.push(`<span style="color:rgb(${rr},${gg},${bb})">`);
|
||
return `${ph('COLOR', idx)}${inner}${endColor}`;
|
||
});
|
||
|
||
// ── Markdown images & links (extract before auto-linking) ──
|
||
const mdLinks = [];
|
||
// 
|
||
html = html.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (full, alt, url) => {
|
||
const safeUrl = this._rawHttpUrl(url);
|
||
if (!safeUrl) return full;
|
||
const idx = mdLinks.length;
|
||
mdLinks.push((this._isImageHidden && this._isImageHidden(safeUrl))
|
||
? this._hiddenImagePlaceholder(safeUrl)
|
||
: `<img ${this._imgSrcAttr(safeUrl)} class="chat-image" alt="${alt || 'image'}">`);
|
||
return ph('MDLINK', idx);
|
||
});
|
||
// [text](url)
|
||
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (full, text, url) => {
|
||
const safeUrl = this._rawHttpUrl(url);
|
||
if (!safeUrl) return full;
|
||
const idx = mdLinks.length;
|
||
mdLinks.push(`<a href="${this._escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer nofollow" title="${this._escapeHtml(safeUrl)}" data-masked-link="true">${text}</a>`);
|
||
return ph('MDLINK', idx);
|
||
});
|
||
|
||
// Auto-link URLs (and render image URLs as inline images)
|
||
// Use placeholders to prevent @mention regex from matching inside URLs
|
||
const autoLinks = [];
|
||
html = html.replace(
|
||
/\bhttps?:\/\/[a-zA-Z0-9\-._~:/?#\[\]@!$&()*+,;=%]+/g,
|
||
(url) => {
|
||
const safeUrl = this._rawHttpUrl(url);
|
||
if (!safeUrl) return url;
|
||
const idx = autoLinks.length;
|
||
if (this._isRemoteImageUrl(safeUrl)) {
|
||
autoLinks.push((this._isImageHidden && this._isImageHidden(safeUrl))
|
||
? this._hiddenImagePlaceholder(safeUrl)
|
||
: `<img ${this._imgSrcAttr(safeUrl)} class="chat-image" alt="image" loading="lazy">`);
|
||
} else {
|
||
autoLinks.push(`<a href="${this._escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer nofollow">${this._escapeHtml(safeUrl)}</a>`);
|
||
}
|
||
return ph('AUTOLINK', idx);
|
||
}
|
||
);
|
||
|
||
// Render @mentions with highlight (negative lookbehind prevents matching inside email addresses).
|
||
// Only style as a mention when the matched name resolves to a real
|
||
// channel member (login name OR display name), or to the current user.
|
||
// Random `@text` that doesn't match anyone is left as plain text. (#5273)
|
||
// Match by login name first (longest first, supports spaces), then fall
|
||
// back to display names. Self-mention falls back to a simple \w match for
|
||
// when channel members haven't loaded yet.
|
||
const validNames = new Set();
|
||
const loginToDisplay = new Map();
|
||
const displayToLogin = new Map();
|
||
// Map matched names back to user id so we can prefer the viewer's personal
|
||
// nickname for the display text. (#5290)
|
||
const nameToUserId = new Map();
|
||
if (Array.isArray(this.channelMembers)) {
|
||
for (const m of this.channelMembers) {
|
||
if (!m) continue;
|
||
if (m.loginName) {
|
||
validNames.add(m.loginName.toLowerCase());
|
||
loginToDisplay.set(m.loginName.toLowerCase(), m.username || m.loginName);
|
||
if (m.id) nameToUserId.set(m.loginName.toLowerCase(), m.id);
|
||
}
|
||
if (m.username) {
|
||
validNames.add(m.username.toLowerCase());
|
||
displayToLogin.set(m.username.toLowerCase(), m.loginName || m.username);
|
||
if (m.id) nameToUserId.set(m.username.toLowerCase(), m.id);
|
||
}
|
||
// Also let users autocomplete/style mentions by their assigned nickname.
|
||
const nick = m.id && this._nicknames ? this._nicknames[m.id] : null;
|
||
if (nick) {
|
||
validNames.add(nick.toLowerCase());
|
||
if (m.id) nameToUserId.set(nick.toLowerCase(), m.id);
|
||
}
|
||
}
|
||
}
|
||
const selfLogin = (this.user.username || '').toLowerCase();
|
||
if (selfLogin) validNames.add(selfLogin);
|
||
// Also include known persona names so @PersonaName lights up as a mention
|
||
// and pings the persona's owner. (#5349) Personas are tracked in
|
||
// _channelPersonas (lowercase name → { user_id, name, avatar }) and are
|
||
// populated as messages from personas are rendered.
|
||
if (this._channelPersonas instanceof Map) {
|
||
for (const [low, p] of this._channelPersonas.entries()) {
|
||
validNames.add(low);
|
||
if (p && p.user_id) nameToUserId.set(low, p.user_id);
|
||
}
|
||
}
|
||
// Also include the user's own personas so they can self-reference.
|
||
if (Array.isArray(this._personas)) {
|
||
for (const p of this._personas) {
|
||
if (!p || !p.name) continue;
|
||
const low = p.name.toLowerCase();
|
||
validNames.add(low);
|
||
if (this.user && this.user.id) nameToUserId.set(low, this.user.id);
|
||
}
|
||
}
|
||
// Role mentions (#5579): every role name is a valid @target, styled as a
|
||
// role and lit up for a viewer who holds it.
|
||
const roleByName = new Map();
|
||
const myRoleIds = new Set(((this.user && this.user.roles) || []).map(r => r && r.id));
|
||
for (const r of (this._mentionableRoles || [])) {
|
||
if (!r || !r.name) continue;
|
||
const low = r.name.toLowerCase();
|
||
roleByName.set(low, { name: r.name, color: r.color, color2: r.color2, color_shimmer: r.color_shimmer, mine: myRoleIds.has(r.id) });
|
||
validNames.add(low);
|
||
}
|
||
const allNames = [...validNames].sort((a, b) => b.length - a.length);
|
||
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
// Build alt list of known names; also keep a generic fallback for any
|
||
// \w token so we can detect a candidate before validating it below.
|
||
const namesAlt = allNames.length ? allNames.map(escapeRe).join('|') + '|' : '';
|
||
const mentionRegex = new RegExp(`(?<![\\w@])@(${namesAlt}\\w{1,30})`, 'gi');
|
||
html = html.replace(mentionRegex, (match, name) => {
|
||
const lower = name.toLowerCase();
|
||
// Only render as a mention if this matches a known member or self.
|
||
// (When channelMembers hasn't loaded yet, allow self-mention only.)
|
||
const isKnown = validNames.has(lower);
|
||
const isSelf = lower === selfLogin;
|
||
if (!isKnown && !isSelf) return match;
|
||
// A role, unless a member shares the name, in which case the person wins.
|
||
const role = roleByName.get(lower);
|
||
if (role && !nameToUserId.has(lower) && !isSelf) {
|
||
const roleColor = this._safeColor(role.color);
|
||
const style = roleColor ? ` style="--role-color:${roleColor}"` : '';
|
||
return `<span class="mention mention-role${role.mine ? ' mention-self' : ''}"${style}>${this._roleNameHtml(role, '@' + role.name)}</span>`;
|
||
}
|
||
// Prefer the viewer's personal nickname for that user, then the
|
||
// server-side display name, then the raw token. (#5290)
|
||
const uid = nameToUserId.get(lower);
|
||
const nick = uid && this._nicknames ? this._nicknames[uid] : null;
|
||
const display = nick || loginToDisplay.get(lower) || name;
|
||
return `<span class="mention${isSelf ? ' mention-self' : ''}">@${this._escapeHtml(display)}</span>`;
|
||
});
|
||
|
||
// ── @everyone / @here mentions ──
|
||
// Render as a styled mention badge. Notification + audio cue is handled
|
||
// separately in app-socket.js when a new message arrives.
|
||
html = html.replace(/(?<![\w@])@(everyone|here)\b/gi, (_m, name) => {
|
||
return `<span class="mention mention-everyone" data-everyone="${name.toLowerCase()}">@${this._escapeHtml(name.toLowerCase())}</span>`;
|
||
});
|
||
|
||
// ── #channel-name links ──
|
||
// Recognize #foo / #foo-bar / #🎮general references and turn them into
|
||
// clickable spans that switch the active channel on click. We resolve
|
||
// against the user's currently-loaded channel list (case-insensitive).
|
||
// Matched names must follow a non-word/non-hash boundary so things like
|
||
// ## headings or message IDs (#1234) don't get linkified spuriously.
|
||
if (Array.isArray(this.channels) && this.channels.length) {
|
||
const chanByName = new Map();
|
||
const nameByCode = new Map();
|
||
// Names a channel used to have, so a #old-name typed before a rename
|
||
// still points at it and reads as the name it has now (#5602). A current
|
||
// name always wins over another channel's former one.
|
||
const formerByName = new Map();
|
||
for (const c of this.channels) {
|
||
if (c && c.name && c.code && !c.is_dm) {
|
||
chanByName.set(String(c.name).toLowerCase(), c.code);
|
||
nameByCode.set(c.code, String(c.name));
|
||
let former = [];
|
||
try { former = typeof c.former_names === 'string' ? JSON.parse(c.former_names) : (c.former_names || []); } catch { former = []; }
|
||
if (Array.isArray(former)) for (const old of former) {
|
||
if (typeof old === 'string' && old) formerByName.set(old.toLowerCase(), c.code);
|
||
}
|
||
}
|
||
}
|
||
if (chanByName.size > 0) {
|
||
// Names with spaces are typed as #foo_bar, so try the literal form
|
||
// first, then fall back to a space-substituted lookup so spaced
|
||
// channel names resolve too.
|
||
const lookup = (map, lower) => map.get(lower) || map.get(lower.replace(/_/g, ' '));
|
||
html = html.replace(/(?<![\w#&])#([\p{L}\p{N}\p{Emoji_Presentation}_-][\p{L}\p{N}\p{Emoji_Presentation}_-]{0,49})/gu, (match, name) => {
|
||
const lower = name.toLowerCase();
|
||
let code = lookup(chanByName, lower);
|
||
let label = name;
|
||
if (!code) {
|
||
code = lookup(formerByName, lower);
|
||
if (!code) return match;
|
||
label = (nameByCode.get(code) || name).replace(/\s+/g, '_');
|
||
}
|
||
return `<span class="channel-link" data-channel-code="${this._escapeHtml(code)}">#${this._escapeHtml(label)}</span>`;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Render spoilers (||text||): CSP-safe, uses delegated click handler
|
||
html = html.replace(/\|\|(.+?)\|\|/g, '<span class="spoiler">$1</span>');
|
||
|
||
// Render custom + bundled built-in image emojis :name:
|
||
html = html.replace(/:([a-zA-Z0-9_-]+):/g, (match, name) => {
|
||
const emoji = this._findNamedEmoji(name);
|
||
if (emoji) return `<img src="${this._escapeHtml(emoji.url)}" alt=":${this._escapeHtml(name)}:" title=":${this._escapeHtml(name)}:" class="custom-emoji">`;
|
||
return match;
|
||
});
|
||
|
||
// Render __underline__
|
||
html = html.replace(/__(.+?)__/g, '<u>$1</u>');
|
||
|
||
// Render /me action text (italic)
|
||
if (html.startsWith('_') && html.endsWith('_') && html.length > 2) {
|
||
html = `<em class="action-text">${html.slice(1, -1)}</em>`;
|
||
}
|
||
|
||
// Render **bold**
|
||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
|
||
// Render *italic*
|
||
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||
|
||
// Render ~~strikethrough~~
|
||
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||
|
||
// Render ==highlight==
|
||
html = html.replace(/==(.+?)==/g, '<mark class="chat-highlight">$1</mark>');
|
||
|
||
// Render `inline code`
|
||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||
|
||
// Render grouped > blockquotes and preserve attribution lines inside the quote.
|
||
// A line quotes only when the > is followed by a space, another >, or
|
||
// nothing at all: ">implying" and ">.<" stay as typed (#5654).
|
||
const blockquotes = [];
|
||
html = html.replace(/(^|\n)((?:>(?:[ \t][^\n]*|>[^\n]*)?(?:\n|$))+)/g, (full, pre, block) => {
|
||
// A lone ">" with nothing on it is only a blank line inside a quote,
|
||
// never a quote by itself.
|
||
if (block.split('\n').every(line => /^>\s*$/.test(line))) return full;
|
||
const lines = block.trim().split('\n').map(line => line.replace(/^>\s?/, ''));
|
||
let authorHtml = '';
|
||
// The name may already be a highlighted mention by now, so the tags are
|
||
// set aside for the test (they stay in what is shown).
|
||
if (lines[0] && /^@[^\s].+ wrote:$/.test(lines[0].replace(/<[^>]*>/g, ''))) {
|
||
authorHtml = `<div class="chat-blockquote-author">${lines.shift()}</div>`;
|
||
}
|
||
const textHtml = lines.join('<br>');
|
||
const idx = blockquotes.length;
|
||
blockquotes.push(`<blockquote class="chat-blockquote">${authorHtml}<div class="chat-blockquote-body">${textHtml}</div></blockquote>`);
|
||
// The line break after the quote stays in the text, so a list that
|
||
// follows still starts on its own line; the <br> it turns into is
|
||
// dropped again when the quote is put back (#5661).
|
||
return `${pre}${ph('BLOCKQUOTE', idx)}${block.endsWith('\n') ? '\n' : ''}`;
|
||
});
|
||
|
||
// (Colour spans were marked out before the link pass and are put back at
|
||
// the very end.)
|
||
|
||
// ── Headings: # H1, ## H2, ### H3 at start of line ──
|
||
html = html.replace(/(^|\n)(#{1,3})\s+(.+)/g, (_, pre, hashes, text) => {
|
||
const level = hashes.length;
|
||
return `${pre}<div class="chat-heading chat-h${level}">${text}</div>`;
|
||
});
|
||
|
||
// ── Horizontal rules: --- or ___ on their own line (3+ chars) ──
|
||
html = html.replace(/(^|\n)([-]{3,}|[_]{3,})\s*(?=\n|$)/g, '$1<hr class="chat-hr">');
|
||
|
||
// ── Markdown tables ──
|
||
// | h1 | h2 |
|
||
// |----|----|
|
||
// | a | b |
|
||
// Run before lists/line-break conversion. Cell text passes through
|
||
// already-resolved emoji / custom-emoji / mention HTML, so emoji
|
||
// (unicode and :name:) render naturally inside cells. (#5286)
|
||
const tablePlaceholders = [];
|
||
const splitRow = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim());
|
||
// Spaces only ([ \t]) around the pipes: \s would also swallow the line
|
||
// break after the |---| row, and every row below the header then came out
|
||
// as plain text.
|
||
const tableRe = /(^|\n)((?:\|[^\n]*\|[ \t]*\n)+)\|[ \t]*:?-{2,}:?(?:[ \t]*\|[ \t]*:?-{2,}:?)+[ \t]*\|[ \t]*(?:\n((?:\|[^\n]*\|[ \t]*(?:\n|$))*))?/g;
|
||
html = html.replace(tableRe, (full, pre, headBlock, bodyBlock) => {
|
||
// headBlock holds 1+ leading rows; the last one is the header (the rest
|
||
// would only happen with malformed input, so drop them safely by taking
|
||
// just the last row as header).
|
||
const headRows = headBlock.trim().split('\n').filter(l => /^\s*\|.*\|\s*$/.test(l));
|
||
if (headRows.length === 0) return full;
|
||
const headerCells = splitRow(headRows[headRows.length - 1]);
|
||
const bodyRows = (bodyBlock || '').trim().split('\n').filter(l => /^\s*\|.*\|\s*$/.test(l));
|
||
const thead = `<thead><tr>${headerCells.map(c => `<th>${c}</th>`).join('')}</tr></thead>`;
|
||
const tbody = bodyRows.length
|
||
? `<tbody>${bodyRows.map(row => {
|
||
const cells = splitRow(row);
|
||
// Pad / trim to header width
|
||
while (cells.length < headerCells.length) cells.push('');
|
||
return `<tr>${cells.slice(0, headerCells.length).map(c => `<td>${c}</td>`).join('')}</tr>`;
|
||
}).join('')}</tbody>`
|
||
: '';
|
||
const idx = tablePlaceholders.length;
|
||
tablePlaceholders.push(`<div class="chat-table-wrap"><table class="chat-table">${thead}${tbody}</table></div>`);
|
||
return `${pre}${ph('TABLE', idx)}`;
|
||
});
|
||
|
||
// ── Lists (ordered + unordered) with multi-tier nesting (#5304) ──
|
||
// A list line starts with optional leading whitespace (spaces or tabs;
|
||
// tabs count as 2 spaces for indent purposes), then either "- " / "* "
|
||
// / "+ " (unordered) or "N. " (ordered). Indentation determines depth:
|
||
// each 2 spaces ⇒ one extra level. Mixed unordered/ordered at the same
|
||
// depth open separate lists. Adjacent list-blocks are detected by the
|
||
// outer regex (any consecutive run of qualifying lines).
|
||
const listLineRe = /^([ \t]*)([-*+]|\d+\.)\s+(.*)$/;
|
||
const listBlockRe = /((?:(?:^|\n)[ \t]*(?:[-*+]|\d+\.)[ \t]+.+)+)/g;
|
||
html = html.replace(listBlockRe, (match) => {
|
||
const lines = match.replace(/^\n/, '').split('\n');
|
||
// Parse each line into { depth, ordered, num, text }
|
||
const parsed = lines.map(line => {
|
||
const m = line.match(listLineRe);
|
||
if (!m) return null;
|
||
const indent = m[1].replace(/\t/g, ' ');
|
||
const depth = Math.floor(indent.length / 2);
|
||
const marker = m[2];
|
||
const ordered = /^\d+\.$/.test(marker);
|
||
const num = ordered ? parseInt(marker, 10) : null;
|
||
return { depth, ordered, num, text: m[3] };
|
||
}).filter(Boolean);
|
||
if (!parsed.length) return match;
|
||
|
||
// Build nested HTML using a stack of open lists.
|
||
let out = '';
|
||
const stack = []; // each entry: { ordered, depth }
|
||
const closeTo = (targetLen) => {
|
||
while (stack.length > targetLen) {
|
||
const top = stack.pop();
|
||
out += '</li>';
|
||
out += top.ordered ? '</ol>' : '</ul>';
|
||
}
|
||
};
|
||
parsed.forEach((item, idx) => {
|
||
// Close lists that are at deeper depth than this item
|
||
while (stack.length && stack[stack.length - 1].depth > item.depth) {
|
||
out += '</li>';
|
||
const top = stack.pop();
|
||
out += top.ordered ? '</ol>' : '</ul>';
|
||
}
|
||
const top = stack[stack.length - 1];
|
||
if (!top || top.depth < item.depth) {
|
||
// Open a new nested list. If we're nesting under an open <li>,
|
||
// don't close it; the new list goes inside.
|
||
if (top && top.depth < item.depth) {
|
||
// already inside an open <li> from previous sibling
|
||
}
|
||
const startAttr = item.ordered ? ` start="${item.num || 1}"` : '';
|
||
out += item.ordered ? `<ol class="chat-list"${startAttr}>` : '<ul class="chat-list">';
|
||
stack.push({ ordered: item.ordered, depth: item.depth });
|
||
} else if (top.depth === item.depth && top.ordered !== item.ordered) {
|
||
// Same depth but list type changed: close current, open new.
|
||
out += '</li>';
|
||
const popped = stack.pop();
|
||
out += popped.ordered ? '</ol>' : '</ul>';
|
||
const startAttr = item.ordered ? ` start="${item.num || 1}"` : '';
|
||
out += item.ordered ? `<ol class="chat-list"${startAttr}>` : '<ul class="chat-list">';
|
||
stack.push({ ordered: item.ordered, depth: item.depth });
|
||
} else {
|
||
// Same depth, same type: close previous <li> sibling.
|
||
out += '</li>';
|
||
}
|
||
out += `<li>${item.text}`;
|
||
});
|
||
closeTo(0);
|
||
return '\n' + out;
|
||
});
|
||
|
||
html = html.replace(/\n/g, '<br>');
|
||
|
||
// ── Restore tables (do this after <br> so they aren't broken up) ──
|
||
tablePlaceholders.forEach((tbl, idx) => {
|
||
html = html.replace(new RegExp(`(?:<br>)?${ph('TABLE', idx)}(?:<br>)?`), () => tbl);
|
||
});
|
||
|
||
blockquotes.forEach((block, idx) => {
|
||
html = html.replace(new RegExp(`(?:<br>)?${ph('BLOCKQUOTE', idx)}(?:<br>)?`), () => block);
|
||
});
|
||
|
||
// ── Restore fenced code blocks ──
|
||
codeBlocks.forEach((block, idx) => {
|
||
const escaped = this._escapeHtml(block.code).replace(/\n$/, '');
|
||
const langAttr = block.lang ? ` data-lang="${this._escapeHtml(block.lang)}"` : '';
|
||
const langLabel = block.lang ? `<span class="code-block-lang">${this._escapeHtml(block.lang)}</span>` : '';
|
||
const rendered = `<div class="code-block"${langAttr}>${langLabel}<pre><code>${escaped}</code></pre></div>`;
|
||
html = html.replace(ph('CODEBLOCK', idx), () => rendered);
|
||
});
|
||
|
||
// ── Restore markdown links/images ──
|
||
// Function replacements throughout: a URL holding $& or $' must not be
|
||
// read as a replacement pattern.
|
||
mdLinks.forEach((link, idx) => {
|
||
html = html.replace(ph('MDLINK', idx), () => link);
|
||
});
|
||
|
||
// ── Restore auto-linked URLs ──
|
||
autoLinks.forEach((link, idx) => {
|
||
html = html.replace(ph('AUTOLINK', idx), () => link);
|
||
});
|
||
|
||
// ── Restore timestamps ──
|
||
// Function replacement, so a formatted date containing $& or $1 cannot
|
||
// be read as a replacement pattern.
|
||
timestamps.forEach((el, idx) => {
|
||
html = html.replace(ph('TIMESTAMP', idx), () => el);
|
||
});
|
||
|
||
// ── Restore Discord emotes ──
|
||
emotes.forEach((el, idx) => {
|
||
html = html.replace(ph('DEMOTE', idx), () => el);
|
||
});
|
||
|
||
// ── Colour spans go back last, around whatever was rendered inside them ──
|
||
colorOpens.forEach((open, idx) => {
|
||
html = html.replace(ph('COLOR', idx), () => open);
|
||
});
|
||
html = html.split(endColor).join('</span>');
|
||
|
||
if (emojiOnly) html = `<span class="emoji-only-msg">${html}</span>`;
|
||
|
||
return html;
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// POLLS
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
_renderPollWidget(msgId, poll) {
|
||
if (!poll || !poll.question || !Array.isArray(poll.options)) return '';
|
||
const votes = poll.votes || {};
|
||
const totalVotes = poll.totalVotes || 0;
|
||
const myId = this.user.id;
|
||
|
||
const optionsHtml = poll.options.map((opt, i) => {
|
||
const voters = votes[i] || [];
|
||
const count = voters.length;
|
||
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
|
||
const myVote = voters.some(v => v.user_id === myId);
|
||
const voterNames = poll.anonymous ? '' : voters.map(v => this._escapeHtml(v.username)).join(', ');
|
||
// An option can carry a picture (#5648). It is part of the button, so a
|
||
// click on it is a vote, not the lightbox.
|
||
const img = Array.isArray(poll.images) && typeof poll.images[i] === 'string' && /^\/uploads\//.test(poll.images[i])
|
||
? `<img class="poll-option-img" src="${this._escapeHtml(poll.images[i])}" alt="" loading="lazy">` : '';
|
||
return `<button class="poll-option${myVote ? ' poll-voted' : ''}${img ? ' has-image' : ''}" data-msg-id="${msgId}" data-option="${i}" title="${voterNames}">
|
||
<div class="poll-option-bar" style="width:${pct}%"></div>${img}
|
||
<span class="poll-option-text">${this._escapeHtml(opt)}</span>
|
||
<span class="poll-option-count">${count} (${pct}%)</span>
|
||
</button>`;
|
||
}).join('');
|
||
|
||
const settings = [];
|
||
if (poll.multiVote) settings.push(t('poll.multiple_votes'));
|
||
if (poll.anonymous) settings.push(t('poll.anonymous'));
|
||
const settingsHtml = settings.length ? `<div class="poll-settings-info">${settings.join(' · ')}</div>` : '';
|
||
|
||
// A picture poll can sit in columns (#5648).
|
||
const cols = Number(poll.columns) > 1 ? Math.min(5, Math.floor(Number(poll.columns))) : 0;
|
||
return `<div class="poll-widget" data-msg-id="${msgId}">
|
||
<div class="poll-question">${this._escapeHtml(poll.question)}</div>
|
||
<div class="poll-options${cols ? ' poll-grid' : ''}"${cols ? ` style="--poll-cols:${cols}"` : ''}>${optionsHtml}</div>
|
||
<div class="poll-footer">${t(totalVotes === 1 ? 'poll.votes_one' : 'poll.votes_other', { count: totalVotes })}${settingsHtml ? ' · ' : ''}${settingsHtml}</div>
|
||
</div>`;
|
||
},
|
||
|
||
_updatePollVotes(messageId, votes, totalVotes) {
|
||
const widget = document.querySelector(`.poll-widget[data-msg-id="${messageId}"]`);
|
||
if (!widget) return;
|
||
|
||
const wasAtBottom = this._coupledToBottom;
|
||
const myId = this.user.id;
|
||
|
||
// Get current poll data from the message to know anonymous/multiVote settings
|
||
const msgEl = document.querySelector(`[data-msg-id="${messageId}"]`);
|
||
const pollAnonymous = msgEl && msgEl.dataset.pollAnonymous === '1';
|
||
|
||
widget.querySelectorAll('.poll-option').forEach(btn => {
|
||
const idx = parseInt(btn.dataset.option);
|
||
const voters = votes[idx] || [];
|
||
const count = voters.length;
|
||
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
|
||
const myVote = voters.some(v => v.user_id === myId);
|
||
|
||
btn.classList.toggle('poll-voted', myVote);
|
||
btn.title = pollAnonymous ? '' : voters.map(v => this._escapeHtml(v.username)).join(', ');
|
||
const bar = btn.querySelector('.poll-option-bar');
|
||
if (bar) bar.style.width = pct + '%';
|
||
const countEl = btn.querySelector('.poll-option-count');
|
||
if (countEl) countEl.textContent = `${count} (${pct}%)`;
|
||
});
|
||
|
||
const footer = widget.querySelector('.poll-footer');
|
||
if (footer) {
|
||
const settingsInfo = footer.querySelector('.poll-settings-info');
|
||
const settingsHtml = settingsInfo ? ' · ' + settingsInfo.outerHTML : '';
|
||
footer.innerHTML = `${t(totalVotes === 1 ? 'poll.votes_one' : 'poll.votes_other', { count: totalVotes })}${settingsHtml}`;
|
||
}
|
||
|
||
if (wasAtBottom) this._scrollToBottom(true);
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// REACTIONS
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
_renderReactions(msgId, reactions) {
|
||
if (!reactions || reactions.length === 0) return '';
|
||
// Group by emoji
|
||
const grouped = {};
|
||
reactions.forEach(r => {
|
||
if (!grouped[r.emoji]) grouped[r.emoji] = { emoji: r.emoji, users: [] };
|
||
grouped[r.emoji].users.push({ id: r.user_id, username: r.username });
|
||
});
|
||
|
||
const badges = Object.values(grouped).map(g => {
|
||
const isOwn = g.users.some(u => u.id === this.user.id);
|
||
const names = g.users.map(u => u.username).join(', ');
|
||
const usersJson = this._escapeHtml(JSON.stringify(g.users.map(u => u.username)));
|
||
// Check if it's a custom emoji
|
||
const customMatch = g.emoji.match(/^:([a-zA-Z0-9_-]+):$/);
|
||
let emojiDisplay = this._escapeHtml(g.emoji);
|
||
if (customMatch && this.customEmojis) {
|
||
const ce = this._findNamedEmoji(customMatch[1]);
|
||
if (ce) emojiDisplay = `<img src="${this._escapeHtml(ce.url)}" alt=":${this._escapeHtml(ce.name)}:" class="custom-emoji reaction-custom-emoji">`;
|
||
}
|
||
return `<button class="reaction-badge${isOwn ? ' own' : ''}" data-emoji="${this._escapeHtml(g.emoji)}" data-users="${usersJson}" title="${this._escapeHtml(names)}">${emojiDisplay} ${g.users.length}</button>`;
|
||
}).join('');
|
||
|
||
return `<div class="reactions-row">${badges}</div>`;
|
||
},
|
||
|
||
_updateMessageReactions(messageId, reactions) {
|
||
// Update both the main pane and the DM PiP if either contains this message.
|
||
const els = document.querySelectorAll(`[data-msg-id="${messageId}"]`);
|
||
if (!els.length) return;
|
||
|
||
// A reader at the bottom of the chat, a thread or the DM window stays
|
||
// there while the row grows the message, and again when a custom emoji in
|
||
// it loads: the feed anchor (app-feed-anchor.js) sees every change in a
|
||
// message's height.
|
||
const wasAtBottom = this._coupledToBottom;
|
||
const html = this._renderReactions(messageId, reactions);
|
||
|
||
els.forEach((msgEl) => {
|
||
const oldRow = msgEl.querySelector('.reactions-row');
|
||
if (oldRow) oldRow.remove();
|
||
if (!html) return;
|
||
const content = msgEl.querySelector('.message-content, .thread-msg-content');
|
||
if (content) content.insertAdjacentHTML('afterend', html);
|
||
});
|
||
|
||
if (wasAtBottom) this._scrollToBottom(true);
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// REPLY
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
// The text a reply line shows for a message: pictures and files by a short
|
||
// label, not the address they are stored under (an encrypted picture's
|
||
// "e2e-img:image/png:/uploads/..." used to show as it was).
|
||
// Several pictures in a row read as one count, not a label each (#5799).
|
||
_replyPreviewText(content) {
|
||
const isPicture = (l) => this._isImageUrl(l) || /^\/uploads\/\S+$/i.test(l);
|
||
const lines = String(content || '').split('\n').map(l => l.trim()).filter(Boolean);
|
||
return lines.map((l, i) => {
|
||
if (isPicture(l)) {
|
||
if (i > 0 && isPicture(lines[i - 1])) return null;
|
||
let n = 1;
|
||
while (i + n < lines.length && isPicture(lines[i + n])) n++;
|
||
return n > 1 ? `🖼️ ${t('messages.preview_pictures', { count: n })}` : `🖼️ ${t('messages.preview_picture')}`;
|
||
}
|
||
const file = /^\[file:([^\]]*)\]\(/.exec(l);
|
||
if (file) return `📎 ${file[1] || t('messages.preview_file')}`;
|
||
if (l.startsWith('e2e-file:')) {
|
||
let name = '';
|
||
try { name = JSON.parse(l.slice(9)).name || ''; } catch { /* unreadable marker: plain label */ }
|
||
return `📎 ${name || t('messages.preview_file')}`;
|
||
}
|
||
return l;
|
||
}).filter(l => l !== null).join(' ');
|
||
},
|
||
|
||
_renderReplyBanner(replyCtx) {
|
||
const text = this._replyPreviewText(replyCtx.content);
|
||
const previewText = text.length > 80 ? text.substring(0, 80) + '…' : text;
|
||
const color = this._getUserColor(replyCtx.username);
|
||
return `
|
||
<div class="reply-banner" data-reply-msg-id="${replyCtx.id}">
|
||
<span class="reply-line" style="background:${color}"></span>
|
||
<span class="reply-author" style="color:${color}">${this._escapeHtml(this._getNickname(replyCtx.user_id, replyCtx.username))}</span>
|
||
<span class="reply-preview">${this._escapeHtml(previewText)}</span>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// A self destructing message takes no replies or quotes. Pinning or
|
||
// protecting it drops the timer line, and with it this block.
|
||
_refuseSelfDestructing(msgEl) {
|
||
if (!msgEl?.querySelector?.('.msg-self-destruct')) return false;
|
||
this._showToast?.(t('messages.self_destruct_no_reply'), 'info');
|
||
return true;
|
||
},
|
||
|
||
_setReply(msgEl, msgId) {
|
||
// In a forum a reply to a topic belongs in the topic's thread: that is what
|
||
// bumps it, and it keeps the answer under the question instead of posting
|
||
// a second topic that quotes the first. (#144)
|
||
const forumCh = this.channels && this.channels.find(c => c.code === this.currentChannel);
|
||
if (forumCh && forumCh.is_forum && msgEl && msgEl.closest && msgEl.closest('#messages')) {
|
||
this._clearReply();
|
||
this._openThread(msgId);
|
||
return;
|
||
}
|
||
// Opening a forum topic's thread (above) stays allowed, as from the
|
||
// Threads button; a reply in the channel itself does not.
|
||
if (this._refuseSelfDestructing(msgEl)) return;
|
||
// Get message info: works for both full messages and compact messages
|
||
let author = msgEl.querySelector('.message-author')?.textContent;
|
||
if (!author) {
|
||
// Compact message: look up the previous full message's author
|
||
let prev = msgEl.previousElementSibling;
|
||
while (prev) {
|
||
const authorEl = prev.querySelector('.message-author');
|
||
if (authorEl) { author = authorEl.textContent; break; }
|
||
prev = prev.previousElementSibling;
|
||
}
|
||
}
|
||
author = author || t('voice.someone');
|
||
const content = msgEl.querySelector('.message-content')?.textContent || '';
|
||
const preview = content.length > 60 ? content.substring(0, 60) + '…' : content;
|
||
|
||
this.replyingTo = { id: msgId, username: author, content };
|
||
|
||
const bar = document.getElementById('reply-bar');
|
||
bar.style.display = 'flex';
|
||
document.getElementById('reply-preview-text').innerHTML =
|
||
t('thread_runtime.replying_to', { author: this._escapeHtml(author), preview: this._escapeHtml(preview) });
|
||
document.getElementById('message-input').focus();
|
||
},
|
||
|
||
_clearReply() {
|
||
this.replyingTo = null;
|
||
const bar = document.getElementById('reply-bar');
|
||
if (bar) bar.style.display = 'none';
|
||
},
|
||
|
||
_quoteMessage(msgEl) {
|
||
if (this._refuseSelfDestructing(msgEl)) return;
|
||
// Get the raw text content of the message
|
||
const rawContent = msgEl.dataset.rawContent || msgEl.querySelector('.message-content')?.textContent || '';
|
||
// Get the author name
|
||
let author = msgEl.querySelector('.message-author')?.textContent;
|
||
if (!author) {
|
||
let prev = msgEl.previousElementSibling;
|
||
while (prev) {
|
||
const authorEl = prev.querySelector('.message-author');
|
||
if (authorEl) { author = authorEl.textContent; break; }
|
||
prev = prev.previousElementSibling;
|
||
}
|
||
}
|
||
author = author || t('voice.someone');
|
||
|
||
// Build the blockquote text: each line prefixed with >
|
||
const quotedLines = rawContent.split('\n').map(l => `> ${l}`).join('\n');
|
||
const quoteText = `${t('thread_runtime.wrote', { author })}\n${quotedLines}\n`;
|
||
|
||
// Sent along with the message, so the quote can lead back here.
|
||
const quotedId = parseInt(msgEl.dataset.msgId, 10);
|
||
this._pendingQuote = quotedId ? { id: quotedId, code: this.currentChannel } : null;
|
||
|
||
const input = document.getElementById('message-input');
|
||
// If there's already text, add a newline before the quote
|
||
if (input.value) {
|
||
input.value += '\n' + quoteText;
|
||
} else {
|
||
input.value = quoteText;
|
||
}
|
||
|
||
input.focus();
|
||
// Trigger input event so textarea auto-resizes
|
||
input.dispatchEvent(new Event('input'));
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// EDIT MESSAGE
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
_startEditMessage(msgEl, msgId) {
|
||
// Guard against re-entering edit mode
|
||
if (msgEl.classList.contains('editing')) return;
|
||
// A protected message is kept as it is; the server refuses the edit too.
|
||
if (msgEl.dataset.archived === '1' || msgEl.classList.contains('archived')) {
|
||
this._showToast?.(t('app.messages.protected_no_edit'), 'error');
|
||
return;
|
||
}
|
||
|
||
const contentEl = msgEl.querySelector('.message-content, .thread-msg-content');
|
||
if (!contentEl) return;
|
||
|
||
// Use the stored raw markdown content (set on render and kept in sync on
|
||
// edit events). Falls back to textContent only for very old DOM nodes that
|
||
// pre-date this attribute, but avoids the two bugs that textContent causes:
|
||
// 1) markdown formatting stripped (bold/italic/etc. lost)
|
||
// 2) '(edited)' tag text leaked into the textarea on repeated edits.
|
||
const rawText = msgEl.dataset.rawContent ?? contentEl.textContent;
|
||
// Pictures sent together (#5799): only the text is edited, and the
|
||
// pictures go back under it unchanged.
|
||
const grouped = this._mediaGroupSplit?.(rawText) || null;
|
||
|
||
// Replace content with an editable textarea
|
||
const originalHtml = contentEl.innerHTML;
|
||
contentEl.innerHTML = '';
|
||
msgEl.classList.add('editing'); // hide toolbar while editing
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.className = 'edit-textarea';
|
||
textarea.value = grouped ? grouped.text : rawText;
|
||
textarea.rows = 1;
|
||
textarea.maxLength = parseInt(this.serverSettings?.max_message_chars) || 2000;
|
||
// The same drag bar the composer has, so a long message can be pulled
|
||
// open while editing it. It sits under the box, and dragging it down makes
|
||
// the box taller, since the message above it may be at the very top of
|
||
// the chat with nowhere to drag up to (#5662).
|
||
const grip = document.createElement('div');
|
||
grip.className = 'pip-input-resizer edit-resizer';
|
||
grip.setAttribute('aria-hidden', 'true');
|
||
contentEl.appendChild(textarea);
|
||
contentEl.appendChild(grip);
|
||
this._bindInputResizer?.(grip);
|
||
|
||
// Track active edit textarea for emoji picker redirection
|
||
this._activeEditTextarea = textarea;
|
||
|
||
const btnRow = document.createElement('div');
|
||
btnRow.className = 'edit-actions';
|
||
btnRow.innerHTML = `<button class="edit-emoji-btn" title="${t('app.input_bar.emoji_btn')}">😀</button><button class="edit-save-btn">${t('modals.common.save')}</button><button class="edit-cancel-btn">${t('modals.common.cancel')}</button>`;
|
||
contentEl.appendChild(btnRow);
|
||
|
||
// Emoji button in edit bar opens the picker
|
||
btnRow.querySelector('.edit-emoji-btn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
this._activeEditTextarea = textarea;
|
||
this._toggleEmojiPicker();
|
||
});
|
||
|
||
textarea.focus();
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||
|
||
const cancel = () => {
|
||
msgEl.classList.remove('editing');
|
||
contentEl.innerHTML = originalHtml;
|
||
if (this._activeEditTextarea === textarea) this._activeEditTextarea = null;
|
||
// Close emoji picker if it was open for this edit
|
||
const picker = document.getElementById('emoji-picker');
|
||
if (picker) picker.style.display = 'none';
|
||
// Close autocomplete dropdowns
|
||
this._hideMentionDropdown();
|
||
this._hideEmojiDropdown();
|
||
};
|
||
|
||
btnRow.querySelector('.edit-cancel-btn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
cancel();
|
||
});
|
||
btnRow.querySelector('.edit-save-btn').addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
let newContent = textarea.value.trim();
|
||
if (grouped) newContent = [newContent, ...grouped.media].filter(Boolean).join('\n');
|
||
if (!newContent) {
|
||
// Discord-style: clearing the whole message and confirming the edit
|
||
// offers to delete the message rather than silently cancelling it.
|
||
// Enter confirms the prompt via the shared confirm modal.
|
||
cancel();
|
||
if (await this._showConfirmModal(t('confirm.delete_message'), '', { danger: true, confirmLabel: t('msg_toolbar.delete') })) {
|
||
const pip = msgEl.closest('#dm-pip-messages') ? this._activeDMPip : null;
|
||
const attachments = this._getMessageAttachments?.(msgId);
|
||
this.socket.emit('delete-message', pip
|
||
? { messageId: msgId, channelCode: pip, attachments }
|
||
: { messageId: msgId, attachments });
|
||
}
|
||
return;
|
||
}
|
||
if (newContent === rawText) return cancel();
|
||
|
||
// E2E: encrypt edited DM content. The PiP can edit a DM that isn't
|
||
// the active channel, so resolve the partner against the container's
|
||
// channel code when available.
|
||
const pipContext = msgEl.closest('#dm-pip-messages') ? this._activeDMPip : null;
|
||
const partner = pipContext ? this._getE2EPartnerFor(pipContext) : this._getE2EPartner();
|
||
if (partner) {
|
||
try {
|
||
newContent = await this._e2eEncryptText(partner, newContent);
|
||
} catch (err) {
|
||
console.warn('[E2E] Failed to encrypt edited message:', err);
|
||
}
|
||
}
|
||
|
||
const channelCode = pipContext || this.currentChannel;
|
||
this.socket.emit('edit-message', { messageId: msgId, content: newContent, channelCode });
|
||
cancel(); // will be updated by the server event
|
||
});
|
||
|
||
textarea.addEventListener('keydown', (e) => {
|
||
e.stopPropagation();
|
||
|
||
// Ctrl/Cmd+E toggles the emoji picker for this edit. The global shortcut
|
||
// in app-ui.js can't fire here because we stopPropagation above, so it's
|
||
// re-handled locally; _activeEditTextarea (set above) routes the pick into
|
||
// this textarea rather than the main composer.
|
||
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'e') {
|
||
e.preventDefault();
|
||
this._activeEditTextarea = textarea;
|
||
this._toggleEmojiPicker();
|
||
return;
|
||
}
|
||
|
||
// Handle @mention and :emoji dropdown navigation in edit mode
|
||
const mentionDd = document.getElementById('mention-dropdown');
|
||
if (mentionDd && mentionDd.style.display !== 'none') {
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
this._navigateMentionDropdown(e.key === 'ArrowDown' ? 1 : -1);
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||
const active = mentionDd.querySelector('.mention-item.active');
|
||
if (active) { e.preventDefault(); active.click(); return; }
|
||
}
|
||
if (e.key === 'Escape') { this._hideMentionDropdown(); return; }
|
||
}
|
||
const emojiDd = document.getElementById('emoji-dropdown');
|
||
if (emojiDd && emojiDd.style.display !== 'none') {
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
this._navigateEmojiDropdown(e.key === 'ArrowDown' ? 1 : -1);
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||
const active = emojiDd.querySelector('.emoji-ac-item.active');
|
||
if (active) { e.preventDefault(); active.click(); return; }
|
||
}
|
||
if (e.key === 'Escape') { this._hideEmojiDropdown(); return; }
|
||
}
|
||
|
||
// Markdown formatting shortcuts
|
||
if (this._handleMarkdownShortcuts(textarea, e)) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
if (this._composerListKey(e, textarea)) return;
|
||
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
btnRow.querySelector('.edit-save-btn').click();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
cancel();
|
||
}
|
||
});
|
||
|
||
textarea.addEventListener('paste', (e) => {
|
||
if (this._handleMarkdownLinkPaste(textarea, e)) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Enable @mention and :emoji autocomplete in edit textarea
|
||
textarea.addEventListener('input', () => {
|
||
this._checkMentionTrigger(textarea);
|
||
this._checkChannelTrigger(textarea);
|
||
this._checkEmojiTrigger(textarea);
|
||
});
|
||
|
||
// Click inside edit area should not bubble to delegation handler
|
||
contentEl.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
}, { once: false });
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// CLIENT-SIDE LINK POLICY (#5483, v3.44.0)
|
||
// ═══════════════════════════════════════════════════════
|
||
//
|
||
// End-to-end encrypted DMs are ciphertext by the time they reach the server,
|
||
// so the send-message automod path cannot see their links at all. The setting
|
||
// existed and did nothing for them, which is worse than not having it.
|
||
//
|
||
// The recipient's client CAN see them, after decryption and before rendering,
|
||
// and that is the check worth having. A hostile sender can run a patched
|
||
// client and skip any check on their own side, but they cannot reach into the
|
||
// recipient's browser and switch this off. So the enforcement that protects
|
||
// the person at risk is the one that runs where the risk lands.
|
||
//
|
||
// The rules themselves come from /js/automod-rules.js, the same file the
|
||
// server requires, so the two cannot drift into disagreeing.
|
||
|
||
// Is this container showing DM content?
|
||
//
|
||
// The first version of this looked for a [data-dm-render] attribute that does
|
||
// not exist: the DM PiP renderer marks a JS property on the message object,
|
||
// not the DOM. So the only branch that ever fired was the current-channel
|
||
// check, and a DM popped out over a normal channel was never recognised.
|
||
// @birdcrazy caught it (#5483). Detect the PiP container by its actual id.
|
||
_isDmContainer(containerEl) {
|
||
try {
|
||
if (containerEl) {
|
||
if (containerEl.id === 'dm-pip-messages') return true;
|
||
if (containerEl.closest && containerEl.closest('#dm-pip-messages, #dm-pip')) return true;
|
||
if (containerEl.querySelector && containerEl.querySelector('#dm-pip-messages')) return true;
|
||
}
|
||
const ch = (this.channels || []).find(c => c.code === this.currentChannel);
|
||
return !!(ch && ch.is_dm);
|
||
} catch { return false; }
|
||
},
|
||
|
||
_initLinkPolicy() {
|
||
this._linkPolicy = null;
|
||
const apply = (p) => { this._linkPolicy = p && p.enabled ? p : null; };
|
||
this.socket.on('link-policy', apply);
|
||
this.socket.emit('get-link-policy', null, apply);
|
||
},
|
||
|
||
// Returns null when the text is fine, or { rule, host, url, message }.
|
||
// Safe to call before the policy has loaded: no policy means no verdict.
|
||
_checkLinkPolicy(text) {
|
||
if (!this._linkPolicy || !window.HavenAutomodRules) return null;
|
||
try {
|
||
return window.HavenAutomodRules.checkText(text, this._linkPolicy);
|
||
} catch { return null; }
|
||
},
|
||
|
||
// True when links in this text should be rendered inert rather than clickable.
|
||
// Applied to DM content specifically, since that is the path the server cannot
|
||
// inspect. Channel messages were already blocked at send time.
|
||
_dmLinkBlocked(text) {
|
||
if (!this._linkPolicy || !this._linkPolicy.scanDms) return null;
|
||
return this._checkLinkPolicy(text);
|
||
},
|
||
|
||
// One-time identity disclosure on DMs. Haven does not verify who anyone is,
|
||
// and on an open server someone can register a display name that matches a
|
||
// person you trust (the owner, a mod) and DM you as them. This is a nudge to
|
||
// check, not a control. Dismissed globally, remembered in localStorage.
|
||
_maybeShowDmSafetyNotice(container) {
|
||
if (!container) return;
|
||
try { if (localStorage.getItem('haven_dm_safety_dismissed') === '1') return; } catch { /* storage blocked (private mode): show the notice */ }
|
||
// Already present in this container, so don't stack copies on re-render.
|
||
if (container.querySelector(':scope > .dm-safety-notice')) return;
|
||
|
||
const notice = document.createElement('div');
|
||
notice.className = 'dm-safety-notice';
|
||
notice.innerHTML =
|
||
'<span class="dm-safety-icon" aria-hidden="true">🛡️</span>' +
|
||
`<span class="dm-safety-text">${t('dm_runtime.safety_notice')}</span>` +
|
||
`<button type="button" class="dm-safety-dismiss">${t('dm_runtime.safety_dismiss')}</button>`;
|
||
|
||
notice.querySelector('.dm-safety-dismiss').addEventListener('click', () => {
|
||
try { localStorage.setItem('haven_dm_safety_dismissed', '1'); } catch { /* storage blocked (private mode): nothing is remembered, nothing else breaks */ }
|
||
// Clear it everywhere it might be showing (main pane + any open PiP).
|
||
document.querySelectorAll('.dm-safety-notice').forEach(n => n.remove());
|
||
});
|
||
|
||
container.insertBefore(notice, container.firstChild);
|
||
},
|
||
|
||
// Neutralise disallowed links in an already-rendered DM message container.
|
||
//
|
||
// Scoped to DMs on purpose. Channel messages were checked at send time, so
|
||
// running this there would only ever affect history that predates the current
|
||
// policy, and silently breaking old links nobody complained about is not a
|
||
// trade worth making.
|
||
//
|
||
// Anchors become plain text with a warning; images from disallowed hosts
|
||
// become a click-to-reveal placeholder rather than loading. The media proxy
|
||
// already stops the IP leak, so this is about the click, not the fetch.
|
||
_enforceDmLinkPolicy(containerEl) {
|
||
if (!containerEl) return;
|
||
const policy = this._linkPolicy;
|
||
const R = window.HavenAutomodRules;
|
||
if (!policy || !policy.scanDms || !R) return;
|
||
|
||
const hostBlocked = (rawUrl) => {
|
||
if (!rawUrl) return false;
|
||
try {
|
||
const u = new URL(rawUrl, location.href);
|
||
if (u.origin === location.origin) return false; // our own uploads / proxy
|
||
// A data: or blob: address has no host to judge. A picture that has not
|
||
// loaded yet carries a data: placeholder, and reading that as a host
|
||
// turned the server's own uploads into "blocked domain" notices.
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||
return !R.checkHost(u.hostname, policy).allowed;
|
||
} catch { return false; }
|
||
};
|
||
|
||
containerEl.querySelectorAll('.message-content a[href]').forEach(a => {
|
||
if (a.dataset.policyChecked) return;
|
||
a.dataset.policyChecked = '1';
|
||
if (!hostBlocked(a.href)) return;
|
||
|
||
let host = a.href;
|
||
try { host = new URL(a.href).hostname; } catch { /* unparsable address: the tooltip shows it whole */ }
|
||
const span = document.createElement('span');
|
||
span.className = 'blocked-link';
|
||
span.title = t('dm_runtime.blocked_link_tooltip', { host });
|
||
span.textContent = a.textContent;
|
||
const badge = document.createElement('span');
|
||
badge.className = 'blocked-link-badge';
|
||
badge.textContent = ` ⚠ ${t('dm_runtime.blocked_link_badge')}`;
|
||
span.appendChild(badge);
|
||
a.replaceWith(span);
|
||
});
|
||
|
||
containerEl.querySelectorAll('.message-content img[data-mp-origin], .message-content img.chat-image').forEach(img => {
|
||
if (img.dataset.policyChecked) return;
|
||
img.dataset.policyChecked = '1';
|
||
const origin = img.dataset.mpOrigin || img.getAttribute('data-mp-src') || img.dataset.lazySrc || img.src;
|
||
if (!hostBlocked(origin)) return;
|
||
const ph = document.createElement('span');
|
||
ph.className = 'hidden-image';
|
||
ph.setAttribute('role', 'button');
|
||
ph.tabIndex = 0;
|
||
ph.dataset.hiddenSrc = origin;
|
||
ph.textContent = t('dm_runtime.blocked_image');
|
||
img.replaceWith(ph);
|
||
});
|
||
},
|
||
|
||
};
|