openvibers/openvibe.live Commits
Recently Online cards are even, every replay has a picture, and chat's live cards are one row (#760)
Changes
@@@ -74,15 +74,30 @@transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);}.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }--.recent-streamer-card { display: flex; flex-direction: column; height: 100%; min-height: 322px; }--.recent-streamer-head { min-height: 58px; padding: 8px 12px; flex-wrap: nowrap; }++/* Recently Online: every card has the same places in the same order (who and when; this week in numbers, each with++ its icon; when they are usually on; two lines about the channel; two replays), so cards are the same height++ without a hole in the short ones. */++.recent-streamer-card { display: flex; flex-direction: column; height: 100%; }++.recent-streamer-head { min-height: 54px; padding: 8px 12px 6px; flex-wrap: nowrap; }.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }--.recent-streamer-facts { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 12px 8px; min-height: 49px; }--.recent-streamer-chip { align-self: start; padding: 4px 7px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .75rem; line-height: 1.2; }--.recent-streamer-summary { margin: 0 12px; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.recent-streamer-head .streamer-group-last-online.is-live { color: var(--live, #ef4444); font-weight: 800; }++.recent-streamer-stats { position: relative; display: flex; align-items: center; gap: 9px; flex-wrap: nowrap; height: 38px; box-sizing: border-box; overflow: hidden; padding: 15px 12px 0; font-size: .75rem; line-height: 1.3; }++.recent-streamer-cap { position: absolute; left: 12px; top: 0; font-size: .64rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }++.recent-streamer-stat { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-weight: 700; color: var(--text-primary); }++.recent-streamer-stat i { font-size: .7rem; color: var(--accent); }++.recent-streamer-habit { margin: 3px 12px 6px; min-height: 1.3em; font-size: .76rem; line-height: 1.3; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.recent-streamer-about { position: relative; margin: 0 12px 8px; }++.recent-streamer-summary { margin: 0; min-height: 2.8em; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }.recent-streamer-summary.expanded { display: block; }--.recent-streamer-more { align-self: start; min-width: 44px; min-height: 44px; margin: 0 8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: .78rem; cursor: pointer; }++/* More sits on the end of the second line, over a fade into the card, not on a row of its own; its box for a finger++ is larger than its word (::after). */++.recent-streamer-more { position: absolute; right: 0; bottom: 0; padding: 0 0 0 30px; border: 0; color: var(--accent); font: inherit; font-size: .76rem; font-weight: 700; line-height: 1.47; cursor: pointer;++ background: linear-gradient(90deg, transparent, var(--bg-card) 26px); }++.recent-streamer-more::after { content: ''; position: absolute; inset: -14px -10px -12px 0; }++.recent-streamer-more[hidden] { display: none; }++.recent-streamer-about.is-open .recent-streamer-more { position: relative; display: block; margin: 4px 0 0; padding: 0; background: none; }++.streamer-group-stream-thumb.is-blank { display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); color: var(--text-muted); font-size: .9rem; }.recent-streamer-history { margin-top: auto; padding-bottom: 0; }.recent-stream-row { min-height: 61px; }.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }@@@ -1511,42 +1511,65 @@ function renderRecentlyOnline(containerId, streamers, append) {img.onerror = () => img.remove(); face.append(img);}const name = el('strong', 'streamer-group-name', s.display_name || s.username);-- const when = el('span', 'streamer-group-last-online', s.live ? 'Live now' : 'Live ' + timeAgo(s.last_online_at));++ const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : timeAgo(s.last_online_at));++ if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);head.append(face, name, when);card.append(head);-- const chips = el('div', 'recent-streamer-facts');-- const chip = (value) => { if (value) chips.append(el('span', 'recent-streamer-chip', value)); };-- if (facts.streams_this_week) chip(facts.streams_this_week + ' streams this week');-- if (facts.hours_this_week) chip(facts.hours_this_week + ' h this week');-- chip(facts.usual_time);-- if (facts.days_in_a_row >= 3) chip(facts.days_in_a_row + ' days in a row');-- if (facts.last_stream_seconds) chip('Last stream ' + formatDuration(facts.last_stream_seconds));-- card.append(chips);++ // The same three places for everyone, so a card with little to say is not a card with a hole in it: this++ // week in numbers (each with its icon), when they are usually on, and two lines about the channel.++ const stats = el('div', 'recent-streamer-stats');++ const stat = (icon, value, label) => {++ if (!value) return;++ const n = el('span', 'recent-streamer-stat');++ n.title = label;++ const i = el('i', 'fa-solid ' + icon); i.setAttribute('aria-hidden', 'true');++ n.append(i, el('span', null, value));++ stats.append(n);++ };++ stat('fa-tower-broadcast', facts.streams_this_week ? facts.streams_this_week + (Number(facts.streams_this_week) === 1 ? ' stream' : ' streams') : '', 'Streams this week');++ stat('fa-clock', facts.hours_this_week ? facts.hours_this_week + ' h' : '', 'Hours live this week');++ stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' days' : '', 'Days in a row');++ if (stats.childElementCount) stats.prepend(el('span', 'recent-streamer-cap', 'This week'));++ card.append(stats);++ const lastLine = facts.last_stream_seconds ? 'Last stream ' + formatDuration(facts.last_stream_seconds) : '';++ card.append(el('p', 'recent-streamer-habit', [facts.usual_time, lastLine].filter(Boolean).join(' · ')));const overview = String(s.ai_overview_short || s.ai_overview || '').trim();++ const about = el('div', 'recent-streamer-about');++ const summary = el('p', 'recent-streamer-summary', overview);++ about.append(summary);if (overview) {-- const summary = el('p', 'recent-streamer-summary', overview);const more = el('button', 'recent-streamer-more', 'More');-- more.type = 'button'; more.setAttribute('aria-expanded', 'false');++ more.type = 'button'; more.setAttribute('aria-expanded', 'false'); more.hidden = true;more.addEventListener('click', () => {-- const open = summary.classList.toggle('expanded');++ const open = about.classList.toggle('is-open');++ summary.classList.toggle('expanded', open);more.textContent = open ? 'Less' : 'More';more.setAttribute('aria-expanded', String(open));});-- card.append(summary, more);++ about.append(more);++ // "More" only where the two lines cut something off; it sits on the end of the second line.++ requestAnimationFrame(() => { more.hidden = summary.scrollHeight <= summary.clientHeight + 2; });}++ card.append(about);const history = el('div', 'streamer-group-streams recent-streamer-history');for (const h of (facts.streams || []).slice(0, 2)) {const replay = !h.is_live && h.vod_id;const row = el('a', 'streamer-group-stream recent-stream-row');row.href = replay ? videoHref('vod', h.vod_id) : channelHref;++ // A picture on every row: the replay's own, else the one Live can make from the recording, else a mark++ // that says what the row is. Never an empty box.const thumb = el('span', 'streamer-group-stream-thumb');-- const src = _sgThumbSrc(h.thumbnail_url);++ const made = replay ? `/api/thumbnails/generate/vod/${encodeURIComponent(h.vod_id)}` : null;++ const src = _sgThumbSrc(h.thumbnail_url) || made;++ const blank = () => { thumb.replaceChildren(); thumb.classList.add('is-blank'); const i = el('i', 'fa-solid ' + (h.is_live ? 'fa-tower-broadcast' : 'fa-play')); i.setAttribute('aria-hidden', 'true'); thumb.append(i); };if (src) {const img = el('img', 'streamer-group-stream-thumb-img');-- img.src = src; img.alt = ''; img.loading = 'lazy';-- img.onerror = () => img.remove();++ img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';++ img.onerror = () => { if (made && img.dataset.made !== '1') { img.dataset.made = '1'; img.src = made; } else blank(); };++ if (src === made) img.dataset.made = '1';++ img.src = src;thumb.append(img);-- }++ } else blank();const info = el('span', 'streamer-group-stream-info');info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));@@@ -2812,28 +2812,36 @@ function _chatEventOf(m) {function _chatEvCss() {if (document.getElementById('chat-ev-css')) return;const st = document.createElement('style'); st.id = 'chat-ev-css';++ // One row a card (the owner, 2026-11-10: "they just take up a lot of space in the chat versus chat messages"): the++ // streamer's picture with the live ring round it, who and what in one line with the time after it, and a small++ // Watch or Read. The whole row opens it (the button's own link is stretched over the card), so the button can be++ // small without being hard to hit; the link's own menu copies its address.st.textContent = `.chat-msg.system.chat-ev,.chat-msg.system.chat-ev *{font-style:normal}--.chat-ev{--ev:var(--accent,#3b82f6);container-type:inline-size;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:4px 10px;margin:6px 0;padding:8px 8px 8px 10px;border-radius:12px;border:1px solid color-mix(in srgb,var(--ev) 30%,transparent);background:linear-gradient(90deg,color-mix(in srgb,var(--ev) 12%,transparent),color-mix(in srgb,var(--ev) 4%,transparent) 70%);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.35}++.chat-ev{--ev:var(--accent,#3b82f6);container-type:inline-size;position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:0 8px;min-height:36px;margin:3px 0;padding:3px 5px 3px 4px;border-radius:999px;border:1px solid color-mix(in srgb,var(--ev) 26%,transparent);background:linear-gradient(90deg,color-mix(in srgb,var(--ev) 13%,transparent),color-mix(in srgb,var(--ev) 3%,transparent) 75%);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.3;transition:border-color .15s ease,background-color .15s ease}++.chat-ev:hover{border-color:color-mix(in srgb,var(--ev) 55%,transparent)}.chat-ev--live{--ev:var(--live,#ef4444)}++.chat-ev--rundown{border-radius:16px;padding:5px 6px 5px 5px}.chat-ev.chat-msg-new{animation:chatEvIn .32s var(--ov-ease-out,cubic-bezier(.2,.8,.2,1)) both}--@keyframes chatEvIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}--.chat-ev-icon{position:relative;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:color-mix(in srgb,var(--ev) 18%,transparent);color:var(--ev);font-size:.85rem}--.chat-ev--live .chat-ev-icon::before{content:'';width:10px;height:10px;border-radius:50%;background:var(--ev);box-shadow:0 0 0 0 color-mix(in srgb,var(--ev) 60%,transparent);animation:chatEvPulse 1.8s ease-out infinite}--@keyframes chatEvPulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ev) 55%,transparent)}70%{box-shadow:0 0 0 9px transparent}100%{box-shadow:0 0 0 0 transparent}}++@keyframes chatEvIn{from{opacity:0;transform:translateY(calc(6px * var(--ov-move,1))) scale(.98)}to{opacity:1;transform:none}}++.chat-ev-icon{position:relative;display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:color-mix(in srgb,var(--ev) 18%,transparent);color:var(--ev);font-size:.78rem;flex:none}++.chat-ev-face{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;object-fit:cover}++.chat-ev--live .chat-ev-icon{box-shadow:0 0 0 2px var(--ev);animation:chatEvPulse 1.8s ease-out infinite}++.chat-ev--live .chat-ev-icon::before{content:'';width:9px;height:9px;border-radius:50%;background:var(--ev)}++.chat-ev--live .chat-ev-icon:has(.chat-ev-face)::before{content:none}++@keyframes chatEvPulse{0%{box-shadow:0 0 0 2px var(--ev),0 0 0 2px color-mix(in srgb,var(--ev) 55%,transparent)}70%{box-shadow:0 0 0 2px var(--ev),0 0 0 9px transparent}100%{box-shadow:0 0 0 2px var(--ev),0 0 0 2px transparent}}.chat-ev-body{min-width:0}--.chat-ev-line{display:flex;flex-wrap:wrap;column-gap:.3em;color:var(--text-primary,#e6edf7)}.chat-ev-line b{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700}--.chat-ev-title{display:block;flex-basis:100%;min-width:0;margin-top:2px;color:var(--text-secondary,#a8b3c4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}--.chat-ev-meta{display:block;margin-top:2px;font-size:.72rem;color:var(--text-muted,#7d8aa0)}--.chat-ev-acts{display:flex;align-items:center;gap:4px}--.chat-ev-cta{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:0 11px;border-radius:999px;background:var(--ev);color:#fff;font-weight:700;font-size:.76rem;text-decoration:none;white-space:nowrap}--.chat-ev-cta:hover{filter:brightness(1.1)}.chat-ev-cta:focus-visible,.chat-ev-copy:focus-visible{outline:2px solid var(--ev);outline-offset:2px}--.chat-ev-copy{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:0;background:transparent;color:var(--text-muted,#7d8aa0);cursor:pointer}--.chat-ev-copy:hover{background:color-mix(in srgb,var(--text-primary,#fff) 8%,transparent);color:var(--text-primary,#e6edf7)}--@media (pointer:coarse){.chat-ev-cta{min-height:44px;padding:0 14px}.chat-ev-copy{width:44px;height:44px}}--@container (max-width:360px){.chat-ev-cta{padding:0;width:36px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.8rem}}--@container (max-width:240px){.chat-ev{grid-template-columns:auto minmax(0,1fr)}.chat-ev-acts{grid-column:2;justify-self:start}}--.chat-ev--here{grid-template-columns:auto minmax(0,1fr);padding:6px 10px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}.chat-ev--here .chat-ev-icon::before{width:8px;height:8px}--@container (max-width:360px){@media (pointer:coarse){.chat-ev-cta{width:44px}}}++.chat-ev-line{display:flex;align-items:baseline;column-gap:.3em;min-width:0;color:var(--text-primary,#e6edf7);white-space:nowrap}.chat-ev-line b{min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:700}++.chat-ev-what{flex:none}++.chat-ev-title{display:block;min-width:0;color:var(--text-secondary,#a8b3c4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.chat-ev-meta{flex:none;margin-left:.2em;font-size:.72rem;color:var(--text-muted,#7d8aa0)}++.chat-ev-acts{display:flex;align-items:center}++.chat-ev-cta{display:inline-flex;align-items:center;gap:5px;min-height:28px;padding:0 11px;border-radius:999px;background:var(--ev);color:#fff;font-weight:700;font-size:.74rem;text-decoration:none;white-space:nowrap;transition:transform .12s ease,filter .15s ease}++.chat-ev-cta::after{content:'';position:absolute;inset:-4px 0}++.chat-ev-cta i{font-size:.68rem}++.chat-ev:hover .chat-ev-cta{filter:brightness(1.1)}.chat-ev:active .chat-ev-cta{transform:scale(.95)}++.chat-ev-cta:focus-visible{outline:2px solid var(--ev);outline-offset:2px}++@container (max-width:260px){.chat-ev-cta{padding:0;width:28px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.7rem}.chat-ev-meta{display:none}}++.chat-ev--here{grid-template-columns:auto minmax(0,1fr);min-height:30px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}`;document.head.appendChild(st);}@@@ -2867,8 +2875,8 @@ function renderChatEventCard(ev, opts = {}) {if (recent && Math.abs(when.date - Number(recent.dataset.evAt || 0)) < _CHAT_EV_FOLD_MS) {recent.dataset.evAt = String(when.date.getTime());let meta = recent.querySelector('.chat-ev-meta');-- if (!meta) { meta = document.createElement('span'); meta.className = 'chat-ev-meta'; recent.querySelector('.chat-ev-body').appendChild(meta); }-- meta.textContent = showTime ? `${when.text} · went live again` : 'Went live again';++ if (!meta) { meta = document.createElement('span'); meta.className = 'chat-ev-meta'; recent.querySelector('.chat-ev-line').appendChild(meta); }++ meta.textContent = showTime ? `${when.text} · again` : 'again';return;}}@@@ -2888,6 +2896,15 @@ function renderChatEventCard(ev, opts = {}) {icon.className = 'chat-ev-icon';icon.setAttribute('aria-hidden', 'true');if (ev.kind === 'rundown') icon.innerHTML = '<i class="fa-solid fa-newspaper"></i>';++ // The streamer's own picture (the network's address for it, by name); the live dot stays where there is none.++ if (ev.kind === 'live' && /^[A-Za-z0-9_]{2,32}$/.test(String(ev.name || ''))) {++ const face = document.createElement('img');++ face.className = 'chat-ev-face';++ face.alt = ''; face.loading = 'lazy'; face.decoding = 'async';++ face.src = `https://openvibe.network/avatar/${encodeURIComponent(ev.name)}?s=64`;++ face.onerror = () => face.remove();++ icon.appendChild(face);++ }const body = document.createElement('div');body.className = 'chat-ev-body';@@@ -2895,22 +2912,30 @@ function renderChatEventCard(ev, opts = {}) {line.className = 'chat-ev-line';const who = document.createElement('b');who.textContent = ev.name;++ const what = document.createElement('span');++ what.className = 'chat-ev-what';if (here && here.mine) {-- line.append('You went live');++ what.textContent = 'You went live';++ line.append(what);} else if (ev.kind === 'live') {-- line.append(who, here ? ' went live' : ' is live');++ what.textContent = here ? 'went live' : 'is live';++ line.append(who, what);} else {-- line.append(who, ' on The Rundown');-- const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;-- line.append(title);++ what.textContent = 'on The Rundown';++ line.append(who, what);}-- body.appendChild(line);-- // The time, with the viewer's timestamp setting (the Watch/Read button is the link, so no address is repeated).++ // The time, with the viewer's timestamp setting, after the words on the same line (the Watch/Read button is the++ // link, so no address is repeated).if (showTime) {const meta = document.createElement('span');meta.className = 'chat-ev-meta';meta.textContent = when.text;-- body.appendChild(meta);++ line.appendChild(meta);++ }++ body.appendChild(line);++ if (ev.kind === 'rundown') {++ const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;++ body.appendChild(title);}const acts = document.createElement('div');@@@ -2926,16 +2951,7 @@ function renderChatEventCard(ev, opts = {}) {if (u.origin === window.location.origin && typeof handleLinkClick === 'function') handleLinkClick(e, u.pathname + u.search + u.hash);} catch { /* a normal link */ }});-- const copy = document.createElement('button');-- copy.type = 'button';-- copy.className = 'chat-ev-copy';-- copy.title = 'Copy the link';-- copy.setAttribute('aria-label', 'Copy the link');-- copy.innerHTML = '<i class="fa-solid fa-link" aria-hidden="true"></i>';-- copy.addEventListener('click', async () => {-- try { await navigator.clipboard.writeText(ev.url); toast('Link copied', 'success'); } catch { toast('Could not copy the link', 'error'); }-- });-- acts.append(cta, copy);++ acts.append(cta);if (here) el.append(icon, body);else el.append(icon, body, acts);container.appendChild(el);@@@ -46,6 +46,13 @@ const card = src.slice(src.indexOf('function renderChatEventCard('), src.indexOfassert.ok(!/innerHTML\s*=\s*[^;]*ev\.(name|title)/.test(card), 'the name and title never go through innerHTML');assert.ok(/who\.textContent = ev\.name/.test(card) && /title\.textContent = `“\$\{ev\.title\}”`/.test(card), 'they are text nodes');assert.ok(/showTime = !!opts\.isGlobal \|\| !!chatSettings\.showTimestamps/.test(card), 'time: global always, elsewhere the viewer\'s setting');--assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /went live again/.test(card), 'the same person within 15 minutes updates the card');++assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /\$\{when\.text\} · again/.test(card), 'the same person within 15 minutes updates the card');++// One row a card (the owner, 2026-11-10: the cards took more room than the messages around them).++assert.ok(/line\.appendChild\(meta\)/.test(card), 'the time is on the line, not under it');++assert.ok(/face\.src = `https:\/\/openvibe\.network\/avatar\/\$\{encodeURIComponent\(ev\.name\)\}\?s=64`/.test(card) && /face\.onerror = \(\) => face\.remove\(\)/.test(card), "the streamer's picture, and the live dot where there is none");++assert.ok(!/chat-ev-copy/.test(src), 'no copy button: the link has its own menu');++const evCss = src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('));++assert.ok(/\.chat-ev\{[^}]*min-height:36px;margin:3px 0;/.test(evCss), 'a row the height of a message');++assert.ok(/\.chat-ev-cta::after\{content:'';position:absolute;inset:-4px 0\}/.test(evCss), 'the whole row opens it, so the button can be small');assert.ok(!/prefers-reduced-motion/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'Calm plays the cards (openvibe-shared 4.183.0); only motion off stops them, by the loader\'s one rule');console.log('chat event cards: ok');@@@ -32,10 +32,10 @@ assert.strictEqual(isHere({ path: '/@moosely/studio' }, 'goosely'), null, 'someoconst card = chat.slice(chat.indexOf('function renderChatEventCard('), chat.indexOf('/* ── Deploy notices'));assert.ok(/const here = ev\.kind === 'live' && _chatEvIsHere\(ev\)/.test(card), 'the card asks where it is');--assert.ok(card.includes("line.append('You went live')"), 'the streamer reads "You went live"');++assert.ok(card.includes("what.textContent = 'You went live'"), 'the streamer reads "You went live"');assert.ok(/if \(here\) el\.append\(icon, body\);\s*else el\.append\(icon, body, acts\);/.test(card), 'no Watch button or copy link here');const css = chat.slice(chat.indexOf('function _chatEvCss('), chat.indexOf('function _chatEvTime('));--assert.ok(/@container \(max-width:240px\)\{\.chat-ev\{grid-template-columns:auto minmax\(0,1fr\)\}\.chat-ev-acts\{grid-column:2/.test(css), 'narrow: buttons under the text');++assert.ok(/@container \(max-width:260px\)\{\.chat-ev-cta\{padding:0;width:28px;/.test(css), 'narrow: the button is its icon alone, the words keep the row');const studio = read('js/broadcast-studio.js');assert.ok(/if \(pv && pv\.paused && pv\.srcObject && pv\.style\.display !== 'none'\) pv\.play\(\)\.catch\(\(\) => \{\}\);/.test(studio), 'a paused self-view plays again');
@@ -74,15 +74,30 @@
@@ -74,15 +74,30 @@
transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);
transition: border-color var(--ov-dur-2, 0.2s), box-shadow var(--ov-dur-2, 0.2s);
}
}
.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }
.streamer-group-card:hover { border-color: var(--accent-dark); box-shadow: var(--ring-accent); }
-.recent-streamer-card { display: flex; flex-direction: column; height: 100%; min-height: 322px; }
-.recent-streamer-head { min-height: 58px; padding: 8px 12px; flex-wrap: nowrap; }
+/* Recently Online: every card has the same places in the same order (who and when; this week in numbers, each with
+ its icon; when they are usually on; two lines about the channel; two replays), so cards are the same height
+ without a hole in the short ones. */
+.recent-streamer-card { display: flex; flex-direction: column; height: 100%; }
+.recent-streamer-head { min-height: 54px; padding: 8px 12px 6px; flex-wrap: nowrap; }
.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }
.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }
.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }
.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }
-.recent-streamer-facts { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 12px 8px; min-height: 49px; }
-.recent-streamer-chip { align-self: start; padding: 4px 7px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .75rem; line-height: 1.2; }
-.recent-streamer-summary { margin: 0 12px; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.recent-streamer-head .streamer-group-last-online.is-live { color: var(--live, #ef4444); font-weight: 800; }
+.recent-streamer-stats { position: relative; display: flex; align-items: center; gap: 9px; flex-wrap: nowrap; height: 38px; box-sizing: border-box; overflow: hidden; padding: 15px 12px 0; font-size: .75rem; line-height: 1.3; }
+.recent-streamer-cap { position: absolute; left: 12px; top: 0; font-size: .64rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
+.recent-streamer-stat { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-weight: 700; color: var(--text-primary); }
+.recent-streamer-stat i { font-size: .7rem; color: var(--accent); }
+.recent-streamer-habit { margin: 3px 12px 6px; min-height: 1.3em; font-size: .76rem; line-height: 1.3; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.recent-streamer-about { position: relative; margin: 0 12px 8px; }
+.recent-streamer-summary { margin: 0; min-height: 2.8em; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.recent-streamer-summary.expanded { display: block; }
.recent-streamer-summary.expanded { display: block; }
-.recent-streamer-more { align-self: start; min-width: 44px; min-height: 44px; margin: 0 8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: .78rem; cursor: pointer; }
+/* More sits on the end of the second line, over a fade into the card, not on a row of its own; its box for a finger
+ is larger than its word (::after). */
+.recent-streamer-more { position: absolute; right: 0; bottom: 0; padding: 0 0 0 30px; border: 0; color: var(--accent); font: inherit; font-size: .76rem; font-weight: 700; line-height: 1.47; cursor: pointer;
+ background: linear-gradient(90deg, transparent, var(--bg-card) 26px); }
+.recent-streamer-more::after { content: ''; position: absolute; inset: -14px -10px -12px 0; }
+.recent-streamer-more[hidden] { display: none; }
+.recent-streamer-about.is-open .recent-streamer-more { position: relative; display: block; margin: 4px 0 0; padding: 0; background: none; }
+.streamer-group-stream-thumb.is-blank { display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); color: var(--text-muted); font-size: .9rem; }
.recent-streamer-history { margin-top: auto; padding-bottom: 0; }
.recent-streamer-history { margin-top: auto; padding-bottom: 0; }
.recent-stream-row { min-height: 61px; }
.recent-stream-row { min-height: 61px; }
.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }
.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }
@@ -1511,42 +1511,65 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1511,42 +1511,65 @@ function renderRecentlyOnline(containerId, streamers, append) {
img.onerror = () => img.remove(); face.append(img);
img.onerror = () => img.remove(); face.append(img);
}
}
const name = el('strong', 'streamer-group-name', s.display_name || s.username);
const name = el('strong', 'streamer-group-name', s.display_name || s.username);
- const when = el('span', 'streamer-group-last-online', s.live ? 'Live now' : 'Live ' + timeAgo(s.last_online_at));
+ const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : timeAgo(s.last_online_at));
+ if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);
head.append(face, name, when);
head.append(face, name, when);
card.append(head);
card.append(head);
- const chips = el('div', 'recent-streamer-facts');
- const chip = (value) => { if (value) chips.append(el('span', 'recent-streamer-chip', value)); };
- if (facts.streams_this_week) chip(facts.streams_this_week + ' streams this week');
- if (facts.hours_this_week) chip(facts.hours_this_week + ' h this week');
- chip(facts.usual_time);
- if (facts.days_in_a_row >= 3) chip(facts.days_in_a_row + ' days in a row');
- if (facts.last_stream_seconds) chip('Last stream ' + formatDuration(facts.last_stream_seconds));
- card.append(chips);
+ // The same three places for everyone, so a card with little to say is not a card with a hole in it: this
+ // week in numbers (each with its icon), when they are usually on, and two lines about the channel.
+ const stats = el('div', 'recent-streamer-stats');
+ const stat = (icon, value, label) => {
+ if (!value) return;
+ const n = el('span', 'recent-streamer-stat');
+ n.title = label;
+ const i = el('i', 'fa-solid ' + icon); i.setAttribute('aria-hidden', 'true');
+ n.append(i, el('span', null, value));
+ stats.append(n);
+ };
+ stat('fa-tower-broadcast', facts.streams_this_week ? facts.streams_this_week + (Number(facts.streams_this_week) === 1 ? ' stream' : ' streams') : '', 'Streams this week');
+ stat('fa-clock', facts.hours_this_week ? facts.hours_this_week + ' h' : '', 'Hours live this week');
+ stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' days' : '', 'Days in a row');
+ if (stats.childElementCount) stats.prepend(el('span', 'recent-streamer-cap', 'This week'));
+ card.append(stats);
+ const lastLine = facts.last_stream_seconds ? 'Last stream ' + formatDuration(facts.last_stream_seconds) : '';
+ card.append(el('p', 'recent-streamer-habit', [facts.usual_time, lastLine].filter(Boolean).join(' · ')));
const overview = String(s.ai_overview_short || s.ai_overview || '').trim();
const overview = String(s.ai_overview_short || s.ai_overview || '').trim();
+ const about = el('div', 'recent-streamer-about');
+ const summary = el('p', 'recent-streamer-summary', overview);
+ about.append(summary);
if (overview) {
if (overview) {
- const summary = el('p', 'recent-streamer-summary', overview);
const more = el('button', 'recent-streamer-more', 'More');
const more = el('button', 'recent-streamer-more', 'More');
- more.type = 'button'; more.setAttribute('aria-expanded', 'false');
+ more.type = 'button'; more.setAttribute('aria-expanded', 'false'); more.hidden = true;
more.addEventListener('click', () => {
more.addEventListener('click', () => {
- const open = summary.classList.toggle('expanded');
+ const open = about.classList.toggle('is-open');
+ summary.classList.toggle('expanded', open);
more.textContent = open ? 'Less' : 'More';
more.textContent = open ? 'Less' : 'More';
more.setAttribute('aria-expanded', String(open));
more.setAttribute('aria-expanded', String(open));
});
});
- card.append(summary, more);
+ about.append(more);
+ // "More" only where the two lines cut something off; it sits on the end of the second line.
+ requestAnimationFrame(() => { more.hidden = summary.scrollHeight <= summary.clientHeight + 2; });
}
}
+ card.append(about);
const history = el('div', 'streamer-group-streams recent-streamer-history');
const history = el('div', 'streamer-group-streams recent-streamer-history');
for (const h of (facts.streams || []).slice(0, 2)) {
for (const h of (facts.streams || []).slice(0, 2)) {
const replay = !h.is_live && h.vod_id;
const replay = !h.is_live && h.vod_id;
const row = el('a', 'streamer-group-stream recent-stream-row');
const row = el('a', 'streamer-group-stream recent-stream-row');
row.href = replay ? videoHref('vod', h.vod_id) : channelHref;
row.href = replay ? videoHref('vod', h.vod_id) : channelHref;
+ // A picture on every row: the replay's own, else the one Live can make from the recording, else a mark
+ // that says what the row is. Never an empty box.
const thumb = el('span', 'streamer-group-stream-thumb');
const thumb = el('span', 'streamer-group-stream-thumb');
- const src = _sgThumbSrc(h.thumbnail_url);
+ const made = replay ? `/api/thumbnails/generate/vod/${encodeURIComponent(h.vod_id)}` : null;
+ const src = _sgThumbSrc(h.thumbnail_url) || made;
+ const blank = () => { thumb.replaceChildren(); thumb.classList.add('is-blank'); const i = el('i', 'fa-solid ' + (h.is_live ? 'fa-tower-broadcast' : 'fa-play')); i.setAttribute('aria-hidden', 'true'); thumb.append(i); };
if (src) {
if (src) {
const img = el('img', 'streamer-group-stream-thumb-img');
const img = el('img', 'streamer-group-stream-thumb-img');
- img.src = src; img.alt = ''; img.loading = 'lazy';
- img.onerror = () => img.remove();
+ img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
+ img.onerror = () => { if (made && img.dataset.made !== '1') { img.dataset.made = '1'; img.src = made; } else blank(); };
+ if (src === made) img.dataset.made = '1';
+ img.src = src;
thumb.append(img);
thumb.append(img);
- }
+ } else blank();
const info = el('span', 'streamer-group-stream-info');
const info = el('span', 'streamer-group-stream-info');
info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));
info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));
info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));
info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));
@@ -2812,28 +2812,36 @@ function _chatEventOf(m) {
@@ -2812,28 +2812,36 @@ function _chatEventOf(m) {
function _chatEvCss() {
function _chatEvCss() {
if (document.getElementById('chat-ev-css')) return;
if (document.getElementById('chat-ev-css')) return;
const st = document.createElement('style'); st.id = 'chat-ev-css';
const st = document.createElement('style'); st.id = 'chat-ev-css';
+ // One row a card (the owner, 2026-11-10: "they just take up a lot of space in the chat versus chat messages"): the
+ // streamer's picture with the live ring round it, who and what in one line with the time after it, and a small
+ // Watch or Read. The whole row opens it (the button's own link is stretched over the card), so the button can be
+ // small without being hard to hit; the link's own menu copies its address.
st.textContent = `.chat-msg.system.chat-ev,.chat-msg.system.chat-ev *{font-style:normal}
st.textContent = `.chat-msg.system.chat-ev,.chat-msg.system.chat-ev *{font-style:normal}
-.chat-ev{--ev:var(--accent,#3b82f6);container-type:inline-size;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:4px 10px;margin:6px 0;padding:8px 8px 8px 10px;border-radius:12px;border:1px solid color-mix(in srgb,var(--ev) 30%,transparent);background:linear-gradient(90deg,color-mix(in srgb,var(--ev) 12%,transparent),color-mix(in srgb,var(--ev) 4%,transparent) 70%);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.35}
+.chat-ev{--ev:var(--accent,#3b82f6);container-type:inline-size;position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:0 8px;min-height:36px;margin:3px 0;padding:3px 5px 3px 4px;border-radius:999px;border:1px solid color-mix(in srgb,var(--ev) 26%,transparent);background:linear-gradient(90deg,color-mix(in srgb,var(--ev) 13%,transparent),color-mix(in srgb,var(--ev) 3%,transparent) 75%);color:var(--text-secondary,#a8b3c4);font-size:.84rem;line-height:1.3;transition:border-color .15s ease,background-color .15s ease}
+.chat-ev:hover{border-color:color-mix(in srgb,var(--ev) 55%,transparent)}
.chat-ev--live{--ev:var(--live,#ef4444)}
.chat-ev--live{--ev:var(--live,#ef4444)}
+.chat-ev--rundown{border-radius:16px;padding:5px 6px 5px 5px}
.chat-ev.chat-msg-new{animation:chatEvIn .32s var(--ov-ease-out,cubic-bezier(.2,.8,.2,1)) both}
.chat-ev.chat-msg-new{animation:chatEvIn .32s var(--ov-ease-out,cubic-bezier(.2,.8,.2,1)) both}
-@keyframes chatEvIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
-.chat-ev-icon{position:relative;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:color-mix(in srgb,var(--ev) 18%,transparent);color:var(--ev);font-size:.85rem}
-.chat-ev--live .chat-ev-icon::before{content:'';width:10px;height:10px;border-radius:50%;background:var(--ev);box-shadow:0 0 0 0 color-mix(in srgb,var(--ev) 60%,transparent);animation:chatEvPulse 1.8s ease-out infinite}
-@keyframes chatEvPulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ev) 55%,transparent)}70%{box-shadow:0 0 0 9px transparent}100%{box-shadow:0 0 0 0 transparent}}
+@keyframes chatEvIn{from{opacity:0;transform:translateY(calc(6px * var(--ov-move,1))) scale(.98)}to{opacity:1;transform:none}}
+.chat-ev-icon{position:relative;display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:color-mix(in srgb,var(--ev) 18%,transparent);color:var(--ev);font-size:.78rem;flex:none}
+.chat-ev-face{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;object-fit:cover}
+.chat-ev--live .chat-ev-icon{box-shadow:0 0 0 2px var(--ev);animation:chatEvPulse 1.8s ease-out infinite}
+.chat-ev--live .chat-ev-icon::before{content:'';width:9px;height:9px;border-radius:50%;background:var(--ev)}
+.chat-ev--live .chat-ev-icon:has(.chat-ev-face)::before{content:none}
+@keyframes chatEvPulse{0%{box-shadow:0 0 0 2px var(--ev),0 0 0 2px color-mix(in srgb,var(--ev) 55%,transparent)}70%{box-shadow:0 0 0 2px var(--ev),0 0 0 9px transparent}100%{box-shadow:0 0 0 2px var(--ev),0 0 0 2px transparent}}
.chat-ev-body{min-width:0}
.chat-ev-body{min-width:0}
-.chat-ev-line{display:flex;flex-wrap:wrap;column-gap:.3em;color:var(--text-primary,#e6edf7)}.chat-ev-line b{min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700}
-.chat-ev-title{display:block;flex-basis:100%;min-width:0;margin-top:2px;color:var(--text-secondary,#a8b3c4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.chat-ev-meta{display:block;margin-top:2px;font-size:.72rem;color:var(--text-muted,#7d8aa0)}
-.chat-ev-acts{display:flex;align-items:center;gap:4px}
-.chat-ev-cta{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:0 11px;border-radius:999px;background:var(--ev);color:#fff;font-weight:700;font-size:.76rem;text-decoration:none;white-space:nowrap}
-.chat-ev-cta:hover{filter:brightness(1.1)}.chat-ev-cta:focus-visible,.chat-ev-copy:focus-visible{outline:2px solid var(--ev);outline-offset:2px}
-.chat-ev-copy{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:0;background:transparent;color:var(--text-muted,#7d8aa0);cursor:pointer}
-.chat-ev-copy:hover{background:color-mix(in srgb,var(--text-primary,#fff) 8%,transparent);color:var(--text-primary,#e6edf7)}
-@media (pointer:coarse){.chat-ev-cta{min-height:44px;padding:0 14px}.chat-ev-copy{width:44px;height:44px}}
-@container (max-width:360px){.chat-ev-cta{padding:0;width:36px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.8rem}}
-@container (max-width:240px){.chat-ev{grid-template-columns:auto minmax(0,1fr)}.chat-ev-acts{grid-column:2;justify-self:start}}
-.chat-ev--here{grid-template-columns:auto minmax(0,1fr);padding:6px 10px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}.chat-ev--here .chat-ev-icon::before{width:8px;height:8px}
-@container (max-width:360px){@media (pointer:coarse){.chat-ev-cta{width:44px}}}
+.chat-ev-line{display:flex;align-items:baseline;column-gap:.3em;min-width:0;color:var(--text-primary,#e6edf7);white-space:nowrap}.chat-ev-line b{min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:700}
+.chat-ev-what{flex:none}
+.chat-ev-title{display:block;min-width:0;color:var(--text-secondary,#a8b3c4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.chat-ev-meta{flex:none;margin-left:.2em;font-size:.72rem;color:var(--text-muted,#7d8aa0)}
+.chat-ev-acts{display:flex;align-items:center}
+.chat-ev-cta{display:inline-flex;align-items:center;gap:5px;min-height:28px;padding:0 11px;border-radius:999px;background:var(--ev);color:#fff;font-weight:700;font-size:.74rem;text-decoration:none;white-space:nowrap;transition:transform .12s ease,filter .15s ease}
+.chat-ev-cta::after{content:'';position:absolute;inset:-4px 0}
+.chat-ev-cta i{font-size:.68rem}
+.chat-ev:hover .chat-ev-cta{filter:brightness(1.1)}.chat-ev:active .chat-ev-cta{transform:scale(.95)}
+.chat-ev-cta:focus-visible{outline:2px solid var(--ev);outline-offset:2px}
+@container (max-width:260px){.chat-ev-cta{padding:0;width:28px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.7rem}.chat-ev-meta{display:none}}
+.chat-ev--here{grid-template-columns:auto minmax(0,1fr);min-height:30px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -2867,8 +2875,8 @@ function renderChatEventCard(ev, opts = {}) {
@@ -2867,8 +2875,8 @@ function renderChatEventCard(ev, opts = {}) {
if (recent && Math.abs(when.date - Number(recent.dataset.evAt || 0)) < _CHAT_EV_FOLD_MS) {
if (recent && Math.abs(when.date - Number(recent.dataset.evAt || 0)) < _CHAT_EV_FOLD_MS) {
recent.dataset.evAt = String(when.date.getTime());
recent.dataset.evAt = String(when.date.getTime());
let meta = recent.querySelector('.chat-ev-meta');
let meta = recent.querySelector('.chat-ev-meta');
- if (!meta) { meta = document.createElement('span'); meta.className = 'chat-ev-meta'; recent.querySelector('.chat-ev-body').appendChild(meta); }
- meta.textContent = showTime ? `${when.text} · went live again` : 'Went live again';
+ if (!meta) { meta = document.createElement('span'); meta.className = 'chat-ev-meta'; recent.querySelector('.chat-ev-line').appendChild(meta); }
+ meta.textContent = showTime ? `${when.text} · again` : 'again';
return;
return;
}
}
}
}
@@ -2888,6 +2896,15 @@ function renderChatEventCard(ev, opts = {}) {
@@ -2888,6 +2896,15 @@ function renderChatEventCard(ev, opts = {}) {
icon.className = 'chat-ev-icon';
icon.className = 'chat-ev-icon';
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('aria-hidden', 'true');
if (ev.kind === 'rundown') icon.innerHTML = '<i class="fa-solid fa-newspaper"></i>';
if (ev.kind === 'rundown') icon.innerHTML = '<i class="fa-solid fa-newspaper"></i>';
+ // The streamer's own picture (the network's address for it, by name); the live dot stays where there is none.
+ if (ev.kind === 'live' && /^[A-Za-z0-9_]{2,32}$/.test(String(ev.name || ''))) {
+ const face = document.createElement('img');
+ face.className = 'chat-ev-face';
+ face.alt = ''; face.loading = 'lazy'; face.decoding = 'async';
+ face.src = `https://openvibe.network/avatar/${encodeURIComponent(ev.name)}?s=64`;
+ face.onerror = () => face.remove();
+ icon.appendChild(face);
+ }
const body = document.createElement('div');
const body = document.createElement('div');
body.className = 'chat-ev-body';
body.className = 'chat-ev-body';
@@ -2895,22 +2912,30 @@ function renderChatEventCard(ev, opts = {}) {
@@ -2895,22 +2912,30 @@ function renderChatEventCard(ev, opts = {}) {
line.className = 'chat-ev-line';
line.className = 'chat-ev-line';
const who = document.createElement('b');
const who = document.createElement('b');
who.textContent = ev.name;
who.textContent = ev.name;
+ const what = document.createElement('span');
+ what.className = 'chat-ev-what';
if (here && here.mine) {
if (here && here.mine) {
- line.append('You went live');
+ what.textContent = 'You went live';
+ line.append(what);
} else if (ev.kind === 'live') {
} else if (ev.kind === 'live') {
- line.append(who, here ? ' went live' : ' is live');
+ what.textContent = here ? 'went live' : 'is live';
+ line.append(who, what);
} else {
} else {
- line.append(who, ' on The Rundown');
- const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
- line.append(title);
+ what.textContent = 'on The Rundown';
+ line.append(who, what);
}
}
- body.appendChild(line);
- // The time, with the viewer's timestamp setting (the Watch/Read button is the link, so no address is repeated).
+ // The time, with the viewer's timestamp setting, after the words on the same line (the Watch/Read button is the
+ // link, so no address is repeated).
if (showTime) {
if (showTime) {
const meta = document.createElement('span');
const meta = document.createElement('span');
meta.className = 'chat-ev-meta';
meta.className = 'chat-ev-meta';
meta.textContent = when.text;
meta.textContent = when.text;
- body.appendChild(meta);
+ line.appendChild(meta);
+ }
+ body.appendChild(line);
+ if (ev.kind === 'rundown') {
+ const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
+ body.appendChild(title);
}
}
const acts = document.createElement('div');
const acts = document.createElement('div');
@@ -2926,16 +2951,7 @@ function renderChatEventCard(ev, opts = {}) {
@@ -2926,16 +2951,7 @@ function renderChatEventCard(ev, opts = {}) {
if (u.origin === window.location.origin && typeof handleLinkClick === 'function') handleLinkClick(e, u.pathname + u.search + u.hash);
if (u.origin === window.location.origin && typeof handleLinkClick === 'function') handleLinkClick(e, u.pathname + u.search + u.hash);
} catch { /* a normal link */ }
} catch { /* a normal link */ }
});
});
- const copy = document.createElement('button');
- copy.type = 'button';
- copy.className = 'chat-ev-copy';
- copy.title = 'Copy the link';
- copy.setAttribute('aria-label', 'Copy the link');
- copy.innerHTML = '<i class="fa-solid fa-link" aria-hidden="true"></i>';
- copy.addEventListener('click', async () => {
- try { await navigator.clipboard.writeText(ev.url); toast('Link copied', 'success'); } catch { toast('Could not copy the link', 'error'); }
- });
- acts.append(cta, copy);
+ acts.append(cta);
if (here) el.append(icon, body);
if (here) el.append(icon, body);
else el.append(icon, body, acts);
else el.append(icon, body, acts);
container.appendChild(el);
container.appendChild(el);
@@ -46,6 +46,13 @@ const card = src.slice(src.indexOf('function renderChatEventCard('), src.indexOf
@@ -46,6 +46,13 @@ const card = src.slice(src.indexOf('function renderChatEventCard('), src.indexOf
assert.ok(!/innerHTML\s*=\s*[^;]*ev\.(name|title)/.test(card), 'the name and title never go through innerHTML');
assert.ok(!/innerHTML\s*=\s*[^;]*ev\.(name|title)/.test(card), 'the name and title never go through innerHTML');
assert.ok(/who\.textContent = ev\.name/.test(card) && /title\.textContent = `“\$\{ev\.title\}”`/.test(card), 'they are text nodes');
assert.ok(/who\.textContent = ev\.name/.test(card) && /title\.textContent = `“\$\{ev\.title\}”`/.test(card), 'they are text nodes');
assert.ok(/showTime = !!opts\.isGlobal \|\| !!chatSettings\.showTimestamps/.test(card), 'time: global always, elsewhere the viewer\'s setting');
assert.ok(/showTime = !!opts\.isGlobal \|\| !!chatSettings\.showTimestamps/.test(card), 'time: global always, elsewhere the viewer\'s setting');
-assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /went live again/.test(card), 'the same person within 15 minutes updates the card');
+assert.ok(/_CHAT_EV_FOLD_MS/.test(card) && /\$\{when\.text\} · again/.test(card), 'the same person within 15 minutes updates the card');
+// One row a card (the owner, 2026-11-10: the cards took more room than the messages around them).
+assert.ok(/line\.appendChild\(meta\)/.test(card), 'the time is on the line, not under it');
+assert.ok(/face\.src = `https:\/\/openvibe\.network\/avatar\/\$\{encodeURIComponent\(ev\.name\)\}\?s=64`/.test(card) && /face\.onerror = \(\) => face\.remove\(\)/.test(card), "the streamer's picture, and the live dot where there is none");
+assert.ok(!/chat-ev-copy/.test(src), 'no copy button: the link has its own menu');
+const evCss = src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('));
+assert.ok(/\.chat-ev\{[^}]*min-height:36px;margin:3px 0;/.test(evCss), 'a row the height of a message');
+assert.ok(/\.chat-ev-cta::after\{content:'';position:absolute;inset:-4px 0\}/.test(evCss), 'the whole row opens it, so the button can be small');
assert.ok(!/prefers-reduced-motion/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'Calm plays the cards (openvibe-shared 4.183.0); only motion off stops them, by the loader\'s one rule');
assert.ok(!/prefers-reduced-motion/.test(src.slice(src.indexOf('function _chatEvCss('), src.indexOf('function _chatEvTime('))), 'Calm plays the cards (openvibe-shared 4.183.0); only motion off stops them, by the loader\'s one rule');
console.log('chat event cards: ok');
console.log('chat event cards: ok');
@@ -32,10 +32,10 @@ assert.strictEqual(isHere({ path: '/@moosely/studio' }, 'goosely'), null, 'someo
@@ -32,10 +32,10 @@ assert.strictEqual(isHere({ path: '/@moosely/studio' }, 'goosely'), null, 'someo
const card = chat.slice(chat.indexOf('function renderChatEventCard('), chat.indexOf('/* ── Deploy notices'));
const card = chat.slice(chat.indexOf('function renderChatEventCard('), chat.indexOf('/* ── Deploy notices'));
assert.ok(/const here = ev\.kind === 'live' && _chatEvIsHere\(ev\)/.test(card), 'the card asks where it is');
assert.ok(/const here = ev\.kind === 'live' && _chatEvIsHere\(ev\)/.test(card), 'the card asks where it is');
-assert.ok(card.includes("line.append('You went live')"), 'the streamer reads "You went live"');
+assert.ok(card.includes("what.textContent = 'You went live'"), 'the streamer reads "You went live"');
assert.ok(/if \(here\) el\.append\(icon, body\);\s*else el\.append\(icon, body, acts\);/.test(card), 'no Watch button or copy link here');
assert.ok(/if \(here\) el\.append\(icon, body\);\s*else el\.append\(icon, body, acts\);/.test(card), 'no Watch button or copy link here');
const css = chat.slice(chat.indexOf('function _chatEvCss('), chat.indexOf('function _chatEvTime('));
const css = chat.slice(chat.indexOf('function _chatEvCss('), chat.indexOf('function _chatEvTime('));
-assert.ok(/@container \(max-width:240px\)\{\.chat-ev\{grid-template-columns:auto minmax\(0,1fr\)\}\.chat-ev-acts\{grid-column:2/.test(css), 'narrow: buttons under the text');
+assert.ok(/@container \(max-width:260px\)\{\.chat-ev-cta\{padding:0;width:28px;/.test(css), 'narrow: the button is its icon alone, the words keep the row');
const studio = read('js/broadcast-studio.js');
const studio = read('js/broadcast-studio.js');
assert.ok(/if \(pv && pv\.paused && pv\.srcObject && pv\.style\.display !== 'none'\) pv\.play\(\)\.catch\(\(\) => \{\}\);/.test(studio), 'a paused self-view plays again');
assert.ok(/if \(pv && pv\.paused && pv\.srcObject && pv\.style\.display !== 'none'\) pv\.play\(\)\.catch\(\(\) => \{\}\);/.test(studio), 'a paused self-view plays again');