openvibers/openvibe.chat Commits

openvibe.chat's home says what Chat is and shows its real activity: this week's messages and people, the busiest stream chats (live ones first), public servers and rooms, how it works and questions, around global chat (#125)

OP OpenVibers committed 23 days ago · 71d9bdffbc · 6 files, +259 −12

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 52a480e..50cbe15 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -255,3 +255,35 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-spoiler { background: #6b7280; color: transparent; border-radius: 4px; padding: 0 2px; cursor: pointer; }
.oc-spoiler:not(.revealed):not(:focus) * { visibility: hidden; }
.oc-spoiler:focus, .oc-spoiler.revealed { background: rgba(127, 127, 127, 0.15); color: inherit; cursor: auto; }
++
++/* The front page around global chat (server/web/home.js): the heading and real numbers, stream chats, servers and
++ rooms, how it works, questions, the rest of the network. */
++.oc-hero { padding: 8px 0 12px; }
++.oc-eyebrow { display: inline-block; margin: 0; padding: 3px 12px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-accent); font-size: .78rem; font-weight: 700; letter-spacing: .3px; }
++.oc-hero h1 { margin: .5rem 0 .4rem; font-size: clamp(1.6rem, 4vw, 2.3rem); line-height: 1.15; letter-spacing: -.01em; }
++.oc-lede { margin: 0; max-width: 64ch; font-size: 1.05rem; color: var(--oc-muted); }
++.oc-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 1rem 0 0; }
++.oc-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 1.2rem 0 0; padding: 0; }
++.oc-stats > div { display: flex; flex-direction: column-reverse; min-width: 0; padding: 8px 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
++.oc-stats dt { margin: 0; font-size: .8rem; color: var(--oc-muted); }
++.oc-stats dd { margin: 0; font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
++.oc-room > h2 { margin: 1.4rem 0 .2rem; font-size: 1.3rem; }
++.oc-home-sec { margin-top: 40px; }
++.oc-home-sec h2 { margin: 0 0 .5rem; font-size: 1.3rem; }
++.oc-home-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
++.oc-home-head a { font-weight: 700; font-size: .92rem; }
++.oc-streams, .oc-more { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); }
++.oc-stream, .oc-more a { display: grid; gap: 2px; height: 100%; box-sizing: border-box; padding: 12px 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); color: var(--oc-text); text-decoration: none; }
++.oc-stream:hover, .oc-stream:focus-visible, .oc-more a:hover, .oc-more a:focus-visible { border-color: var(--oc-accent); }
++.oc-stream .oc-muted, .oc-more span { font-size: .88rem; color: var(--oc-muted); }
++.oc-badge-live { color: #fecaca; border-color: rgba(248, 113, 113, .6); background: rgba(127, 29, 29, .55); }
++.oc-steps { list-style: none; counter-reset: oc-step; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(230px, 100%), 1fr)); }
++.oc-steps li { counter-increment: oc-step; padding: 14px 16px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
++.oc-steps li::before { content: counter(oc-step); display: inline-grid; place-items: center; width: 26px; height: 26px; margin-bottom: .4rem; border-radius: 50%; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); font-weight: 800; font-size: .85rem; }
++.oc-steps h3 { margin: 0 0 .3rem; font-size: 1rem; }
++.oc-steps p { margin: 0; color: var(--oc-muted); font-size: .92rem; }
++.oc-faq { display: grid; gap: 8px; margin-top: 12px; }
++.oc-faq details { border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
++.oc-faq summary { cursor: pointer; padding: 11px 14px; font-weight: 700; }
++.oc-faq details[open] summary { border-bottom: 1px solid var(--oc-line); }
++.oc-faq p { margin: 0; padding: 11px 14px 13px; color: var(--oc-muted); }
ddiff --git a/server/web/discovery.js b/server/web/discovery.js
iindex 820cd6c..70aac80 100644
---- a/server/web/discovery.js
++++ b/server/web/discovery.js
@@@ -62,12 +62,12 @@ function newestRoomActivity(pages) {
function homeJsonLd(config) {
const site = String(config.web.baseUrl).replace(/\/+$/, '');
const name = 'OpenVibe.Chat';
-- const description = 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation, with rooms and direct messages.';
++ const description = 'Chat with everyone on OpenVibe, start a server with text and voice channels, message friends and call them. Free and open source.';
return [
seo.jsonLd.website({ name, url: site, description }),
seo.jsonLd.softwareApp({
name, url: site, description, category: 'SocialNetworkingApplication',
-- keywords: 'chat, rooms, direct messages, openvibe',
++ keywords: 'chat, chat rooms, community servers, voice channels, video calls, direct messages, AI chat members, openvibe',
}),
seo.jsonLd.webPage({ name: 'Global chat', url: `${site}/`, description, siteUrl: site }),
];
@@@ -91,7 +91,7 @@ function createDiscoveryRoutes({ config }) {
router.get('/llms.txt', (req, res) => {
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: 'OpenVibe.Chat',
-- summary: 'OpenVibe.Chat: the network-wide chat room of OpenVibe, plus its rooms and the direct messages of its members.',
++ summary: 'OpenVibe.Chat: global chat for the whole OpenVibe network, the chat of every stream on OpenVibe.Live, servers with text and voice channels, rooms, direct messages, calls and AI members.',
details: 'One OpenVibe account works here and on every other OpenVibe site. Anyone can read the global chat and the public rooms without an account; posting needs a signed-in person. The global chat and the public room pages are readable as plain HTML without JavaScript and update live over the site\'s own WebSocket. Direct messages, room settings and the API are per-person and are never listed here.',
sections: [
{ title: 'Start here', links: [
ddiff --git a/server/web/home.js b/server/web/home.js
nnew file mode 100644
iindex 0000000..cf0d893
---- /dev/null
++++ b/server/web/home.js
@@@ -0,0 +1,159 @@
++'use strict';
++/**
++ * The front page's real activity (openvibe.chat /): what Chat's own tables say happened, read once a minute and shared
++ * by every visitor. Nothing here is made up and nothing is about one reader: counts of chat messages this week and the
++ * people who wrote them, every message ever kept, the stream chats on OpenVibe.Live that were busiest this week (the
++ * live ones first), and the public servers and rooms. A part that is empty is left out of the page.
++ *
++ * Only chat lines count (no system lines, commands, tips or sounds), never deleted or filtered ones, and never the
++ * channel of a banned account. A person is counted once per week, by account or by anonymous id.
++ *
++ * activity() → { weekMessages, weekPeople, totalMessages, streams: [...], rooms: [...], servers: [...] }
++ * sections({ activity, signedIn, live }) → the HTML pieces the page places around global chat
++ */
++const db = require('../db/database');
++const { esc } = require('./layout');
++
++const TTL_MS = 60_000;
++const WEEK_MS = 7 * 24 * 3600 * 1000;
++const STREAMS_MAX = 8;
++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
++
++const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
++const n = (v) => Number(v || 0).toLocaleString('en-US');
++const plural = (v, one, many) => `${n(v)} ${Number(v) === 1 ? one : many}`;
++
++/** "3 hours ago" for a 'YYYY-MM-DD HH:MM:SS' UTC time. */
++function ago(ts) {
++ const t = Date.parse(/[TZ]/.test(String(ts)) ? String(ts) : `${String(ts).replace(' ', 'T')}Z`);
++ if (!Number.isFinite(t)) return '';
++ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
++ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} min ago`;
++ if (s < 86400) { const h = Math.floor(s / 3600); return `${h} hour${h === 1 ? '' : 's'} ago`; }
++ const d = Math.floor(s / 86400);
++ return `${d} day${d === 1 ? '' : 's'} ago`;
++}
++
++let cached = null; // { at, value }
++let running = null;
++
++async function read() {
++ const since = sqlTime(Date.now() - WEEK_MS);
++ const chatLine = "m.message_type = 'chat' AND COALESCE(m.is_deleted, 0) = 0 AND COALESCE(m.is_filtered, 0) = 0";
++ const week = await db.get(`SELECT COUNT(*) AS messages, COUNT(DISTINCT COALESCE(CAST(m.user_id AS text), m.anon_id)) AS people
++ FROM chat_messages m WHERE m.timestamp >= ? AND ${chatLine}`, [since]) || {};
++ const total = await db.get(`SELECT COUNT(*) AS n FROM chat_messages m WHERE ${chatLine}`) || {};
++ // The stream chats on Live that people wrote in this week, by the channel's owner (channel_user_id).
++ const busy = await db.all(`SELECT u.username, u.display_name, COUNT(*) AS messages,
++ COUNT(DISTINCT COALESCE(CAST(m.user_id AS text), m.anon_id)) AS people, MAX(m.timestamp) AS last
++ FROM chat_messages m JOIN ctx_users u ON u.id = m.channel_user_id
++ WHERE m.timestamp >= ? AND ${chatLine} AND COALESCE(m.is_global, 0) = 0 AND COALESCE(u.is_banned, 0) = 0
++ GROUP BY u.username, u.display_name ORDER BY messages DESC LIMIT ?`, [since, STREAMS_MAX * 2]);
++ const live = await db.all(`SELECT u.username, u.display_name, s.title, s.started_at FROM ctx_streams s JOIN ctx_users u ON u.id = s.user_id
++ WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0 ORDER BY s.started_at DESC LIMIT ?`, [STREAMS_MAX]);
++ const byName = new Map();
++ for (const r of live) {
++ if (!USERNAME_RE.test(String(r.username || ''))) continue;
++ byName.set(r.username, { username: r.username, display_name: r.display_name || r.username, live: true, title: r.title || null, messages: 0, people: 0, last: null });
++ }
++ for (const r of busy) {
++ if (!USERNAME_RE.test(String(r.username || ''))) continue;
++ const row = byName.get(r.username) || { username: r.username, display_name: r.display_name || r.username, live: false, title: null };
++ byName.set(r.username, { ...row, messages: Number(r.messages) || 0, people: Number(r.people) || 0, last: r.last || null });
++ }
++ // Live ones first, then by messages; a quiet channel that is not live (one or two lines) is not a "busy chat".
++ const streams = [...byName.values()].filter((s) => s.live || s.messages >= 3)
++ .sort((a, b) => (Number(b.live) - Number(a.live)) || (b.messages - a.messages)).slice(0, STREAMS_MAX);
++ let rooms = [];
++ let servers = [];
++ try { rooms = (await require('../rooms/rooms').list(null, { limit: 6 })).public.filter(Boolean); } catch { rooms = []; }
++ try { servers = (await require('../rooms/servers').list(null, { limit: 6 })).public.filter(Boolean); } catch { servers = []; }
++ return {
++ weekMessages: Number(week.messages) || 0, weekPeople: Number(week.people) || 0, totalMessages: Number(total.n) || 0,
++ streams, rooms, servers,
++ };
++}
++
++/** The activity, at most a minute old; a failed read gives the last good one, or an empty one (the parts hide). */
++async function activity() {
++ if (cached && Date.now() - cached.at < TTL_MS) return cached.value;
++ if (!running) {
++ running = read().then((value) => { cached = { at: Date.now(), value }; return value; })
++ .catch(() => (cached ? cached.value : { weekMessages: 0, weekPeople: 0, totalMessages: 0, streams: [], rooms: [], servers: [] }))
++ .finally(() => { running = null; });
++ }
++ return running;
++}
++
++const LIVE = 'https://openvibe.live';
++
++/** Questions and answers, each true of what runs today (the page shows them; the FAQPage JSON-LD repeats them). */
++const FAQ = [
++ { q: 'Do I need an account to read?', a: 'No. Global chat, public rooms and public servers read without an account. To write, sign in with your free OpenVibe account, the same one every OpenVibe site uses.' },
++ { q: 'How do I start a server?', a: 'Sign in, open Servers and give it a name. It starts with text and voice channels; add more, give people roles, and share an invite link. A server can be public or private.' },
++ { q: 'What are AI members?', a: 'An AI you add to a room or channel, with a name, a persona and a memory of that room. It answers when someone writes its @name, with OpenVibe\'s model or one from your own AI key.' },
++ { q: 'Can I send pictures?', a: 'Yes, in rooms and server channels. A picture goes to OpenVibe.Pics as yours, with its hidden location and camera details removed, and shows inline in the conversation.' },
++ { q: 'How many people fit in a call?', a: 'Up to eight in a voice or video call, with mute, camera and kick controls. Call one person from your messages, or open a call room.' },
++ { q: 'Who can message me?', a: 'You decide in your settings who may start a conversation with you and whether group conversations may add you. You can block anyone from a conversation.' },
++];
++
++/** The numbers under the heading: only those that are known and not zero. */
++function stats(a) {
++ const items = [
++ a.weekMessages ? [n(a.weekMessages), 'messages this week'] : null,
++ a.weekPeople ? [n(a.weekPeople), a.weekPeople === 1 ? 'person chatting this week' : 'people chatting this week'] : null,
++ a.totalMessages ? [n(a.totalMessages), 'messages in all'] : null,
++ a.servers.length ? [n(a.servers.length), a.servers.length === 1 ? 'public server' : 'public servers'] : null,
++ a.streams.some((s) => s.live) ? [n(a.streams.filter((s) => s.live).length), 'streams live now'] : null,
++ ].filter(Boolean);
++ if (!items.length) return '';
++ return `<dl class="oc-stats">${items.map(([v, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>`;
++}
++
++function streamsSection(a) {
++ if (!a.streams.length) return '';
++ const item = (s) => `<li><a class="oc-stream" href="${LIVE}/@${esc(s.username)}"><span class="oc-stream-name"><strong>${esc(s.display_name)}</strong>${s.live ? ' <span class="oc-badge oc-badge-live">Live now</span>' : ''}</span>
++<span class="oc-muted">${s.live && s.title ? `${esc(s.title)}${s.messages ? ' · ' : ''}` : ''}${s.messages ? `${plural(s.messages, 'message', 'messages')} from ${plural(s.people, 'person', 'people')} this week` : ''}${s.last && !s.live ? ` · last ${esc(ago(s.last))}` : ''}</span></a></li>`;
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-streams"><div class="oc-home-head"><h2 id="oc-h-streams">Stream chats on OpenVibe.Live</h2><a href="${LIVE}/">Who is live</a></div>
++<p class="oc-muted">Every channel on OpenVibe.Live has its own chat, run by OpenVibe.Chat. The busiest this week:</p>
++<ul class="oc-streams" role="list">${a.streams.map(item).join('')}</ul></section>`;
++}
++
++function placesSection(a) {
++ const servers = a.servers.map((s) => `<li><a class="oc-stream" href="/s/${esc(s.slug)}"><span class="oc-stream-name"><strong>${esc(s.name)}</strong></span><span class="oc-muted">${esc(s.description || plural(s.members, 'member', 'members'))}</span></a></li>`);
++ const rooms = a.rooms.map((r) => `<li><a class="oc-stream" href="/r/${esc(r.slug)}"><span class="oc-stream-name"><strong>${esc(r.name)}</strong></span><span class="oc-muted">${esc(r.topic || plural(r.members, 'member', 'members'))}${r.last_message_at ? ` · last ${esc(ago(r.last_message_at))}` : ''}</span></a></li>`);
++ return [
++ servers.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-servers"><div class="oc-home-head"><h2 id="oc-h-servers">Public servers</h2><a href="/servers">All servers</a></div><ul class="oc-streams" role="list">${servers.join('')}</ul></section>` : '',
++ rooms.length ? `<section class="oc-home-sec" aria-labelledby="oc-h-rooms"><div class="oc-home-head"><h2 id="oc-h-rooms">Public rooms</h2><a href="/rooms">All rooms</a></div><ul class="oc-streams" role="list">${rooms.join('')}</ul></section>` : '',
++ ].join('\n');
++}
++
++function stepsSection() {
++ const steps = [
++ ['Sign in once', 'Your OpenVibe account works on every OpenVibe site, and your chat settings follow it.'],
++ ['Start a server or a room', 'Name it, add text and voice channels, and choose public or invite only.'],
++ ['Bring people in', 'Share an invite link, give people roles, add an AI member, or start a call.'],
++ ];
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-how"><h2 id="oc-h-how">How it works</h2><ol class="oc-steps">${steps.map(([t, d]) => `<li><h3>${esc(t)}</h3><p>${esc(d)}</p></li>`).join('')}</ol></section>`;
++}
++
++function faqSection() {
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-faq"><h2 id="oc-h-faq">Questions</h2><div class="oc-faq">${FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div></section>`;
++}
++
++function moreSection() {
++ const more = [
++ [LIVE, 'OpenVibe.Live', 'Go live from your browser, and chat with your viewers.'],
++ ['https://openvibe.community', 'OpenVibe.Community', 'Forums and pastes for longer conversations.'],
++ ['https://openvibe.pics', 'OpenVibe.Pics', 'Where the pictures you send in chat live.'],
++ ['https://openvibe.games', 'OpenVibe.Games', 'Games to play with the people you chat with.'],
++ ];
++ return `<section class="oc-home-sec" aria-labelledby="oc-h-more"><h2 id="oc-h-more">More on OpenVibe</h2><ul class="oc-more" role="list">${more.map(([href, name, text]) => `<li><a href="${href}/"><strong>${esc(name)}</strong><span>${esc(text)}</span></a></li>`).join('')}</ul></section>`;
++}
++
++/** Everything around global chat: the numbers, then stream chats, servers and rooms, how it works, questions, more. */
++function sections(a) {
++ return { stats: stats(a), streams: streamsSection(a), places: placesSection(a), steps: stepsSection(), faq: faqSection(), more: moreSection() };
++}
++
++module.exports = { activity, sections, FAQ, _reset: () => { cached = null; } };
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 7f1daea..76efde0 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -99,7 +99,8 @@ function contentSecurityPolicy(nonce) {
function renderPage(o) {
const { config } = o;
if (!o.robots) throw new TypeError('renderPage needs explicit robots');
-- const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
++ // fullTitle: a page that names the site itself (the front page) takes no suffix.
++ const title = o.fullTitle || (o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME);
const head = sharedSeo.headTags({
title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
robots: o.robots, siteName: SITE_NAME, type: 'website', jsonLd: o.jsonLd,
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 3524159..3fe1b8a 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -39,6 +39,8 @@ const cache = require('openvibe-shared/cache-policy');
const { renderPage, contentSecurityPolicy, assetVersion, esc, SITE_NAME } = require('./layout');
const { createSessionRoutes } = require('./session');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
++const home = require('./home');
++const sharedSeo = require('openvibe-shared/seo');
const session = require('../auth/network-session');
const historyStore = require('../chat/history-store');
const dm = require('../chat/dm');
@@@ -236,19 +238,32 @@ function createWebRoutes({ config }) {
</form>`)
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ // Around the room: the real activity (server/web/home.js, read once a minute), how it works and questions.
++ const parts = home.sections(await home.activity());
++ const signedIn = actor.kind === 'user';
await page(req, res, 200, {
-- actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,
-- description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',
-- jsonLd: homeJsonLd(config),
-- page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },
-- body: `<section class="oc-room" aria-labelledby="oc-h">
--<h1 id="oc-h">Global chat</h1>
++ actor, fullTitle: 'OpenVibe.Chat: global chat, servers, rooms and calls', path: '/', active: 'global', robots: 'index, follow', script: true,
++ description: 'Chat with everyone on OpenVibe, start a server with text and voice channels, message friends and call them. Free and open source.',
++ jsonLd: [...homeJsonLd(config), sharedSeo.jsonLd.faq(home.FAQ)],
++ page: { view: 'global', latest: latest_id, signedIn, me: signedIn ? actor.user.username : null },
++ body: `<header class="oc-hero"><p class="oc-eyebrow">OpenVibe.Chat</p>
++<h1>Chat with everyone on OpenVibe, or start your own server</h1>
++<p class="oc-lede">Global chat for the whole network, the chat of every stream on OpenVibe.Live, servers with text and voice channels, rooms, private messages, calls of up to eight people, and AI members that join in. One free account, everywhere.</p>
++<p class="oc-hero-actions"><a class="oc-button" href="${signedIn ? '/servers#oc-new-server' : '/auth/login?next=%2Fservers'}">Start a server</a> <a class="oc-button oc-button--quiet" href="/rooms">Rooms</a> <a class="oc-button oc-button--quiet" href="${signedIn ? '/messages' : '/auth/login?next=%2Fmessages'}">Messages</a></p>
++${parts.stats}</header>
++<section class="oc-room" aria-labelledby="oc-h">
++<h2 id="oc-h">Global chat</h2>
<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
${error}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>
${composer}
</section>
--${whatChatDoes(actor.kind === 'user')}`,
++${parts.streams}
++${parts.places}
++${whatChatDoes(signedIn)}
++${parts.steps}
++${parts.faq}
++${parts.more}`,
showcase: true,
});
});
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 17a9854..7d9513b 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -31,7 +31,9 @@ t('boot', async () => {
t('global chat reads without JavaScript; signing in shows the composer', async () => {
const r = await req('GET', '/');
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<h1 id="oc-h">Global chat<\/h1>/);
++ assert.match(r.text, /<h2 id="oc-h">Global chat<\/h2>/);
++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1: what the site is');
++ assert.match(r.text, /<title>OpenVibe\.Chat: global chat, servers, rooms and calls<\/title>/);
assert.match(r.text, /Sign in with OpenVibe/);
assert.match(r.text, /<meta name="robots" content="index, follow">/);
assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');
@@@ -277,4 +279,42 @@ t('||spoilers|| render hidden on the pages: split before escaping, links inside
assert.match(css, /\.oc-spoiler:not\(\.revealed\):not\(:focus\) \* \{ visibility: hidden; \}/);
});
++t('the front page shows real activity: this week\'s numbers, the busiest stream chats (live first), questions with their FAQPage', async () => {
++ const db = require('../server/db/database');
++ require('../server/web/home')._reset();
++ const streamer = h.addUser('streamer_one', { subject: ids.newId('user') });
++ const quiet = h.addUser('quiet_one', { subject: ids.newId('user') });
++ const banned = h.addUser('banned_one', { subject: ids.newId('user') });
++ for (const u of [streamer, quiet, banned]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ await db.run('UPDATE ctx_users SET is_banned = 1 WHERE id = ?', [banned.id]);
++ const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
++ const old = new Date(Date.now() - 9 * 86400000).toISOString().replace('T', ' ').slice(0, 19);
++ const line = (channel, user, ts, extra = {}) => db.run(`INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, timestamp, channel_user_id, is_deleted)
++ VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`, [1, user.id, user.username, extra.text || 'hi', extra.type || 'chat', ts, channel.id, extra.deleted ? 1 : 0]);
++ await line(streamer, alice, now); await line(streamer, bob, now); await line(streamer, alice, now);
++ await line(streamer, bob, now, { deleted: true });
++ await line(streamer, carol, now, { type: 'system' });
++ await line(streamer, carol, old);
++ await line(quiet, alice, now);
++ await line(banned, alice, now); await line(banned, bob, now); await line(banned, carol, now);
++ await db.run("INSERT INTO ctx_streams (user_id, title, is_live, started_at) VALUES (?, 'Quiet but live', 1, ?)", [quiet.id, now]);
++ const r = await req('GET', '/');
++ assert.strictEqual(r.status, 200);
++ const streams = (/<section class="oc-home-sec" aria-labelledby="oc-h-streams">[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
++ assert.ok(streams.includes('href="https://openvibe.live/@streamer_one"') && streams.includes('3 messages from 2 people this week'), streams.slice(0, 600));
++ assert.ok(streams.indexOf('@quiet_one') !== -1 && streams.indexOf('@quiet_one') < streams.indexOf('@streamer_one'), 'a live stream comes first, however quiet');
++ assert.ok(streams.includes('Live now') && streams.includes('Quiet but live'));
++ assert.ok(!streams.includes('banned_one'), 'a banned channel is not shown');
++ assert.match(r.text, /<dt>messages this week<\/dt><dd>\d[\d,]*<\/dd>/, 'the week\'s count');
++ // Public rooms made earlier in this file are listed with their links; a part with nothing to show is left out.
++ const pub = await require('../server/rooms/rooms').list(null);
++ assert.strictEqual(r.text.includes('id="oc-h-rooms"'), pub.public.length > 0, 'the rooms part shows exactly when there are public rooms');
++ for (const room of pub.public.slice(0, 6)) assert.ok(r.text.includes(`href="/r/${room.slug}"`), `room ${room.slug} is linked`);
++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
++ const faq = ld.find((x) => x['@type'] === 'FAQPage');
++ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
++ assert.match(r.text, /<meta name="description" content="[^"]{50,160}">/);
++});
++
t.run(async () => { if (h && h.close) await h.close(); });