openvibers/openvibe.live Commits

Chat: the emote and GIF pickers close (a ×, any tap outside them, Escape; the phone studio closes one when it takes over); a phone sent no click for a tap on a chat line, so they stayed open. A page that never loaded emotes loads them when the picker opens (#176)

OP OpenVibers committed 27 days ago · 72486c07eb · 5 files, +119 −11

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex d53284f3..7f717d9d 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -4187,6 +4187,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.emote-tab-btn.active {
color: var(--accent); border-bottom-color: var(--accent);
}
++.emote-picker-close {
++ flex: 0 0 auto; min-width: 44px; padding: 5px 12px; background: transparent; border: none;
++ border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 1rem; cursor: pointer;
++}
++.emote-picker-close:hover, .emote-picker-close:focus-visible { color: var(--text-primary); }
++.gif-picker-close { float: right; min-width: 36px; padding: 2px 8px; }
.emote-picker-grid {
display: grid;
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 56e70dbc..4b61fbf3 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -199,6 +199,8 @@
document.body.append(st.root);
// The overlay chat replaces the phone chat sheet.
try { if (typeof _bcMobileChatOpen !== 'undefined' && _bcMobileChatOpen && typeof toggleBroadcastMobileChat === 'function') toggleBroadcastMobileChat(); } catch { /* not open */ }
++ // The studio chat has no emote or GIF buttons: a picker opened before it would have no way to close.
++ try { if (typeof closeEmotePicker === 'function') closeEmotePicker(); if (typeof closeGifPicker === 'function') closeGifPicker(); } catch { /* none open */ }
const v = $('bc-video-preview');
if (v) v.play().catch(() => {});
const msgs = $('bc-chat-messages');
ddiff --git a/public/js/emotes.js b/public/js/emotes.js
iindex eb72401d..4b3f34ad 100644
---- a/public/js/emotes.js
++++ b/public/js/emotes.js
@@@ -277,7 +277,8 @@ function _buildEmoteMenuTabs(picker) {
if (!tabs) return;
tabs.innerHTML = `
<button class="emote-tab-btn ${_emoteMenuMode === 'emotes' ? 'active' : ''}" data-tab="emotes" onclick="renderEmotePicker('emotes')"><i class="fa-solid fa-face-smile"></i> Emotes</button>
-- <button class="emote-tab-btn ${_emoteMenuMode === 'gif' ? 'active' : ''}" data-tab="gif" onclick="renderEmotePicker('gif')"><i class="fa-solid fa-film"></i> GIF</button>`;
++ <button class="emote-tab-btn ${_emoteMenuMode === 'gif' ? 'active' : ''}" data-tab="gif" onclick="renderEmotePicker('gif')"><i class="fa-solid fa-film"></i> GIF</button>
++ <button class="emote-picker-close" type="button" onclick="closeEmotePicker()" aria-label="Close emotes" title="Close"><i class="fa-solid fa-xmark"></i></button>`;
const searchEl = picker.querySelector('.emote-search');
if (searchEl) searchEl.placeholder = 'Search emotes or GIFs…';
}
@@@ -302,6 +303,8 @@ function toggleEmotePicker(inputId) {
_buildEmoteMenuTabs(picker);
// Lazy-load this channel's sound commands the first time the menu opens for it.
const _sid = (typeof chatStreamId !== 'undefined') ? chatStreamId : 0;
++ // A chat whose page never loaded the emotes (the broadcast studio, an offline channel): load them now.
++ if (!emotesLoaded && typeof loadEmotes === 'function') Promise.resolve(loadEmotes(_sid)).then(_refreshOpenEmotePicker).catch(() => {});
if (typeof loadChannelSounds === 'function' && _soundsLoadContext !== String(_sid || 0)) {
loadChannelSounds(_sid).then(_refreshOpenEmotePicker);
}
@@@ -338,7 +341,7 @@ function renderEmotePicker(mode) {
if (cat.key === 'channel') { html += soundsHtml; soundsInserted = true; }
}
if (!soundsInserted) html += soundsHtml;
-- grid.innerHTML = html || '<div class="emote-picker-empty">No emotes found</div>';
++ grid.innerHTML = html || `<div class="emote-picker-empty">${emotesLoaded || query ? 'No emotes found' : 'Loading emotes…'}</div>`;
}
/** Render the "Channel Sounds" section: click a tile to send its !command. */
@@@ -577,14 +580,20 @@ async function deleteDashEmote(id) {
} catch (e) { toast(e.message, 'error'); }
}
--/* ── Close picker on outside click ────────────────────────────── */
--document.addEventListener('click', (e) => {
-- if (!_emotePickerOpen) return;
-- if (e.target.closest('.emote-picker') || e.target.closest('.emote-picker-btn')) return;
++/* ── Closing the picker ───────────────────────────────────────── */
++/** Close every emote picker (main / broadcast / offline / global): its ×, a tap outside it, Escape, the phone studio. */
++function closeEmotePicker() {
_emotePickerOpen = false;
-- // Close all four pickers (main / broadcast / offline / global).
['emote-picker', 'bc-emote-picker', 'oc-emote-picker', 'gc-emote-picker'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
--});
++}
++// pointerdown, not click: a phone sends no click for a tap on plain text (chat lines), so the picker stayed open there.
++document.addEventListener('pointerdown', (e) => {
++ if (!_emotePickerOpen) return;
++ const t = e.target && e.target.closest ? e.target : null;
++ if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn'))) return;
++ closeEmotePicker();
++}, true);
++document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });
ddiff --git a/public/js/gif-picker.js b/public/js/gif-picker.js
iindex d3784bcd..8b3c7814 100644
---- a/public/js/gif-picker.js
++++ b/public/js/gif-picker.js
@@@ -65,6 +65,7 @@ function _getGifPicker() {
picker.style.display = 'none';
picker.innerHTML = `
<div class="gif-picker-header">
++ <button class="emote-picker-close gif-picker-close" type="button" onclick="closeGifPicker()" aria-label="Close GIFs" title="Close"><i class="fa-solid fa-xmark"></i></button>
<div class="gif-picker-provider-row"></div>
<input type="text" class="gif-search" placeholder="Search GIFs..." oninput="onGifSearch(this.value)">
</div>
@@@ -79,11 +80,14 @@ function _getGifPicker() {
// rebuild used to add another pair of document listeners.
if (!_gifDocListeners) {
_gifDocListeners = true;
-- document.addEventListener('click', (event) => {
++ // pointerdown, not click: a phone sends no click for a tap on plain text (chat lines). The GIF buttons toggle it
++ // themselves, so a tap on one is left to them.
++ document.addEventListener('pointerdown', (event) => {
if (!_gifPickerOpen || !_gifPickerEl) return;
-- if (_gifPickerEl.contains(event.target)) return;
++ const t = event.target;
++ if (_gifPickerEl.contains(t) || (t && t.closest && t.closest('.gif-btn'))) return;
closeGifPicker();
-- });
++ }, true);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && _gifPickerOpen) closeGifPicker();
});
ddiff --git a/test/emote-picker-close.test.js b/test/emote-picker-close.test.js
nnew file mode 100644
iindex 00000000..a843f44d
---- /dev/null
++++ b/test/emote-picker-close.test.js
@@@ -0,0 +1,87 @@
++/**
++ * The chat's emote picker closes (owner 2026-10-17, from a phone: "this ui sucks i cant close it stuck open"). The studio
++ * hides the chat's emote button (the picker's only toggle), and a phone sends no click for a tap on plain text, so the
++ * outside-click close never ran. Now: a × in the picker, any tap outside it (pointerdown), Escape, and the studio closes
++ * a picker when it takes over. A page that never loaded the emotes loads them when the picker opens ("Loading emotes…",
++ * not "No emotes found").
++ *
++ * node test/emote-picker-close.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'emotes.js'), 'utf8');
++
++function el(id, cls = '') {
++ const node = {
++ id, className: cls, style: { display: 'none' }, dataset: {}, innerHTML: '', value: '', placeholder: '', children: [],
++ classList: { toggle() {}, add() {}, remove() {}, contains: () => false },
++ querySelector: (sel) => (sel === '.emote-picker-tabs' ? node.tabs : sel === '.emote-search' ? node.search : null),
++ querySelectorAll: () => [],
++ closest: (sel) => (node.within || []).find((x) => x.sel === sel)?.node || null,
++ };
++ return node;
++}
++
++const listeners = {};
++const picker = el('bc-emote-picker', 'emote-picker');
++picker.tabs = el('tabs');
++picker.search = el('search');
++const grid = el('bc-emote-picker-grid');
++const els = { 'bc-emote-picker': picker, 'bc-emote-picker-grid': grid };
++let loads = 0;
++const ctx = {
++ document: {
++ addEventListener: (type, fn) => { listeners[type] = fn; },
++ getElementById: (id) => els[id] || null,
++ querySelector: (sel) => (sel === '#bc-emote-picker .emote-search' ? picker.search : null),
++ },
++ window: {}, console, setTimeout, Promise, Map, Set,
++ api: async () => { loads++; return { defaults: [{ id: 'd1', code: 'LUL', url: 'https://x/1.png' }] }; },
++};
++vm.createContext(ctx);
++vm.runInContext(`${src}\nthis.toggleEmotePicker = toggleEmotePicker; this.closeEmotePicker = closeEmotePicker; this.state = () => ({ open: _emotePickerOpen, loaded: emotesLoaded });`, ctx);
++
++(async () => {
++ // Opening on a page that never loaded the emotes: it says so while they load, then shows them.
++ ctx.toggleEmotePicker('bc-chat-input');
++ assert.strictEqual(picker.style.display, 'flex');
++ assert.match(picker.tabs.innerHTML, /class="emote-picker-close"[^>]*onclick="closeEmotePicker\(\)"[^>]*aria-label="Close emotes"/, 'a × to close it');
++ assert.match(grid.innerHTML, /Loading emotes…/);
++ await new Promise((r) => setTimeout(r, 10));
++ assert.strictEqual(loads, 1, 'the emotes load when the picker opens');
++ assert.ok(ctx.state().loaded);
++
++ // A tap inside the picker (or on its button) keeps it open; a tap anywhere else closes it.
++ const inside = { closest: (sel) => (sel === '.emote-picker' ? picker : null) };
++ listeners.pointerdown({ target: inside });
++ assert.strictEqual(picker.style.display, 'flex');
++ const onButton = { closest: (sel) => (sel === '.emote-picker-btn' ? {} : null) };
++ listeners.pointerdown({ target: onButton });
++ assert.strictEqual(picker.style.display, 'flex');
++ listeners.pointerdown({ target: { closest: () => null } }); // a chat line
++ assert.strictEqual(picker.style.display, 'none', 'a tap on a chat line closes it');
++ assert.strictEqual(ctx.state().open, false);
++
++ // Escape closes it; so does its × (closeEmotePicker, also what the phone studio calls when it takes over).
++ ctx.toggleEmotePicker('bc-chat-input');
++ assert.strictEqual(picker.style.display, 'flex');
++ listeners.keydown({ key: 'Escape' });
++ assert.strictEqual(picker.style.display, 'none');
++ ctx.toggleEmotePicker('bc-chat-input');
++ ctx.closeEmotePicker();
++ assert.strictEqual(picker.style.display, 'none');
++ assert.strictEqual(ctx.state().open, false);
++
++ const studio = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-studio.js'), 'utf8');
++ assert.match(studio.slice(studio.indexOf('function mount()'), studio.indexOf('function unmount()')), /closeEmotePicker\(\)/, 'the studio closes a picker when it mounts');
++ // The GIF picker the chat's GIF button opens: the same rules (a ×, any tap outside, its own button left to toggle it).
++ const gif = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'gif-picker.js'), 'utf8');
++ assert.match(gif, /class="emote-picker-close gif-picker-close"[^>]*onclick="closeGifPicker\(\)"/);
++ assert.match(gif, /document\.addEventListener\('pointerdown'[\s\S]{0,260}closest\('\.gif-btn'\)/);
++ assert.ok(!/document\.addEventListener\('click'/.test(gif), 'no click-only close left');
++ console.log('emote picker close: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });