openvibers/openvibe.live Commits
Channel cards name the channel by its @handle (#779)
Changes
@@@ -1398,8 +1398,8 @@ function _renderHomePulse(p) {identity.className = 'pulse-fact-identity';const face = document.createElement('span');face.className = 'pulse-fact-face';-- const name = String(person.display_name || person.username || person.name || 'OpenVibe');-- face.textContent = name.charAt(0).toUpperCase();++ const name = String(person.username ? '@' + person.username : (person.display_name || person.name || 'OpenVibe'));++ face.textContent = String(person.display_name || person.username || person.name || 'O').charAt(0).toUpperCase();if (person.avatar_url) {const img = document.createElement('img');img.src = person.avatar_url;@@@ -1522,7 +1522,7 @@ function renderRecentlyOnline(containerId, streamers, append) {img.onerror = () => img.remove(); face.append(img);}const who = el('span', 'recent-streamer-who');-- const name = el('a', 'streamer-group-name', shown);++ const name = el('a', 'streamer-group-name', s.username ? '@' + s.username : shown);name.href = channelHref;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);@@@ -1867,7 +1867,7 @@ function streamCardHTML(s, isLive) {${ovLabelChips(s)}<div class="stream-card-streamer">${_avatarSpan(s.avatar_url, s.username, s.profile_color)}-- ${esc(s.username || 'Anonymous')}++ ${esc(s.username ? '@' + s.username : 'Anonymous')}${endedAgo}</div>${(isLive && s.description) ? `<div class="stream-card-desc" title="Click to expand" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('expanded')">${esc(s.description)}</div>` : ''}@@@ -2412,7 +2412,7 @@ function _renderHomeStar(data) {</div><div class="star-body"><div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>-- <h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>++ <h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${s.username ? esc('@' + s.username) : name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3><div class="star-chips">${chips}</div></div><div class="star-more" id="star-more" hidden>@@@ -135,7 +135,7 @@function meta(it, extra) {const row = el('div', { class: 'cf-meta' });if (it.channel) {-- const who = [avatar(it.channel), el('span', { class: 'cf-name', text: it.channel.display_name || it.channel.username })];++ const who = [avatar(it.channel), el('span', { class: 'cf-name', text: '@' + (it.channel.username || it.channel.display_name) })];row.append(it.channel.href ? spa(it.channel.href, { class: 'cf-chan' }, ...who) : el('span', { class: 'cf-chan' }, ...who));}const bits = el('span', { class: 'cf-facts' });@@@ -41,7 +41,7 @@sections.push(`<section class="dsc-sec dsc-sec--live"><h4><i class="fa-solid fa-circle live-dot"></i> Live right now</h4><div class="dsc-live">${d.live.slice(0, 3).map(s => `<a class="dsc-live-card" ${go(`/@${s.username}`)}><span class="dsc-live-thumb">${s.thumbnail_url ? `<img src="${esc(s.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-tower-broadcast"></i></span>'}<span class="dsc-live-pill">LIVE</span><span class="dsc-live-viewers"><i class="fa-solid fa-eye"></i> ${n(s.viewer_count)}</span></span>-- <span class="dsc-live-body">${av(s, 26)}<span><b>${esc(s.display_name || s.username)}</b><small>${esc(s.title || '')}</small></span></span>++ <span class="dsc-live-body">${av(s, 26)}<span><b>${esc('@' + (s.username || s.display_name))}</b><small>${esc(s.title || '')}</small></span></span></a>`).join('')}</div></section>`);}if ((d.clips || []).length) {@@@ -51,7 +51,7 @@const star = d.star ? `<a class="dsc-star" ${go(`/@${d.star.username}`)}><span class="dsc-star-badge">⭐ Star of the day</span>${av(d.star, 56)}-- <span class="dsc-star-body"><b>${esc(d.star.display_name)}${d.star.live ? ' <span class="dsc-live-pill">LIVE</span>' : ''}</b>${d.star.headline ? `<span class="dsc-star-head">${esc(d.star.headline)}</span>` : ''}${d.star.reason ? `<small>${esc(d.star.reason)}</small>` : ''}</span>++ <span class="dsc-star-body"><b>${esc('@' + (d.star.username || d.star.display_name))}${d.star.live ? ' <span class="dsc-live-pill">LIVE</span>' : ''}</b>${d.star.headline ? `<span class="dsc-star-head">${esc(d.star.headline)}</span>` : ''}${d.star.reason ? `<small>${esc(d.star.reason)}</small>` : ''}</span></a>` : '';const recaps = (d.recaps || []).length ? `<div class="dsc-recaps"><div class="dsc-recaps-head"><i class="fa-solid fa-clipboard-list"></i> Fresh after-show reports</div>${d.recaps.slice(0, 4).map(r => `<a class="dsc-recap" ${go(`/recap/${r.stream_id}`)}>@@@ -62,7 +62,7 @@}if ((d.similar || []).length) {sections.push(`<section class="dsc-sec dsc-sec--people"><h4><i class="fa-solid fa-people-group"></i> Streamers to check out</h4><div class="dsc-people">${d.similar.slice(0, 6).map(p => `-- <a class="dsc-person" ${go(`/@${p.username}`)}>${av(p, 44)}<b>${esc(p.display_name || p.username)}</b><small>${p.live ? '<span class="dsc-live-dot"></span> live now' : (p.last_live_at ? `live ${esc(ago(p.last_live_at))}` : '')}${p.followers ? ` · ${n(p.followers)} follower${p.followers === 1 ? '' : 's'}` : ''}</small>${p.same_category && p.category ? `<em>${esc(p.category)}</em>` : ''}</a>`).join('')}</div></section>`);++ <a class="dsc-person" ${go(`/@${p.username}`)}>${av(p, 44)}<b>${esc('@' + (p.username || p.display_name))}</b><small>${p.live ? '<span class="dsc-live-dot"></span> live now' : (p.last_live_at ? `live ${esc(ago(p.last_live_at))}` : '')}${p.followers ? ` · ${n(p.followers)} follower${p.followers === 1 ? '' : 's'}` : ''}</small>${p.same_category && p.category ? `<em>${esc(p.category)}</em>` : ''}</a>`).join('')}</div></section>`);}if (!sections.length && !side) { host.innerHTML = ''; return; }@@@ -144,7 +144,7 @@<h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3><a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}-- <b>${escText(s.display_name || s.username)}</b>++ <b>${escText('@' + (s.username || s.display_name || ''))}</b>${s.category ? `<span class="hf-tag"${s.ai_category && s.ai_category === s.category ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${s.ai_category && s.ai_category === s.category ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}</a>@@@ -732,7 +732,7 @@ button{font:inherit}</div><div class="info"><div class="title">${esc(s.title||"Untitled stream")}</div>-- <div class="meta">${esc(s.display_name||s.username||"OpenVibe.Live")}</div>++ <div class="meta">${esc("@" + (s.username||s.display_name||"OpenVibe.Live"))}</div></div></a>`;}@@@ -56,7 +56,7 @@function show(d) {injectCSS();var c = ensureContainer();-- var name = d.display_name || d.username || 'Someone';++ var name = d.username ? '@' + d.username : (d.display_name || 'Someone');var href = BASE + '/@' + encodeURIComponent(d.username || '');var letter = (name[0] || '?').toUpperCase();var av = d.avatar_urlnnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * A card that opens /@name is named by its @handle (the owner, 2026-11-10): the offline++ * discover board's live cards, star card and "Streamers to check out" rows. Display names++ * alone would read as someone else's name while the address says @username.++ *++ * node test/at-handle.test.js++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const vm = require('node:vm');++const { parseHTML } = require('linkedom');++++const source = fs.readFileSync(require('node:path').join(__dirname, '../public/js/context-cards.js'), 'utf8');++const { document } = parseHTML('<div id="ch-discover"></div>');++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const fixture = {++ channel: null,++ live: [{ username: 'alice', display_name: 'Alice Display', title: 'Night shoot', viewer_count: 7, thumbnail_url: null, avatar_url: null, profile_color: null }],++ clips: [],++ star: { username: 'starly', display_name: 'Star Display', headline: 'Still here', reason: 'Every day', live: false, avatar_url: null, profile_color: null },++ recaps: [],++ similar: [{ username: 'bob', display_name: 'Bob Display', followers: 3, live: false, last_live_at: null, avatar_url: null, profile_color: null }],++};++const context = {++ document,++ window: {},++ esc,++ timeAgo: () => '2h ago',++ ovImgW: null,++ videoHref: (kind, id) => `/${kind}/${id}`,++ handleLinkClick: () => true,++ api: async () => fixture,++ encodeURIComponent,++ Number, String, JSON, Promise,++};++context.window = context;++vm.createContext(context);++vm.runInContext(source, context);++++(async () => {++const host = document.getElementById('ch-discover');++await context.window.renderOfflineDiscover('x', host);++const html = host.innerHTML;++for (const handle of ['@alice', '@starly', '@bob']) {++ assert.ok(html.includes(`<b>${handle}</b>`), `a card names the channel ${handle}`);++}++assert.ok(!html.includes('Alice Display') && !html.includes('Star Display') && !html.includes('Bob Display'), 'no bare display name names a channel');++process.stdout.write('at-handle (discover board): all checks passed\n');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -17,7 +17,7 @@ context.render({ goals: [{ title: 'Do not show', current_amount: 1, target_amoundailyFinished: 2 });const cards = [...document.querySelectorAll('.pulse-fact-card')];assert.equal(cards.length, 2);--assert.match(cards[0].textContent, /LIVE NOW.*Ann.*12.*viewers watching/s);++assert.match(cards[0].textContent, /LIVE NOW.*@ann.*12.*viewers watching/s);assert.match(cards[1].textContent, /TODAY'S QUEST.*2.*people finished today/s);assert.ok(!document.body.textContent.includes('Do not show'));assert.ok(!document.body.textContent.includes('Ben'));
@@ -1398,8 +1398,8 @@ function _renderHomePulse(p) {
@@ -1398,8 +1398,8 @@ function _renderHomePulse(p) {
identity.className = 'pulse-fact-identity';
identity.className = 'pulse-fact-identity';
const face = document.createElement('span');
const face = document.createElement('span');
face.className = 'pulse-fact-face';
face.className = 'pulse-fact-face';
- const name = String(person.display_name || person.username || person.name || 'OpenVibe');
- face.textContent = name.charAt(0).toUpperCase();
+ const name = String(person.username ? '@' + person.username : (person.display_name || person.name || 'OpenVibe'));
+ face.textContent = String(person.display_name || person.username || person.name || 'O').charAt(0).toUpperCase();
if (person.avatar_url) {
if (person.avatar_url) {
const img = document.createElement('img');
const img = document.createElement('img');
img.src = person.avatar_url;
img.src = person.avatar_url;
@@ -1522,7 +1522,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1522,7 +1522,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
img.onerror = () => img.remove(); face.append(img);
img.onerror = () => img.remove(); face.append(img);
}
}
const who = el('span', 'recent-streamer-who');
const who = el('span', 'recent-streamer-who');
- const name = el('a', 'streamer-group-name', shown);
+ const name = el('a', 'streamer-group-name', s.username ? '@' + s.username : shown);
name.href = channelHref;
name.href = channelHref;
const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : 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);
if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);
@@ -1867,7 +1867,7 @@ function streamCardHTML(s, isLive) {
@@ -1867,7 +1867,7 @@ function streamCardHTML(s, isLive) {
${ovLabelChips(s)}
${ovLabelChips(s)}
<div class="stream-card-streamer">
<div class="stream-card-streamer">
${_avatarSpan(s.avatar_url, s.username, s.profile_color)}
${_avatarSpan(s.avatar_url, s.username, s.profile_color)}
- ${esc(s.username || 'Anonymous')}
+ ${esc(s.username ? '@' + s.username : 'Anonymous')}
${endedAgo}
${endedAgo}
</div>
</div>
${(isLive && s.description) ? `<div class="stream-card-desc" title="Click to expand" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('expanded')">${esc(s.description)}</div>` : ''}
${(isLive && s.description) ? `<div class="stream-card-desc" title="Click to expand" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('expanded')">${esc(s.description)}</div>` : ''}
@@ -2412,7 +2412,7 @@ function _renderHomeStar(data) {
@@ -2412,7 +2412,7 @@ function _renderHomeStar(data) {
</div>
</div>
<div class="star-body">
<div class="star-body">
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
<div class="star-kicker"><i class="fa-solid fa-star"></i> ${s.rotates ? 'Star of the day' : 'Featured streamer'}</div>
- <h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
+ <h3 class="star-name"><a href="${esc(path)}" onclick="return handleLinkClick(event, '${esc(path)}')">${s.username ? esc('@' + s.username) : name}</a>${foreign && lang.code === 'ja' ? '<span class="star-jp">OpenVibeの看板配信者 — ようこそ!</span>' : ''}</h3>
<div class="star-chips">${chips}</div>
<div class="star-chips">${chips}</div>
</div>
</div>
<div class="star-more" id="star-more" hidden>
<div class="star-more" id="star-more" hidden>
@@ -135,7 +135,7 @@
@@ -135,7 +135,7 @@
function meta(it, extra) {
function meta(it, extra) {
const row = el('div', { class: 'cf-meta' });
const row = el('div', { class: 'cf-meta' });
if (it.channel) {
if (it.channel) {
- const who = [avatar(it.channel), el('span', { class: 'cf-name', text: it.channel.display_name || it.channel.username })];
+ const who = [avatar(it.channel), el('span', { class: 'cf-name', text: '@' + (it.channel.username || it.channel.display_name) })];
row.append(it.channel.href ? spa(it.channel.href, { class: 'cf-chan' }, ...who) : el('span', { class: 'cf-chan' }, ...who));
row.append(it.channel.href ? spa(it.channel.href, { class: 'cf-chan' }, ...who) : el('span', { class: 'cf-chan' }, ...who));
}
}
const bits = el('span', { class: 'cf-facts' });
const bits = el('span', { class: 'cf-facts' });
@@ -41,7 +41,7 @@
@@ -41,7 +41,7 @@
sections.push(`<section class="dsc-sec dsc-sec--live"><h4><i class="fa-solid fa-circle live-dot"></i> Live right now</h4><div class="dsc-live">${d.live.slice(0, 3).map(s => `
sections.push(`<section class="dsc-sec dsc-sec--live"><h4><i class="fa-solid fa-circle live-dot"></i> Live right now</h4><div class="dsc-live">${d.live.slice(0, 3).map(s => `
<a class="dsc-live-card" ${go(`/@${s.username}`)}>
<a class="dsc-live-card" ${go(`/@${s.username}`)}>
<span class="dsc-live-thumb">${s.thumbnail_url ? `<img src="${esc(s.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-tower-broadcast"></i></span>'}<span class="dsc-live-pill">LIVE</span><span class="dsc-live-viewers"><i class="fa-solid fa-eye"></i> ${n(s.viewer_count)}</span></span>
<span class="dsc-live-thumb">${s.thumbnail_url ? `<img src="${esc(s.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-tower-broadcast"></i></span>'}<span class="dsc-live-pill">LIVE</span><span class="dsc-live-viewers"><i class="fa-solid fa-eye"></i> ${n(s.viewer_count)}</span></span>
- <span class="dsc-live-body">${av(s, 26)}<span><b>${esc(s.display_name || s.username)}</b><small>${esc(s.title || '')}</small></span></span>
+ <span class="dsc-live-body">${av(s, 26)}<span><b>${esc('@' + (s.username || s.display_name))}</b><small>${esc(s.title || '')}</small></span></span>
</a>`).join('')}</div></section>`);
</a>`).join('')}</div></section>`);
}
}
if ((d.clips || []).length) {
if ((d.clips || []).length) {
@@ -51,7 +51,7 @@
@@ -51,7 +51,7 @@
const star = d.star ? `<a class="dsc-star" ${go(`/@${d.star.username}`)}>
const star = d.star ? `<a class="dsc-star" ${go(`/@${d.star.username}`)}>
<span class="dsc-star-badge">⭐ Star of the day</span>
<span class="dsc-star-badge">⭐ Star of the day</span>
${av(d.star, 56)}
${av(d.star, 56)}
- <span class="dsc-star-body"><b>${esc(d.star.display_name)}${d.star.live ? ' <span class="dsc-live-pill">LIVE</span>' : ''}</b>${d.star.headline ? `<span class="dsc-star-head">${esc(d.star.headline)}</span>` : ''}${d.star.reason ? `<small>${esc(d.star.reason)}</small>` : ''}</span>
+ <span class="dsc-star-body"><b>${esc('@' + (d.star.username || d.star.display_name))}${d.star.live ? ' <span class="dsc-live-pill">LIVE</span>' : ''}</b>${d.star.headline ? `<span class="dsc-star-head">${esc(d.star.headline)}</span>` : ''}${d.star.reason ? `<small>${esc(d.star.reason)}</small>` : ''}</span>
</a>` : '';
</a>` : '';
const recaps = (d.recaps || []).length ? `<div class="dsc-recaps"><div class="dsc-recaps-head"><i class="fa-solid fa-clipboard-list"></i> Fresh after-show reports</div>${d.recaps.slice(0, 4).map(r => `
const recaps = (d.recaps || []).length ? `<div class="dsc-recaps"><div class="dsc-recaps-head"><i class="fa-solid fa-clipboard-list"></i> Fresh after-show reports</div>${d.recaps.slice(0, 4).map(r => `
<a class="dsc-recap" ${go(`/recap/${r.stream_id}`)}>
<a class="dsc-recap" ${go(`/recap/${r.stream_id}`)}>
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
}
}
if ((d.similar || []).length) {
if ((d.similar || []).length) {
sections.push(`<section class="dsc-sec dsc-sec--people"><h4><i class="fa-solid fa-people-group"></i> Streamers to check out</h4><div class="dsc-people">${d.similar.slice(0, 6).map(p => `
sections.push(`<section class="dsc-sec dsc-sec--people"><h4><i class="fa-solid fa-people-group"></i> Streamers to check out</h4><div class="dsc-people">${d.similar.slice(0, 6).map(p => `
- <a class="dsc-person" ${go(`/@${p.username}`)}>${av(p, 44)}<b>${esc(p.display_name || p.username)}</b><small>${p.live ? '<span class="dsc-live-dot"></span> live now' : (p.last_live_at ? `live ${esc(ago(p.last_live_at))}` : '')}${p.followers ? ` · ${n(p.followers)} follower${p.followers === 1 ? '' : 's'}` : ''}</small>${p.same_category && p.category ? `<em>${esc(p.category)}</em>` : ''}</a>`).join('')}</div></section>`);
+ <a class="dsc-person" ${go(`/@${p.username}`)}>${av(p, 44)}<b>${esc('@' + (p.username || p.display_name))}</b><small>${p.live ? '<span class="dsc-live-dot"></span> live now' : (p.last_live_at ? `live ${esc(ago(p.last_live_at))}` : '')}${p.followers ? ` · ${n(p.followers)} follower${p.followers === 1 ? '' : 's'}` : ''}</small>${p.same_category && p.category ? `<em>${esc(p.category)}</em>` : ''}</a>`).join('')}</div></section>`);
}
}
if (!sections.length && !side) { host.innerHTML = ''; return; }
if (!sections.length && !side) { host.innerHTML = ''; return; }
@@ -144,7 +144,7 @@
@@ -144,7 +144,7 @@
<h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>
<h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>
<a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
<a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
- <b>${escText(s.display_name || s.username)}</b>
+ <b>${escText('@' + (s.username || s.display_name || ''))}</b>
${s.category ? `<span class="hf-tag"${s.ai_category && s.ai_category === s.category ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${s.ai_category && s.ai_category === s.category ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
${s.category ? `<span class="hf-tag"${s.ai_category && s.ai_category === s.category ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${s.ai_category && s.ai_category === s.category ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
</a>
</a>
@@ -732,7 +732,7 @@ button{font:inherit}
@@ -732,7 +732,7 @@ button{font:inherit}
</div>
</div>
<div class="info">
<div class="info">
<div class="title">${esc(s.title||"Untitled stream")}</div>
<div class="title">${esc(s.title||"Untitled stream")}</div>
- <div class="meta">${esc(s.display_name||s.username||"OpenVibe.Live")}</div>
+ <div class="meta">${esc("@" + (s.username||s.display_name||"OpenVibe.Live"))}</div>
</div>
</div>
</a>`;
</a>`;
}
}
@@ -56,7 +56,7 @@
@@ -56,7 +56,7 @@
function show(d) {
function show(d) {
injectCSS();
injectCSS();
var c = ensureContainer();
var c = ensureContainer();
- var name = d.display_name || d.username || 'Someone';
+ var name = d.username ? '@' + d.username : (d.display_name || 'Someone');
var href = BASE + '/@' + encodeURIComponent(d.username || '');
var href = BASE + '/@' + encodeURIComponent(d.username || '');
var letter = (name[0] || '?').toUpperCase();
var letter = (name[0] || '?').toUpperCase();
var av = d.avatar_url
var av = d.avatar_url
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * A card that opens /@name is named by its @handle (the owner, 2026-11-10): the offline
+ * discover board's live cards, star card and "Streamers to check out" rows. Display names
+ * alone would read as someone else's name while the address says @username.
+ *
+ * node test/at-handle.test.js
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const { parseHTML } = require('linkedom');
+
+const source = fs.readFileSync(require('node:path').join(__dirname, '../public/js/context-cards.js'), 'utf8');
+const { document } = parseHTML('<div id="ch-discover"></div>');
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const fixture = {
+ channel: null,
+ live: [{ username: 'alice', display_name: 'Alice Display', title: 'Night shoot', viewer_count: 7, thumbnail_url: null, avatar_url: null, profile_color: null }],
+ clips: [],
+ star: { username: 'starly', display_name: 'Star Display', headline: 'Still here', reason: 'Every day', live: false, avatar_url: null, profile_color: null },
+ recaps: [],
+ similar: [{ username: 'bob', display_name: 'Bob Display', followers: 3, live: false, last_live_at: null, avatar_url: null, profile_color: null }],
+};
+const context = {
+ document,
+ window: {},
+ esc,
+ timeAgo: () => '2h ago',
+ ovImgW: null,
+ videoHref: (kind, id) => `/${kind}/${id}`,
+ handleLinkClick: () => true,
+ api: async () => fixture,
+ encodeURIComponent,
+ Number, String, JSON, Promise,
+};
+context.window = context;
+vm.createContext(context);
+vm.runInContext(source, context);
+
+(async () => {
+const host = document.getElementById('ch-discover');
+await context.window.renderOfflineDiscover('x', host);
+const html = host.innerHTML;
+for (const handle of ['@alice', '@starly', '@bob']) {
+ assert.ok(html.includes(`<b>${handle}</b>`), `a card names the channel ${handle}`);
+}
+assert.ok(!html.includes('Alice Display') && !html.includes('Star Display') && !html.includes('Bob Display'), 'no bare display name names a channel');
+process.stdout.write('at-handle (discover board): all checks passed\n');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -17,7 +17,7 @@ context.render({ goals: [{ title: 'Do not show', current_amount: 1, target_amoun
@@ -17,7 +17,7 @@ context.render({ goals: [{ title: 'Do not show', current_amount: 1, target_amoun
dailyFinished: 2 });
dailyFinished: 2 });
const cards = [...document.querySelectorAll('.pulse-fact-card')];
const cards = [...document.querySelectorAll('.pulse-fact-card')];
assert.equal(cards.length, 2);
assert.equal(cards.length, 2);
-assert.match(cards[0].textContent, /LIVE NOW.*Ann.*12.*viewers watching/s);
+assert.match(cards[0].textContent, /LIVE NOW.*@ann.*12.*viewers watching/s);
assert.match(cards[1].textContent, /TODAY'S QUEST.*2.*people finished today/s);
assert.match(cards[1].textContent, /TODAY'S QUEST.*2.*people finished today/s);
assert.ok(!document.body.textContent.includes('Do not show'));
assert.ok(!document.body.textContent.includes('Do not show'));
assert.ok(!document.body.textContent.includes('Ben'));
assert.ok(!document.body.textContent.includes('Ben'));