openvibers/openvibe.live Commits

Channel chat resizes in every layout, with a mouse, a finger or the keyboard; deploy cards in chat fold to one line.

OP OpenVibers committed 29 Sept 2026 · 2b5df7c616 · 5 files, +156 −62

public/js/channel-chat-resize.js (channel feature) replaces the mouse-only handle that only the live layout had. Each chat gets one grip: beside the stream (1181px+, and the landscape overlay at 1180px and below) it is on the chat's left edge and sets the width; as the portrait bottom sheet it is the handle at the top and sets the height, and letting go most of the way down closes the sheet. Pointer events (mouse, touch, pen) with pointer capture; double-click or double-tap for the default size; arrow keys move it 24px and Enter resets (role=separator, focusable). Each layout remembers its own size (openvibe_chat_width, openvibe_chat_overlay_w, openvibe_chat_sheet_h), applied as --chat-w / --chat-ow / --chat-sheet-h on #page-channel and re-clamped when the window changes. The offline chat can be resized too now. Video and iframes ignore the pointer while dragging.

Deploy notices ("🚀 8 updates shipped") start as one line with a chevron; tapping it opens the commit list and patch notes link. A live card keeps its "Reload to update" button visible. They no longer fill most of the chat.

npm test 150/151 (deploy-wrapper timing flake under a parallel run; passes alone), 1 skipped.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 4f7fe21e..906f01e4 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1464,8 +1464,7 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* ── Hide / show the chat (desktop) ─────────────────────────── */
.chat-header .ch-chat-collapse { margin-right: 2px; }
@media (min-width: 769px) {
-- #page-channel.chat-collapsed .stream-layout > .chat-sidebar,
-- #page-channel.chat-collapsed .chat-resize-handle { display: none !important; }
++ #page-channel.chat-collapsed .stream-layout > .chat-sidebar { display: none !important; }
#page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: chChatReveal .32s cubic-bezier(.22,1,.36,1); }
}
@keyframes chChatReveal { from { opacity: 0; transform: translateX(36px); } to { opacity: 1; transform: none; } }
@@@ -1482,3 +1481,31 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
.ch-chat-reopen[hidden] { display: none; }
@media (max-width: 768px) { .ch-chat-collapse, .ch-chat-reopen { display: none !important; } }
@media (prefers-reduced-motion: reduce) { #page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: none; } }
++
++/* ── Resizing the chat (public/js/channel-chat-resize.js) ─────
++ One grip per chat: on its left edge beside the stream (width), on its top edge as a bottom sheet (height). */
++#page-channel .stream-layout > .chat-sidebar { width: var(--chat-w); }
++@media (max-width: 1180px) and (orientation: landscape) { #page-channel .stream-layout > .chat-sidebar { width: var(--chat-ow, min(420px, 44vw)); } }
++@media (max-width: 1180px) and (orientation: portrait) { #page-channel .stream-layout > .chat-sidebar { width: 100%; height: var(--chat-sheet-h, 55dvh); max-height: 92dvh; } }
++#page-channel.chat-resizing, #page-channel.chat-resizing * { user-select: none; }
++#page-channel.chat-resizing .chat-sidebar { transition: none !important; }
++#page-channel.chat-resizing iframe, #page-channel.chat-resizing video { pointer-events: none; }
++.chat-grip { position: absolute; z-index: 30; touch-action: none; outline: none; }
++.chat-grip::before, .chat-grip::after { content: ''; position: absolute; pointer-events: none; transition: opacity .15s, background-color .15s; }
++@media (min-width: 1181px), (max-width: 1180px) and (orientation: landscape) {
++ .chat-grip { top: 0; bottom: 0; left: -6px; width: 12px; cursor: col-resize; }
++ .chat-grip::before { left: 5px; top: 0; bottom: 0; width: 2px; background: var(--accent); opacity: 0; }
++ .chat-grip::after { left: 4px; top: 50%; width: 4px; height: 44px; margin-top: -22px; border-radius: 4px; background: color-mix(in srgb, var(--text-muted) 60%, transparent); opacity: 0; }
++ .chat-sidebar:hover > .chat-grip::after { opacity: .75; }
++ .chat-grip:hover::before, .chat-grip.dragging::before, .chat-grip:focus-visible::before { opacity: .55; }
++ .chat-grip:hover::after, .chat-grip.dragging::after, .chat-grip:focus-visible::after { opacity: 1; background: var(--accent); }
++}
++@media (max-width: 1180px) and (orientation: portrait) {
++ .chat-grip { top: 0; left: 0; right: 0; height: 24px; cursor: row-resize; }
++ .chat-grip::after { left: 50%; top: 8px; width: 44px; height: 5px; margin-left: -22px; border-radius: 5px; background: color-mix(in srgb, var(--text-muted) 60%, transparent); }
++ .chat-grip:hover::after, .chat-grip.dragging::after, .chat-grip:focus-visible::after { background: var(--accent); }
++ /* The grip draws the sheet's handle now (the header's own pill was only a picture of one). */
++ #page-channel .chat-sidebar .chat-header::before { display: none; }
++ #page-channel .chat-sidebar .chat-header { padding-top: 18px; }
++}
++@media (hover: none) { .chat-sidebar > .chat-grip::after { opacity: .75; } }
ddiff --git a/public/features.json b/public/features.json
iindex d23cd188..496b09e8 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -117,6 +117,7 @@
],
"js": [
"/js/channel-socials.js",
++ "/js/channel-chat-resize.js",
"/js/app-channel.js"
]
},
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex d516d31b..67d84b78 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -142,7 +142,6 @@
<div class="ch-weather-widget ch-weather-full" id="ch-weather-widget" style="display:none"></div>
</div>
<!-- Chat Resize Handle -->
-- <div class="chat-resize-handle" id="chat-resize-handle" title="Drag to resize chat"></div>
<!-- Chat Sidebar -->
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-header">
ddiff --git a/public/js/channel-chat-resize.js b/public/js/channel-chat-resize.js
nnew file mode 100644
iindex 00000000..6dc423a9
---- /dev/null
++++ b/public/js/channel-chat-resize.js
@@@ -0,0 +1,115 @@
++/*
++ OpenVibe.Live — resize the channel page's chat with a mouse, a finger, a pen or the keyboard, in each layout it has:
++
++ side 1181px and wider: the column beside the stream; the grip on its left edge sets the width
++ overlay 1180px and narrower, landscape: the panel over the right side; the same grip sets its width
++ sheet 1180px and narrower, portrait: the bottom sheet; the grip on its top edge sets the height, and
++ letting go most of the way down closes the sheet
++
++ Double-click or double-tap a grip (or press Enter on it) for the default size; arrow keys move it 24px.
++ Each layout remembers its own size (localStorage), applied as CSS variables on #page-channel, which
++ public/css/features/channel.css reads: --chat-w, --chat-ow, --chat-sheet-h.
++*/
++(function () {
++ 'use strict';
++ const WIDE = window.matchMedia('(min-width: 1181px)');
++ const LAND = window.matchMedia('(orientation: landscape)');
++ const MODES = {
++ side: { key: 'openvibe_chat_width', prop: '--chat-w', axis: 'x', min: 260, max: () => Math.min(window.innerWidth * 0.5, 760) },
++ overlay: { key: 'openvibe_chat_overlay_w', prop: '--chat-ow', axis: 'x', min: 260, max: () => window.innerWidth * 0.7 },
++ sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => window.innerHeight * 0.92 },
++ };
++ const page = () => document.getElementById('page-channel');
++ const modeOf = () => (WIDE.matches ? 'side' : LAND.matches ? 'overlay' : 'sheet');
++ const clamp = (v, a, b) => Math.round(Math.max(a, Math.min(b, v)));
++ const read = (k) => { try { return parseInt(localStorage.getItem(k), 10) || 0; } catch { return 0; } };
++ const write = (k, v) => { try { if (v) localStorage.setItem(k, String(Math.round(v))); else localStorage.removeItem(k); } catch { /* private mode */ } };
++
++ function setSize(mode, v) {
++ const p = page(); const m = MODES[mode];
++ if (!p) return;
++ if (v) p.style.setProperty(m.prop, `${clamp(v, m.min, m.max())}px`); else p.style.removeProperty(m.prop);
++ }
++ function applySaved() { for (const mode of Object.keys(MODES)) setSize(mode, read(MODES[mode].key)); }
++ function settle() { window.dispatchEvent(new Event('resize')); } // the player and the diagrams re-measure
++
++ function reset(mode) { write(MODES[mode].key, 0); setSize(mode, 0); settle(); }
++
++ function bind(sidebar) {
++ if (!sidebar || sidebar._ovGrip) return;
++ const g = document.createElement('div');
++ g.className = 'chat-grip';
++ g.setAttribute('role', 'separator');
++ g.setAttribute('aria-label', 'Resize chat');
++ g.tabIndex = 0;
++ g.title = 'Drag to resize the chat · double-click for the default size';
++ sidebar.prepend(g);
++ sidebar._ovGrip = g;
++
++ let drag = null, lastTap = 0;
++ g.addEventListener('pointerdown', (e) => {
++ if (e.pointerType === 'mouse' && e.button !== 0) return;
++ const mode = modeOf(); const r = sidebar.getBoundingClientRect();
++ drag = { mode, id: e.pointerId, x: e.clientX, y: e.clientY, from: MODES[mode].axis === 'x' ? r.width : r.height, cur: 0, moved: false };
++ try { g.setPointerCapture(e.pointerId); } catch { /* */ }
++ g.classList.add('dragging');
++ page()?.classList.add('chat-resizing');
++ e.preventDefault();
++ });
++ g.addEventListener('pointermove', (e) => {
++ if (!drag || e.pointerId !== drag.id) return;
++ const m = MODES[drag.mode];
++ const delta = m.axis === 'x' ? drag.x - e.clientX : drag.y - e.clientY; // the chat sits right / at the bottom
++ if (Math.abs(delta) > 3) drag.moved = true;
++ // The sheet may go below its minimum while dragging: letting go there closes it.
++ drag.cur = Math.round(Math.max(drag.mode === 'sheet' ? 60 : m.min, Math.min(m.max(), drag.from + delta)));
++ const p = page(); if (p) p.style.setProperty(m.prop, `${drag.cur}px`);
++ });
++ const end = (e) => {
++ if (!drag || (e && e.pointerId !== drag.id)) return;
++ const d = drag; drag = null;
++ g.classList.remove('dragging');
++ page()?.classList.remove('chat-resizing');
++ if (!d.moved) {
++ // A tap: two in a row restore the default size.
++ const now = Date.now();
++ if (now - lastTap < 320) { lastTap = 0; reset(d.mode); } else lastTap = now;
++ return;
++ }
++ const m = MODES[d.mode];
++ if (d.mode === 'sheet' && d.cur < m.min) {
++ setSize('sheet', read(m.key)); // keep the last real height for next time
++ const s = document.querySelector('#page-channel .chat-sidebar.mobile-chat-open');
++ if (s && typeof window.toggleMobileChat === 'function') window.toggleMobileChat();
++ return;
++ }
++ write(m.key, clamp(d.cur, m.min, m.max()));
++ settle();
++ };
++ g.addEventListener('pointerup', end);
++ g.addEventListener('pointercancel', end);
++ g.addEventListener('lostpointercapture', end);
++ g.addEventListener('dblclick', () => reset(modeOf()));
++ g.addEventListener('keydown', (e) => {
++ const mode = modeOf(); const m = MODES[mode];
++ if (e.key === 'Enter') { e.preventDefault(); reset(mode); return; }
++ const grow = m.axis === 'x' ? { ArrowLeft: 1, ArrowRight: -1 } : { ArrowUp: 1, ArrowDown: -1 };
++ if (!(e.key in grow)) return;
++ e.preventDefault();
++ const r = sidebar.getBoundingClientRect();
++ const v = clamp((m.axis === 'x' ? r.width : r.height) + grow[e.key] * 24, m.min, m.max());
++ setSize(mode, v); write(m.key, v); settle();
++ });
++ }
++
++ function init() {
++ applySaved();
++ document.querySelectorAll('#page-channel .stream-layout > .chat-sidebar').forEach(bind);
++ }
++ init();
++ document.addEventListener('ov:fragment', init);
++ // A size saved on a wide window can be too wide for a smaller one: re-clamp when the window changes.
++ let t = 0;
++ window.addEventListener('resize', () => { clearTimeout(t); t = setTimeout(applySaved, 150); });
++ window.ChannelChatResize = { init, reset: () => reset(modeOf()) };
++})();
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex caf242a6..f7c77041 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3952,8 +3952,9 @@ function _deployCss() {
if (document.getElementById('chat-deploy-css')) return;
const st = document.createElement('style'); st.id = 'chat-deploy-css';
st.textContent = `.chat-msg.system.chat-deploy,.chat-msg.system.chat-deploy *{font-style:normal}
--.chat-deploy{margin:8px 0;padding:10px 12px;border-radius:12px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 28%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 6%,transparent);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.45;container-type:inline-size}
--.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;color:var(--text-primary,#e6edf7)}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}
++.chat-deploy{margin:6px 0;padding:7px 10px;border-radius:12px;border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 28%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 6%,transparent);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.45;container-type:inline-size}
++.chat-deploy-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;width:100%;margin:0;padding:0;border:0;background:none;font:inherit;text-align:left;cursor:pointer;color:var(--text-primary,#e6edf7)}.chat-deploy-h::after{content:'▾';margin-left:auto;align-self:center;font-size:.7rem;color:var(--text-muted,#7d8aa0);transition:transform .2s}.chat-deploy:not(.is-collapsed) .chat-deploy-h::after{transform:rotate(180deg)}.chat-deploy-h:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:3px;border-radius:6px}
++.chat-deploy.is-collapsed ul,.chat-deploy.is-collapsed .chat-deploy-more,.chat-deploy.is-collapsed .chat-deploy-f a{display:none}.chat-deploy.is-collapsed .chat-deploy-f{margin-top:6px;padding-top:0;border-top:0}.chat-deploy.is-collapsed .chat-deploy-f:not(:has(.chat-deploy-reload)){display:none}.chat-deploy-h b{font-weight:700}.chat-deploy-t{font-size:.76rem;color:var(--text-muted,#7d8aa0)}
.chat-deploy ul{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px}
.chat-deploy li{display:grid;grid-template-columns:4.6em minmax(0,1fr);gap:8px;align-items:baseline}.chat-deploy li.is-hidden{display:none}.chat-deploy.is-open li.is-hidden{display:grid}
.chat-deploy li a{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;color:var(--accent-light,var(--accent,#60a5fa));text-decoration:none}
@@@ -3986,14 +3987,19 @@ function renderDeployCard(data, opts = {}) {
const id = data.id != null ? String(data.id) : '';
let el = id ? container.querySelector('.chat-deploy[data-deploy-id="' + id.replace(/[^\w-]/g, '') + '"]') : null;
const wasOpen = el && el.classList.contains('is-open');
++ // One line until opened: a run of deploys is context, not the conversation (the list and notes are a tap away).
++ const wasExpanded = el && !el.classList.contains('is-collapsed');
if (!el) { el = document.createElement('div'); el.className = 'chat-msg system chat-deploy'; if (id) el.dataset.deployId = id.replace(/[^\w-]/g, ''); }
const range = first.getTime() === last.getTime() || Math.abs(last - first) < 60000 ? _deployTime(last) : _deployTime(first, first.toDateString() !== last.toDateString()) + ' – ' + _deployTime(last, first.toDateString() !== last.toDateString());
const deploys = Number(data.deploys) || 1;
const rest = commits.length - SHOW;
-- el.innerHTML = `<div class="chat-deploy-h"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span></div>
++ el.innerHTML = `<button type="button" class="chat-deploy-h" aria-expanded="${wasExpanded ? 'true' : 'false'}"><b>🚀 ${commits.length} update${commits.length === 1 ? '' : 's'} shipped</b><span class="chat-deploy-t" title="${esc(last.toLocaleString())}">${esc(range)}${deploys > 1 ? ' · ' + deploys + ' deploys' : ''}</span></button>
<ul>${commits.map((c, i) => `<li class="${i >= SHOW ? 'is-hidden' : ''}${fresh.has(c.hash) ? ' is-fresh' : ''}">${c.hash ? `<a href="https://github.com/OpenVibers/OpenVibe.Live/commit/${esc(c.hash)}" target="_blank" rel="noopener">${esc(c.short || String(c.hash).slice(0, 7))}</a>` : '<i></i>'}<span>${esc(c.subject)}</span></li>`).join('')}</ul>
<div class="chat-deploy-f">${rest > 0 ? `<button type="button" class="chat-deploy-more" aria-expanded="${wasOpen ? 'true' : 'false'}">${wasOpen ? 'Show fewer' : `Show ${rest} more`}</button>` : ''}<span class="chat-deploy-a"><a href="/updates" target="_blank" rel="noopener">Patch notes</a>${opts.live ? '<button type="button" class="chat-deploy-reload" title="Load the new version">Reload to update</button>' : ''}</span></div>`;
if (wasOpen) el.classList.add('is-open');
++ el.classList.toggle('is-collapsed', !wasExpanded);
++ const head = el.querySelector('.chat-deploy-h');
++ head.addEventListener('click', () => { const open = el.classList.toggle('is-collapsed') === false; head.setAttribute('aria-expanded', String(open)); });
const more = el.querySelector('.chat-deploy-more');
if (more) more.addEventListener('click', () => { const open = el.classList.toggle('is-open'); more.textContent = open ? 'Show fewer' : `Show ${rest} more`; more.setAttribute('aria-expanded', String(open)); });
const reload = el.querySelector('.chat-deploy-reload');
@@@ -7436,10 +7442,9 @@ function _fcwStopResize() {
}
// Page markup that arrives later (ov-loader.js fragments) gets the same one-time setup the
--// boot-time markup got: font-size classes and scroll containment, the resize handle, GIF buttons.
++// boot-time markup got: font-size classes and scroll containment, GIF buttons.
document.addEventListener('ov:fragment', () => {
try { applyChatSettings(); } catch { /* */ }
-- try { initChatResize(); } catch { /* */ }
if (typeof initGifPickers === 'function') { try { initGifPickers(); } catch { /* */ } }
});
@@@ -7670,60 +7675,7 @@ function closeChatUsersPanel(btn) {
(window.handleChatLinkClick / showLinkContextMenu / OpenVibeChat.linkify),
shared with the /kiosk page. */
--/* ══════════════════════════════════════════════════════════════
-- CHAT RESIZE HANDLE — drag to change chat sidebar width
-- ══════════════════════════════════════════════════════════════ */
--function initChatResize() {
-- const handle = document.getElementById('chat-resize-handle');
-- const sidebar = document.getElementById('chat-sidebar');
-- if (!handle || !sidebar || handle._ovResizeBound) return;
-- handle._ovResizeBound = true;
--
-- const MIN_W = 250;
-- const MAX_RATIO = 0.5; // max 50% of viewport
--
-- // Restore saved width
-- const saved = localStorage.getItem('openvibe_chat_width');
-- if (saved) {
-- const w = parseInt(saved, 10);
-- if (w >= MIN_W && w <= window.innerWidth * MAX_RATIO) {
-- sidebar.style.width = w + 'px';
-- }
-- }
--
-- let dragging = false;
-- let startX = 0;
-- let startW = 0;
--
-- handle.addEventListener('mousedown', (e) => {
-- e.preventDefault();
-- dragging = true;
-- startX = e.clientX;
-- startW = sidebar.offsetWidth;
-- handle.classList.add('dragging');
-- document.body.style.cursor = 'col-resize';
-- document.body.style.userSelect = 'none';
-- });
--
-- document.addEventListener('mousemove', (e) => {
-- if (!dragging) return;
-- // Chat is on the right, so dragging left increases width
-- const diff = startX - e.clientX;
-- const maxW = window.innerWidth * MAX_RATIO;
-- const newW = Math.max(MIN_W, Math.min(maxW, startW + diff));
-- sidebar.style.width = newW + 'px';
-- });
--
-- document.addEventListener('mouseup', () => {
-- if (!dragging) return;
-- dragging = false;
-- handle.classList.remove('dragging');
-- document.body.style.cursor = '';
-- document.body.style.userSelect = '';
-- localStorage.setItem('openvibe_chat_width', sidebar.offsetWidth);
-- });
--}
--initChatResize();
++/* Resizing the channel page's chat (every layout, mouse and touch): public/js/channel-chat-resize.js. */
// Reply / translate buttons and folded Friendly-chat groups, for every chat surface: one listener.
(function _wireChatRowActions() {