openvibers/openvibe.chat Commits

Render and manage server emoji in Chat (#515)

OP OpenVibers committed 3 days ago · b436c96330 · 7 files, +123 −6

* Render and manage server emoji in Chat

* Route emoji reports to Community submissions

* Use shared upload picker for server emoji

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex ffa2aaf..7b70800 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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; }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 8fe343a..d6f3d40 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -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); }
ddiff --git a/public/web/server-emojis.js b/public/web/server-emojis.js
nnew file mode 100644
iindex 0000000..8ea047b
---- /dev/null
++++ b/public/web/server-emojis.js
@@@ -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; });
++ });
++ });
++}());
ddiff --git a/server/chat/emoji-text.js b/server/chat/emoji-text.js
nnew file mode 100644
iindex 0000000..16e1ebd
---- /dev/null
++++ b/server/chat/emoji-text.js
@@@ -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 };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex f9c11b3..28ef76e 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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);
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 48be08c..72aab6a 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -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-ro
const 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">
ddiff --git a/test/emoji-text.test.js b/test/emoji-text.test.js
nnew file mode 100644
iindex 0000000..dadddbe
---- /dev/null
++++ b/test/emoji-text.test.js
@@@ -0,0 +1,8 @@
++'use strict';
++const assert = require('assert');
++const { render } = require('../server/chat/emoji-text');
++const escape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
++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: &lt;script>');
++assert.strictEqual(render(':party:', new Map(), escape, escape), ':party:');
++console.log('emoji-text: render and removed fallback passed');