openvibers/openvibe.chat Commits
Render and manage server emoji in Chat (#515)
Changes
@@@ -807,3 +807,5 @@ div.oc-set-row { cursor: default; }.oc-agent-poll-vote small { color: var(--text-secondary, #96a7c2); white-space: nowrap; }.oc-kind-agent_stream { color: var(--accent, #3b82f6); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }@media (max-width: 480px) { .oc-agent-banner, .oc-agent-poll { padding: 9px 11px; } }++.oc-server-emoji { vertical-align: middle; object-fit: contain; }++.oc-emoji-choices { display: flex; flex-wrap: wrap; max-height: 12rem; overflow: auto; }@@@ -12,6 +12,57 @@var feed = document.getElementById('oc-feed');var form = document.getElementById('oc-compose');var input = document.getElementById('oc-input');++ var serverEmojis = new Map((Array.isArray(cfg.serverEmojis) ? cfg.serverEmojis : []).map(function (e) { return [String(e.name).toLowerCase(), e]; }));++ function textWithRoomEmoji(parent, text) {++ if (!cfg.serverEmojiSlug) { textWithLinks(parent, text); return; }++ var raw = String(text || ''), re = /:([a-z0-9_]{2,32}):/gi, last = 0, m;++ while ((m = re.exec(raw))) {++ textWithLinks(parent, raw.slice(last, m.index));++ var emoji = serverEmojis.get(m[1].toLowerCase());++ if (emoji && /^https:\/\//.test(String(emoji.url || ''))) {++ var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = m[0]; img.width = 22; img.height = 22; img.loading = 'lazy';++ img.addEventListener('error', function () { this.replaceWith(document.createTextNode(this.alt)); });++ parent.appendChild(img);++ } else parent.appendChild(document.createTextNode(m[0]));++ last = re.lastIndex;++ }++ textWithLinks(parent, raw.slice(last));++ }++ if (cfg.serverEmojiSlug && form && input) {++ var picker = el('details', 'oc-emoji-picker');++ var toggle = el('summary', 'ovk-btn ovk-btn--quiet', 'Emoji'); toggle.setAttribute('aria-label', 'Choose server emoji'); picker.appendChild(toggle);++ var choices = el('div', 'oc-emoji-choices'); picker.appendChild(choices);++ function showChoices() {++ choices.replaceChildren();++ Array.from(serverEmojis.values()).forEach(function (emoji) {++ var button = el('button', 'ovk-btn ovk-btn--quiet'); button.type = 'button'; button.title = ':' + emoji.name + ':';++ var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = ':' + emoji.name + ':'; img.width = 22; img.height = 22;++ img.addEventListener('error', function () { this.replaceWith(document.createTextNode(this.alt)); });++ button.appendChild(img); button.appendChild(document.createTextNode(' :' + emoji.name + ':'));++ button.addEventListener('click', function () {++ var start = input.selectionStart, end = input.selectionEnd, value = ':' + emoji.name + ':';++ input.setRangeText(value, start, end, 'end'); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); picker.open = false;++ });++ choices.appendChild(button);++ });++ if (!choices.children.length) choices.textContent = 'No server emoji yet.';++ else {++ var report = el('a', 'ovk-btn ovk-btn--quiet', 'Report an emoji');++ report.href = 'https://openvibe.community/submissions?kind=report#submit';++ choices.appendChild(report);++ }++ }++ showChoices();++ picker.addEventListener('toggle', function () {++ if (!picker.open) return;++ fetch('/api/chat/servers/' + encodeURIComponent(cfg.serverEmojiSlug) + '/emojis', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; }).then(function (body) {++ if (!body || !Array.isArray(body.emojis)) return;++ serverEmojis = new Map(body.emojis.map(function (e) { return [String(e.name).toLowerCase(), e]; })); showChoices();++ }).catch(function () {});++ });++ form.insertBefore(picker, input);++ }var latest = Number(cfg.latest) || 0;var LIVE = 'https://openvibe.live';var viewToggle = document.getElementById('oc-view-toggle');@@@ -209,7 +260,7 @@var li = feed.querySelector('[data-id="' + Number(id) + '"]');if (!li) return;var t = li.querySelector('.oc-text, .oc-bubble');-- if (t) { t.textContent = ''; textWithLinks(t, m.message); }++ if (t) { t.textContent = ''; if (cfg.view === 'room') textWithRoomEmoji(t, m.message); else textWithLinks(t, m.message); }var mark = li.querySelector('.oc-edited');if (!mark) { mark = el('span', 'oc-muted oc-edited'); if (t) t.insertAdjacentElement('afterend', mark); else li.appendChild(mark); }mark.textContent = ' edited · ' + clock(m.edited_at).textContent;@@@ -487,7 +538,7 @@// An AI member writes Markdown: rendered by VibeMarkdown, which escapes every character first (as the server does).var t;if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }-- else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }++ else { t = el('span', 'oc-text'); textWithRoomEmoji(t, m.message); }li.appendChild(t);if (m.edited_at) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted oc-edited', 'edited · ' + clock(m.edited_at).textContent)); }if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }nnew file mode 100644@@@ -0,0 +1,30 @@++(function () {++ 'use strict';++ document.addEventListener('DOMContentLoaded', function () {++ var box = document.querySelector('[data-server-emoji-manager]');++ if (!box) return;++ var url = '/api/chat/servers/' + encodeURIComponent(box.dataset.serverEmojiManager) + '/emojis';++ var status = box.querySelector('[data-emoji-status]');++ var file = null;++ if (window.OVUpload) window.OVUpload.picker(box.querySelector('[data-emoji-picker]'), {++ accept: 'image/png,image/jpeg,image/gif,image/webp', label: 'Choose an emoji image',++ onFile: function (selected) { file = selected; }, onRemove: function (removed) { if (file === removed) file = null; }++ });++ function done(response) { return response.json().then(function (body) { if (!response.ok) throw Error(body.error || 'Could not save emoji'); return body; }); }++ box.querySelector('[data-emoji-upload]').addEventListener('submit', function (event) {++ event.preventDefault();++ var form = event.currentTarget;++ if (!file || file.size > 512 * 1024) { status.textContent = 'Choose an image up to 512 KB.'; return; }++ var button = form.querySelector('button'); button.disabled = true; status.textContent = 'Uploading…';++ var data = new FormData(form); data.append('file', file);++ fetch(url, { method: 'POST', credentials: 'same-origin', body: data }).then(done)++ .then(function () { location.reload(); }).catch(function (error) { status.textContent = error.message; button.disabled = false; });++ });++ box.addEventListener('click', function (event) {++ var button = event.target.closest('[data-emoji-remove]'); if (!button) return;++ button.disabled = true;++ fetch(url + '/' + encodeURIComponent(button.dataset.emojiRemove), { method: 'DELETE', credentials: 'same-origin' }).then(done)++ .then(function () { location.reload(); }).catch(function (error) { status.textContent = error.message; button.disabled = false; });++ });++ });++}());nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++++const NAME = /:([a-z0-9_]{2,32}):/gi;++++// `plain` must escape/linkify its input. Names not in this server remain literal text.++function render(text, emojis, plain, escape) {++ let last = 0, out = '', match;++ NAME.lastIndex = 0;++ while ((match = NAME.exec(String(text || '')))) {++ out += plain(String(text).slice(last, match.index));++ const emoji = emojis.get(match[1].toLowerCase());++ out += emoji && /^https:\/\//.test(emoji.url || '')++ ? `<img class="oc-server-emoji" src="${escape(emoji.url)}" alt="${escape(match[0])}" width="22" height="22" loading="lazy">`++ : plain(match[0]);++ last = NAME.lastIndex;++ }++ return out + plain(String(text || '').slice(last));++}++++module.exports = { render };@@@ -926,7 +926,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a<p class="oc-muted">One vote each. ${v.options.length > 1 ? `Type !1 to !${v.options.length} too.` : ''}</p>${can && can.moderate ? `<form method="post" action="/r/${esc(r.slug)}/poll/close" class="oc-inline"><input type="hidden" name="poll_id" value="${esc(v.poll_id)}"><button class="oc-pill" type="submit" name="action" value="close">Close poll</button><button class="oc-pill" type="submit" name="action" value="veto">Veto result</button></form>` : ''}</section>`;const roomCard = (r) => `<li class="oc-row${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}">${ui.initials(r.name)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`+ `<span class="oc-row-sub">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}${r.unread ? `<span class="oc-dot" aria-label="${r.unread} new">${r.unread > 9 ? '9+' : r.unread}</span>` : ''}</span></span></a></li>`;-- const roomMessage = (m, can, slug, me) => {++ const roomMessage = (m, can, slug, me, emojis = null) => {const t = hhmm(m.created_at);const ai = m.ai_member_id != null;const agent = !!m.agent_stream;@@@ -937,7 +937,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.-- const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${linkify(m.message)}</span>`;++ const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;};const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);@@@ -293,6 +293,8 @@ ${sheet}`,const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;const insight = room ? await require('../chat/room-insight').room(room, { enabled: rooms.insightOn(room) }).catch(() => null) : null;const insightHtml = insight ? insightBlock(insight) : '';++ const serverEmojis = config.emotes.serverEmojiEnabled ? await require('../db/database').all('SELECT code, media_url FROM emotes WHERE server_id = ? ORDER BY lower(code)', [server.id]) : [];++ const emojiMap = new Map(serverEmojis.map((e) => [e.code.toLowerCase(), { url: e.media_url }]));await page(req, res, 200, {actor, shell: true, footer: 'none', title: `${isVoice ? (isStage ? 'Stage · ' : '') : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,@@@ -300,6 +302,8 @@ ${sheet}`,page: {view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: roomPage.more,me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,++ serverEmojiSlug: config.emotes.serverEmojiEnabled ? server.slug : null,++ serverEmojis: serverEmojis.map((e) => ({ name: e.code, url: e.media_url })),...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),},body: shell.shell({@@@ -326,7 +330,7 @@ ${invitePanel}${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}${aiHere}${errorOf(req)}${isVoice ? callPanel(room, rcan, me) : ''}--<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>++<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, rcan, room.slug, me, emojiMap)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>${composer}`,mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,members: shell.membersColumn({ groups: roleGroups }),@@@ -481,6 +485,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-roconst canInvite = can.manage || server.visibility === 'public';const invites = canInvite ? await servers.invites(server) : [];const selfRoles = servers.selfRolesOf(server);++ const emojiRows = config.emotes.serverEmojiEnabled ? await require('../db/database').all('SELECT id, code, media_url FROM emotes WHERE server_id = ? ORDER BY lower(code)', [server.id]) : [];const myRank = can.staff ? -1 : servers.RANK[can.role];// The side column: the server's channels, with the people in its voice channels, and the you-bar.const categories = [];@@@ -524,7 +529,7 @@ ${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ?const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span><form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;await page(req, res, 200, {-- actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none',++ actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none', upload: config.emotes.serverEmojiEnabled, scripts: config.emotes.serverEmojiEnabled ? ['server-emojis.js'] : [],body: shell.shell({rail: shell.rail({ dmUnread, servers: railServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === server.slug })) }),side: shell.sideServer({@@@ -542,6 +547,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/<p><button type="submit">Save</button></p></form><h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul><form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>++${config.emotes.serverEmojiEnabled ? `<section id="oc-server-emojis" data-server-emoji-manager="${esc(server.slug)}"><h2>Emoji</h2><p class="oc-muted">${emojiRows.length} of 50 used. PNG, JPEG, GIF or WebP, up to 512 KB.</p><ul class="oc-members">${emojiRows.map((e) => `<li><img class="oc-server-emoji" src="${esc(e.media_url || '')}" alt=":${esc(e.code)}:" width="22" height="22"> <code>:${esc(e.code)}:</code> <button type="button" class="ovk-btn ovk-btn--quiet" data-emoji-remove="${Number(e.id)}">Remove</button></li>`).join('')}</ul><form data-emoji-upload><label>Name <input name="name" required pattern="[A-Za-z0-9_]{2,32}" maxlength="32"></label><div data-emoji-picker></div> <button class="ovk-btn ovk-btn--primary" type="submit">Add emoji</button></form><p role="status" data-emoji-status></p></section>` : ''}<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2><p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p><form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">nnew file mode 100644@@@ -0,0 +1,8 @@++'use strict';++const assert = require('assert');++const { render } = require('../server/chat/emoji-text');++const escape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');++const map = new Map([['party', { url: 'https://openvibe.media/chat/party.png' }]]);++assert.strictEqual(render('hi :party: :gone: <script>', map, escape, escape), 'hi <img class="oc-server-emoji" src="https://openvibe.media/chat/party.png" alt=":party:" width="22" height="22" loading="lazy"> :gone: <script>');++assert.strictEqual(render(':party:', new Map(), escape, escape), ':party:');++console.log('emoji-text: render and removed fallback passed');
@@ -807,3 +807,5 @@ div.oc-set-row { cursor: default; }
@@ -807,3 +807,5 @@ div.oc-set-row { cursor: default; }
.oc-agent-poll-vote small { color: var(--text-secondary, #96a7c2); white-space: nowrap; }
.oc-agent-poll-vote small { color: var(--text-secondary, #96a7c2); white-space: nowrap; }
.oc-kind-agent_stream { color: var(--accent, #3b82f6); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
.oc-kind-agent_stream { color: var(--accent, #3b82f6); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
@media (max-width: 480px) { .oc-agent-banner, .oc-agent-poll { padding: 9px 11px; } }
@media (max-width: 480px) { .oc-agent-banner, .oc-agent-poll { padding: 9px 11px; } }
+.oc-server-emoji { vertical-align: middle; object-fit: contain; }
+.oc-emoji-choices { display: flex; flex-wrap: wrap; max-height: 12rem; overflow: auto; }
@@ -12,6 +12,57 @@
@@ -12,6 +12,57 @@
var feed = document.getElementById('oc-feed');
var feed = document.getElementById('oc-feed');
var form = document.getElementById('oc-compose');
var form = document.getElementById('oc-compose');
var input = document.getElementById('oc-input');
var input = document.getElementById('oc-input');
+ var serverEmojis = new Map((Array.isArray(cfg.serverEmojis) ? cfg.serverEmojis : []).map(function (e) { return [String(e.name).toLowerCase(), e]; }));
+ function textWithRoomEmoji(parent, text) {
+ if (!cfg.serverEmojiSlug) { textWithLinks(parent, text); return; }
+ var raw = String(text || ''), re = /:([a-z0-9_]{2,32}):/gi, last = 0, m;
+ while ((m = re.exec(raw))) {
+ textWithLinks(parent, raw.slice(last, m.index));
+ var emoji = serverEmojis.get(m[1].toLowerCase());
+ if (emoji && /^https:\/\//.test(String(emoji.url || ''))) {
+ var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = m[0]; img.width = 22; img.height = 22; img.loading = 'lazy';
+ img.addEventListener('error', function () { this.replaceWith(document.createTextNode(this.alt)); });
+ parent.appendChild(img);
+ } else parent.appendChild(document.createTextNode(m[0]));
+ last = re.lastIndex;
+ }
+ textWithLinks(parent, raw.slice(last));
+ }
+ if (cfg.serverEmojiSlug && form && input) {
+ var picker = el('details', 'oc-emoji-picker');
+ var toggle = el('summary', 'ovk-btn ovk-btn--quiet', 'Emoji'); toggle.setAttribute('aria-label', 'Choose server emoji'); picker.appendChild(toggle);
+ var choices = el('div', 'oc-emoji-choices'); picker.appendChild(choices);
+ function showChoices() {
+ choices.replaceChildren();
+ Array.from(serverEmojis.values()).forEach(function (emoji) {
+ var button = el('button', 'ovk-btn ovk-btn--quiet'); button.type = 'button'; button.title = ':' + emoji.name + ':';
+ var img = el('img', 'oc-server-emoji'); img.src = emoji.url; img.alt = ':' + emoji.name + ':'; img.width = 22; img.height = 22;
+ img.addEventListener('error', function () { this.replaceWith(document.createTextNode(this.alt)); });
+ button.appendChild(img); button.appendChild(document.createTextNode(' :' + emoji.name + ':'));
+ button.addEventListener('click', function () {
+ var start = input.selectionStart, end = input.selectionEnd, value = ':' + emoji.name + ':';
+ input.setRangeText(value, start, end, 'end'); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); picker.open = false;
+ });
+ choices.appendChild(button);
+ });
+ if (!choices.children.length) choices.textContent = 'No server emoji yet.';
+ else {
+ var report = el('a', 'ovk-btn ovk-btn--quiet', 'Report an emoji');
+ report.href = 'https://openvibe.community/submissions?kind=report#submit';
+ choices.appendChild(report);
+ }
+ }
+ showChoices();
+ picker.addEventListener('toggle', function () {
+ if (!picker.open) return;
+ fetch('/api/chat/servers/' + encodeURIComponent(cfg.serverEmojiSlug) + '/emojis', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; }).then(function (body) {
+ if (!body || !Array.isArray(body.emojis)) return;
+ serverEmojis = new Map(body.emojis.map(function (e) { return [String(e.name).toLowerCase(), e]; })); showChoices();
+ }).catch(function () {});
+ });
+ form.insertBefore(picker, input);
+ }
var latest = Number(cfg.latest) || 0;
var latest = Number(cfg.latest) || 0;
var LIVE = 'https://openvibe.live';
var LIVE = 'https://openvibe.live';
var viewToggle = document.getElementById('oc-view-toggle');
var viewToggle = document.getElementById('oc-view-toggle');
@@ -209,7 +260,7 @@
@@ -209,7 +260,7 @@
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
var li = feed.querySelector('[data-id="' + Number(id) + '"]');
if (!li) return;
if (!li) return;
var t = li.querySelector('.oc-text, .oc-bubble');
var t = li.querySelector('.oc-text, .oc-bubble');
- if (t) { t.textContent = ''; textWithLinks(t, m.message); }
+ if (t) { t.textContent = ''; if (cfg.view === 'room') textWithRoomEmoji(t, m.message); else textWithLinks(t, m.message); }
var mark = li.querySelector('.oc-edited');
var mark = li.querySelector('.oc-edited');
if (!mark) { mark = el('span', 'oc-muted oc-edited'); if (t) t.insertAdjacentElement('afterend', mark); else li.appendChild(mark); }
if (!mark) { mark = el('span', 'oc-muted oc-edited'); if (t) t.insertAdjacentElement('afterend', mark); else li.appendChild(mark); }
mark.textContent = ' edited · ' + clock(m.edited_at).textContent;
mark.textContent = ' edited · ' + clock(m.edited_at).textContent;
@@ -487,7 +538,7 @@
@@ -487,7 +538,7 @@
// An AI member writes Markdown: rendered by VibeMarkdown, which escapes every character first (as the server does).
// An AI member writes Markdown: rendered by VibeMarkdown, which escapes every character first (as the server does).
var t;
var t;
if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }
if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }
- else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
+ else { t = el('span', 'oc-text'); textWithRoomEmoji(t, m.message); }
li.appendChild(t);
li.appendChild(t);
if (m.edited_at) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted oc-edited', 'edited · ' + clock(m.edited_at).textContent)); }
if (m.edited_at) { li.appendChild(document.createTextNode(' ')); li.appendChild(el('span', 'oc-muted oc-edited', 'edited · ' + clock(m.edited_at).textContent)); }
if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+(function () {
+ 'use strict';
+ document.addEventListener('DOMContentLoaded', function () {
+ var box = document.querySelector('[data-server-emoji-manager]');
+ if (!box) return;
+ var url = '/api/chat/servers/' + encodeURIComponent(box.dataset.serverEmojiManager) + '/emojis';
+ var status = box.querySelector('[data-emoji-status]');
+ var file = null;
+ if (window.OVUpload) window.OVUpload.picker(box.querySelector('[data-emoji-picker]'), {
+ accept: 'image/png,image/jpeg,image/gif,image/webp', label: 'Choose an emoji image',
+ onFile: function (selected) { file = selected; }, onRemove: function (removed) { if (file === removed) file = null; }
+ });
+ function done(response) { return response.json().then(function (body) { if (!response.ok) throw Error(body.error || 'Could not save emoji'); return body; }); }
+ box.querySelector('[data-emoji-upload]').addEventListener('submit', function (event) {
+ event.preventDefault();
+ var form = event.currentTarget;
+ if (!file || file.size > 512 * 1024) { status.textContent = 'Choose an image up to 512 KB.'; return; }
+ var button = form.querySelector('button'); button.disabled = true; status.textContent = 'Uploading…';
+ var data = new FormData(form); data.append('file', file);
+ fetch(url, { method: 'POST', credentials: 'same-origin', body: data }).then(done)
+ .then(function () { location.reload(); }).catch(function (error) { status.textContent = error.message; button.disabled = false; });
+ });
+ box.addEventListener('click', function (event) {
+ var button = event.target.closest('[data-emoji-remove]'); if (!button) return;
+ button.disabled = true;
+ fetch(url + '/' + encodeURIComponent(button.dataset.emojiRemove), { method: 'DELETE', credentials: 'same-origin' }).then(done)
+ .then(function () { location.reload(); }).catch(function (error) { status.textContent = error.message; button.disabled = false; });
+ });
+ });
+}());
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+
+const NAME = /:([a-z0-9_]{2,32}):/gi;
+
+// `plain` must escape/linkify its input. Names not in this server remain literal text.
+function render(text, emojis, plain, escape) {
+ let last = 0, out = '', match;
+ NAME.lastIndex = 0;
+ while ((match = NAME.exec(String(text || '')))) {
+ out += plain(String(text).slice(last, match.index));
+ const emoji = emojis.get(match[1].toLowerCase());
+ out += emoji && /^https:\/\//.test(emoji.url || '')
+ ? `<img class="oc-server-emoji" src="${escape(emoji.url)}" alt="${escape(match[0])}" width="22" height="22" loading="lazy">`
+ : plain(match[0]);
+ last = NAME.lastIndex;
+ }
+ return out + plain(String(text || '').slice(last));
+}
+
+module.exports = { render };
@@ -926,7 +926,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -926,7 +926,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
<p class="oc-muted">One vote each. ${v.options.length > 1 ? `Type !1 to !${v.options.length} too.` : ''}</p>${can && can.moderate ? `<form method="post" action="/r/${esc(r.slug)}/poll/close" class="oc-inline"><input type="hidden" name="poll_id" value="${esc(v.poll_id)}"><button class="oc-pill" type="submit" name="action" value="close">Close poll</button><button class="oc-pill" type="submit" name="action" value="veto">Veto result</button></form>` : ''}</section>`;
<p class="oc-muted">One vote each. ${v.options.length > 1 ? `Type !1 to !${v.options.length} too.` : ''}</p>${can && can.moderate ? `<form method="post" action="/r/${esc(r.slug)}/poll/close" class="oc-inline"><input type="hidden" name="poll_id" value="${esc(v.poll_id)}"><button class="oc-pill" type="submit" name="action" value="close">Close poll</button><button class="oc-pill" type="submit" name="action" value="veto">Veto result</button></form>` : ''}</section>`;
const roomCard = (r) => `<li class="oc-row${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}">${ui.initials(r.name)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
const roomCard = (r) => `<li class="oc-row${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}">${ui.initials(r.name)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.name)}</strong>${kindBadge(r)}${r.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
+ `<span class="oc-row-sub">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}${r.unread ? `<span class="oc-dot" aria-label="${r.unread} new">${r.unread > 9 ? '9+' : r.unread}</span>` : ''}</span></span></a></li>`;
+ `<span class="oc-row-sub">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}${r.unread ? `<span class="oc-dot" aria-label="${r.unread} new">${r.unread > 9 ? '9+' : r.unread}</span>` : ''}</span></span></a></li>`;
- const roomMessage = (m, can, slug, me) => {
+ const roomMessage = (m, can, slug, me, emojis = null) => {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const ai = m.ai_member_id != null;
const ai = m.ai_member_id != null;
const agent = !!m.agent_stream;
const agent = !!m.agent_stream;
@@ -937,7 +937,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -937,7 +937,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
// An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
- const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${linkify(m.message)}</span>`;
+ const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;
return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${ui.messageCards(m)}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
};
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -293,6 +293,8 @@ ${sheet}`,
@@ -293,6 +293,8 @@ ${sheet}`,
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
const insight = room ? await require('../chat/room-insight').room(room, { enabled: rooms.insightOn(room) }).catch(() => null) : null;
const insight = room ? await require('../chat/room-insight').room(room, { enabled: rooms.insightOn(room) }).catch(() => null) : null;
const insightHtml = insight ? insightBlock(insight) : '';
const insightHtml = insight ? insightBlock(insight) : '';
+ const serverEmojis = config.emotes.serverEmojiEnabled ? await require('../db/database').all('SELECT code, media_url FROM emotes WHERE server_id = ? ORDER BY lower(code)', [server.id]) : [];
+ const emojiMap = new Map(serverEmojis.map((e) => [e.code.toLowerCase(), { url: e.media_url }]));
await page(req, res, 200, {
await page(req, res, 200, {
actor, shell: true, footer: 'none', title: `${isVoice ? (isStage ? 'Stage · ' : '') : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
actor, shell: true, footer: 'none', title: `${isVoice ? (isStage ? 'Stage · ' : '') : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
@@ -300,6 +302,8 @@ ${sheet}`,
@@ -300,6 +302,8 @@ ${sheet}`,
page: {
page: {
view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: roomPage.more,
view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, historyMore: roomPage.more,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
+ serverEmojiSlug: config.emotes.serverEmojiEnabled ? server.slug : null,
+ serverEmojis: serverEmojis.map((e) => ({ name: e.code, url: e.media_url })),
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
},
},
body: shell.shell({
body: shell.shell({
@@ -326,7 +330,7 @@ ${invitePanel}
@@ -326,7 +330,7 @@ ${invitePanel}
${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}
${isStage ? `<section class="oc-stage" aria-label="Stage"><h2>${esc(room.topic || 'Stage')}</h2><p>Stage audio uses this server voice channel. Audience members join muted.</p><div id="oc-stage-speakers">Speakers and raised hands load when the stage is live.</div></section>` : ''}
${aiHere}${errorOf(req)}
${aiHere}${errorOf(req)}
${isVoice ? callPanel(room, rcan, me) : ''}
${isVoice ? callPanel(room, rcan, me) : ''}
-<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
+<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}"><li id="oc-history-state" class="oc-muted" hidden aria-live="polite"></li>${msgs.map((m) => roomMessage(m, rcan, room.slug, me, emojiMap)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
${composer}`,
${composer}`,
mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,
mainLabel: `${isVoice ? (isStage ? 'Stage: ' : '') : '#'}${room.name}`,
members: shell.membersColumn({ groups: roleGroups }),
members: shell.membersColumn({ groups: roleGroups }),
@@ -481,6 +485,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
@@ -481,6 +485,7 @@ ${roles.length ? `<fieldset class="oc-set oc-role-set"><legend id="oc-onboard-ro
const canInvite = can.manage || server.visibility === 'public';
const canInvite = can.manage || server.visibility === 'public';
const invites = canInvite ? await servers.invites(server) : [];
const invites = canInvite ? await servers.invites(server) : [];
const selfRoles = servers.selfRolesOf(server);
const selfRoles = servers.selfRolesOf(server);
+ const emojiRows = config.emotes.serverEmojiEnabled ? await require('../db/database').all('SELECT id, code, media_url FROM emotes WHERE server_id = ? ORDER BY lower(code)', [server.id]) : [];
const myRank = can.staff ? -1 : servers.RANK[can.role];
const myRank = can.staff ? -1 : servers.RANK[can.role];
// The side column: the server's channels, with the people in its voice channels, and the you-bar.
// The side column: the server's channels, with the people in its voice channels, and the you-bar.
const categories = [];
const categories = [];
@@ -524,7 +529,7 @@ ${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ?
@@ -524,7 +529,7 @@ ${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ?
const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none',
+ actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', shell: true, footer: 'none', upload: config.emotes.serverEmojiEnabled, scripts: config.emotes.serverEmojiEnabled ? ['server-emojis.js'] : [],
body: shell.shell({
body: shell.shell({
rail: shell.rail({ dmUnread, servers: railServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === server.slug })) }),
rail: shell.rail({ dmUnread, servers: railServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === server.slug })) }),
side: shell.sideServer({
side: shell.sideServer({
@@ -542,6 +547,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
@@ -542,6 +547,7 @@ ${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/
<p><button type="submit">Save</button></p></form>
<p><button type="submit">Save</button></p></form>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<option value="forum">Forum</option>' : ''}${require('../settings').value('stage_channels_enabled', null, false) === true ? '<option value="stage">Stage</option>' : ''}</select>${require('../settings').value('forum_channels_enabled', null, false) === true ? '<label>Community forum slug <input name="community_space" maxlength="40" placeholder="my-forum"></label>' : ''} <button type="submit">Add</button></form>
+${config.emotes.serverEmojiEnabled ? `<section id="oc-server-emojis" data-server-emoji-manager="${esc(server.slug)}"><h2>Emoji</h2><p class="oc-muted">${emojiRows.length} of 50 used. PNG, JPEG, GIF or WebP, up to 512 KB.</p><ul class="oc-members">${emojiRows.map((e) => `<li><img class="oc-server-emoji" src="${esc(e.media_url || '')}" alt=":${esc(e.code)}:" width="22" height="22"> <code>:${esc(e.code)}:</code> <button type="button" class="ovk-btn ovk-btn--quiet" data-emoji-remove="${Number(e.id)}">Remove</button></li>`).join('')}</ul><form data-emoji-upload><label>Name <input name="name" required pattern="[A-Za-z0-9_]{2,32}" maxlength="32"></label><div data-emoji-picker></div> <button class="ovk-btn ovk-btn--primary" type="submit">Add emoji</button></form><p role="status" data-emoji-status></p></section>` : ''}
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<section class="oc-set" id="oc-onboarding" aria-labelledby="oc-onb-h"><h2 id="oc-onb-h">Onboarding</h2>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<p class="oc-muted">People who join read the rules, if there are any, then pick their roles.</p>
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
<form class="oc-form" method="post" action="/s/${esc(server.slug)}/onboarding">
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+'use strict';
+const assert = require('assert');
+const { render } = require('../server/chat/emoji-text');
+const escape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
+const map = new Map([['party', { url: 'https://openvibe.media/chat/party.png' }]]);
+assert.strictEqual(render('hi :party: :gone: <script>', map, escape, escape), 'hi <img class="oc-server-emoji" src="https://openvibe.media/chat/party.png" alt=":party:" width="22" height="22" loading="lazy"> :gone: <script>');
+assert.strictEqual(render(':party:', new Map(), escape, escape), ':party:');
+console.log('emoji-text: render and removed fallback passed');