openvibers/openvibe.live Commits
Channel social links, and the offline channel page reorganized for wide screens.
Changes
@@@ -27,7 +27,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0","sharp": "^0.33.0",@@@ -2859,9 +2859,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.28.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.1",-- "integrity": "sha512-AV39sRi9hm8QETRLM1c6q7nrU8XWPUZzCxZP+h7Cho+LsJ47szHtMk3YdCQHT8+aSC5Hpu68EU8sCvbagR+a0g==",++ "version": "1.29.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.1",++ "integrity": "sha512-HI1aySq1Lipd/GRGI0exL98LVQ2UXWeIlYk3TaTgMsjV/S1L5tTyaI+tNKJ8q8zKpMNdXj0hSflENBE97i/CxQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -46,7 +46,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0","sharp": "^0.33.0",@@@ -56,13 +56,19 @@/* ── Offline channel: discover board — a full-width dashboard grid ── */#ch-discover { width: 100%; }.dsc { width: 100%; max-width: none; margin: 18px 0 0; padding: 0 clamp(4px, 1.5vw, 12px); box-sizing: border-box; text-align: left;-- display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); grid-template-areas: "head head" "live side" "clips side" "people people"; gap: 14px 18px; align-items: start; }--.dsc-head { grid-area: head; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin: 0; }++ display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, 1fr); gap: 18px 22px; align-items: start; }++/* Two independent columns: the main one (live, clips, streamers) and the side (star, reports). They share no rows, so a++ missing section never leaves a hole the other column's height stretches open. */++.dsc-main { display: grid; gap: 20px; min-width: 0; align-content: start; }++.dsc-side { display: grid; gap: 12px; min-width: 0; align-content: start; }++.dsc--noside, .dsc--sideonly { grid-template-columns: 1fr; }++.dsc--sideonly .dsc-side { grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); }++.dsc-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin: 0; }.dsc-title { font-size: 1rem; font-weight: 900; color: var(--text-primary); }.dsc-title i { color: var(--accent); margin-right: 6px; }.dsc-sub { font-size: 0.8rem; color: var(--text-muted); }.dsc-sec { margin: 0; min-width: 0; }--.dsc-sec--live { grid-area: live; } .dsc-sec--clips { grid-area: clips; } .dsc-sec--side { grid-area: side; display: grid; gap: 12px; align-content: start; } .dsc-sec--people { grid-area: people; }++/* Sections stack inside .dsc-main (no named grid areas: they would place items on implicit lines). */.dsc-sec h4 { margin: 0 0 8px; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }.dsc-sec h4 i { color: var(--accent); }.dsc-sec h4 .muted { text-transform: none; letter-spacing: 0; font-weight: 600; }@@@ -102,9 +108,8 @@.dsc-person em { grid-column: 2; font-style: normal; font-size: 0.62rem; color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }.dsc-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 3px; animation: ccMark 1.4s ease-in-out infinite; }/* No live streams and no clips to show: let the side column use the full width, two-up. */--.dsc--noleft { grid-template-columns: 1fr; grid-template-areas: "head" "side" "people"; }--.dsc--noleft .dsc-sec--side { grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); align-items: start; }--@media (max-width: 980px) { .dsc { grid-template-columns: 1fr; grid-template-areas: "head" "live" "clips" "side" "people"; } .dsc--noleft { grid-template-areas: "head" "side" "people"; } .dsc--noleft .dsc-sec--side { grid-template-columns: 1fr; } }++@media (max-width: 980px) { .dsc { grid-template-columns: 1fr; } .dsc--sideonly .dsc-side { grid-template-columns: 1fr; } }++.dsc-people { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }@media (max-width: 640px) { .dsc-live { grid-template-columns: 1fr 1fr; } .dsc-clips { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } }/* ═══════════════════════════════════════════════════════════════@@@ -118,17 +123,17 @@═══════════════════════════════════════════════════════════════ */.dsc-wrap { width: 100%; margin: 14px 0 0; }.dsc-toggle {-- position: relative; overflow: hidden; isolation: isolate;-- display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 11px;-- width: 100%; padding: 10px 13px; cursor: pointer; text-align: left; font: inherit;-- color: var(--text-primary); border-radius: 14px;-- border: 1px solid var(--border);-- background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--bg-card)), var(--bg-secondary));-- transition: border-color .22s, box-shadow .22s, transform .18s;++ position: relative; isolation: isolate;++ display: grid; grid-template-columns: auto minmax(0, max-content) 1fr auto auto; align-items: center; gap: 12px;++ width: 100%; padding: 6px 2px 10px; cursor: pointer; text-align: left; font: inherit; color: var(--text-primary);++ background: none; border: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));++ transition: border-color .22s;}--.dsc-toggle:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 8px 22px rgba(0,0,0,0.26); }--.dsc-toggle:active { transform: scale(0.995); }--.dsc-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++/* A section heading of the offline page (not a card): the rule under it runs the full width. */++.dsc-toggle::after { content: ''; grid-column: 3; height: 1px; align-self: center;++ background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 40%, transparent), transparent 85%); }++.dsc-toggle:hover { border-bottom-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }++.dsc-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }.dsc-toggle-ico {width: 32px; height: 32px; flex: none; border-radius: 10px; display: grid; place-items: center;font-size: 0.8rem; color: var(--accent);@@@ -136,7 +141,7 @@border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);}.dsc-toggle-text { min-width: 0; display: grid; gap: 1px; }--.dsc-toggle-text b { font-size: 0.88rem; font-weight: 850; letter-spacing: -0.01em; }++.dsc-toggle-text b { font-size: 1rem; font-weight: 850; letter-spacing: -0.01em; }/* One line, clipped — the sub-line is context, never a reason for the row to grow taller. */.dsc-toggle-text small { font-size: 0.72rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.dsc-toggle-count {@@@ -160,7 +165,8 @@.dsc-wrap .dsc { margin-top: 10px; }@media (max-width: 560px) {-- .dsc-toggle { gap: 9px; padding: 9px 11px; }++ .dsc-toggle { gap: 9px; grid-template-columns: auto minmax(0, 1fr) auto auto; }++ .dsc-toggle::after { display: none; }.dsc-toggle-ico { width: 29px; height: 29px; font-size: 0.74rem; }.dsc-toggle-text b { font-size: 0.83rem; }.dsc-toggle-text small { font-size: 0.68rem; }@@@ -977,7 +977,7 @@.ch-offline-screen:has(.ch-offline-default) { aspect-ratio: auto; max-height: none; height: auto; min-height: 0; align-items: stretch; }.ch-offline-screen:has(.ch-offline-default) .ch-offline-default { justify-content: flex-start; padding-top: clamp(20px, 5vh, 44px); padding-bottom: clamp(18px, 4vh, 36px); }.ch-offline-explore { width: 100%; margin-top: 22px; }--.off-cyc { width: 100%; max-width: 760px; margin: 0 auto; padding: 0 clamp(10px, 3vw, 24px); box-sizing: border-box; }++.off-cyc { width: 100%; max-width: 980px; margin: 0 auto; padding: 0 clamp(10px, 3vw, 24px); box-sizing: border-box; }.off-cyc-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }.off-cyc-title { display: inline-flex; align-items: center; gap: 7px; font-size: 0.82rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-primary); }.off-cyc-ranges { display: inline-flex; gap: 4px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 999px; padding: 3px; }@@@ -1111,10 +1111,13 @@#ch-weather-widget-offline { max-width: 500px; }.ch-offline-area .stream-main { padding-bottom: 14px; }.ch-offline-header {-- display: flex; align-items: center; gap: 20px;-- padding: 24px; background: var(--bg-card); border-bottom: 1px solid var(--border);++ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;++ padding: 14px clamp(12px, 2vw, 20px); background: var(--bg-card); border-bottom: 1px solid var(--border);}--.ch-avatar-lg { width: 80px; height: 80px; font-size: 2rem; }++.ch-avatar-lg { width: 64px; height: 64px; font-size: 1.6rem; }++.ch-offline-header .ch-username { margin-bottom: 2px; }++.ch-offline-header .ch-description:empty { display: none; }++.ch-offline-header .ch-actions { display: flex; gap: 6px; margin-left: auto; }.ch-username { color: var(--text-muted); margin: 0 0 4px; font-size: 0.9rem; }.ch-description { color: var(--text-secondary); margin: 0 0 8px; font-size: 0.9rem; }.ch-meta { display: flex; gap: 12px; align-items: center; font-size: 0.85rem; }@@@ -1252,6 +1255,14 @@.ai-tl-tr-text { color: var(--text-secondary); }.ai-tl-sentinel { text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; }.about-ai-overview { margin: 0 0 16px; }++/* In About the overview is part of the page, not a card: no fill, no frame, a thin accent rule beside it. */++.ai-tl-overview-card.about-ai-overview { background: transparent; border: none; border-radius: 0; padding: 2px 0 2px 14px;++ border-left: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 55%, transparent); }++.ai-tl-overview-card.about-ai-overview .ai-tl-overview-label { margin-bottom: 5px; }++++/* Wide screens: the tabs sit in the main column (app-channel.js _placeBelowFold) and use its full width. */++.channel-below-fold.is-in-main { padding: 4px 0 28px; }++.channel-below-fold.is-in-main > .container { max-width: none; width: auto; margin: 0; padding: 0 18px; }.ch-about-ai-toggle { display: flex; flex-direction: column; gap: 3px; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px; background: var(--bg-elevated, rgba(255,255,255,0.03)); border: 1px solid var(--border, rgba(255,255,255,0.07)); }.ch-about-ai-hint { font-size: 0.78rem; }.ai-tl-side-label { font-size: 0.95rem; font-weight: 800; color: var(--text-primary); margin: 26px 0 14px; display: flex; align-items: center; gap: 9px; padding-bottom: 7px; border-bottom: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 30%, transparent); }@@@ -1389,3 +1400,59 @@ body.controls-docked .messenger-toggle { z-index: 1100; }.ch-game-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--text-muted); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; }.ch-game-badge:hover { color: var(--text-primary); border-color: var(--accent); }.ch-game-badge[hidden] { display: none; }++++/* ── Social links (public/js/channel-socials.js) ── */++.ch-social-pills { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 8px; }++.ch-social-pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px 4px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 700;++ color: var(--text-primary); text-decoration: none; background: color-mix(in srgb, var(--sc) 12%, transparent);++ border: 1px solid color-mix(in srgb, var(--sc) 38%, transparent); transition: background .15s, transform .1s; max-width: 220px; }++.ch-social-pill i { color: var(--sc); font-size: 0.95em; }++.ch-social-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ch-social-pill:hover { background: color-mix(in srgb, var(--sc) 22%, transparent); transform: translateY(-1px); }++.ch-socials { margin: 4px 0 18px; }++.ch-socials-head { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 10px; display: flex; gap: 6px; align-items: center; }++.ch-socials-head i { color: var(--accent); }++.ch-socials-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }++.ch-social-card { border-radius: 14px; background: var(--bg-card); border: 1px solid color-mix(in srgb, var(--sc) 28%, var(--border)); overflow: hidden; min-width: 0;++ box-shadow: inset 0 3px 0 color-mix(in srgb, var(--sc) 70%, transparent); transition: border-color .15s, transform .15s; }++.ch-social-card:hover { border-color: color-mix(in srgb, var(--sc) 60%, var(--border)); transform: translateY(-2px); }++.ch-social-card.is-live { border-color: #e0245e; }++.ch-social-card-top { display: grid; grid-template-columns: auto 1fr auto auto; gap: 10px; align-items: center; padding: 12px 14px; color: var(--text-primary); text-decoration: none; }++.ch-social-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--sc); background: color-mix(in srgb, var(--sc) 14%, transparent); font-size: 1.05rem; }++.ch-social-id { display: grid; min-width: 0; }++.ch-social-id b { font-size: 0.9rem; }++.ch-social-id small { color: var(--text-muted); font-size: 0.76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ch-social-live { font-size: 0.62rem; font-weight: 900; letter-spacing: 0.08em; padding: 2px 7px; border-radius: 5px; background: #e0245e; color: #fff; }++.ch-social-out { color: var(--text-muted); font-size: 0.72rem; }++.ch-social-body:empty { display: none; }++.ch-social-body { padding: 0 14px 12px; display: grid; gap: 8px; }++.ch-social-meta { display: flex; gap: 10px; align-items: center; min-width: 0; }++.ch-social-meta > div { display: grid; min-width: 0; }++.ch-social-meta b { font-size: 0.84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ch-social-meta small { color: var(--text-muted); font-size: 0.74rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.ch-social-avatar { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; flex: none; }++.ch-social-livethumb { position: relative; display: block; border-radius: 10px; overflow: hidden; aspect-ratio: 16 / 9; background: #000; }++.ch-social-livethumb img { width: 100%; height: 100%; object-fit: cover; display: block; }++.ch-social-livethumb span { position: absolute; right: 8px; bottom: 8px; font-size: 0.7rem; font-weight: 800; padding: 2px 7px; border-radius: 5px; background: rgba(0,0,0,.72); color: #fff; }++.ch-social-livetitle { margin: 0; font-size: 0.82rem; font-weight: 700; }++.ch-social-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.ch-social-items a { display: flex; gap: 8px; align-items: flex-start; padding: 7px 8px; border-radius: 9px; background: var(--bg-secondary); color: var(--text-secondary); text-decoration: none; font-size: 0.8rem; line-height: 1.4; }++.ch-social-items a:hover { color: var(--text-primary); }++.ch-social-items img { width: 64px; height: 36px; object-fit: cover; border-radius: 6px; flex: none; }++.ch-social-items span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }++.ch-social-items time { display: block; color: var(--text-muted); font-size: 0.7rem; margin-top: 2px; }++.ch-social-embed { width: 100%; height: 520px; border: 0; border-radius: 10px; background: transparent; }++.ch-about-edit-socials { margin: 14px 0; display: grid; gap: 8px; }++.ch-social-connected { display: grid; gap: 6px; }++.ch-social-conn { display: flex; align-items: center; gap: 8px; font-size: 0.84rem; }++.ch-social-conn i { color: var(--sc); }++.ch-social-hint { margin: 0; font-size: 0.8rem; }++.ch-social-rows { display: grid; gap: 6px; }++.ch-social-row { display: grid; grid-template-columns: 130px minmax(0, 2fr) minmax(0, 1fr) auto auto auto; gap: 6px; align-items: center; }++.ch-social-row select, .ch-social-row input[type=text], .ch-social-row input:not([type]) { min-width: 0; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); }++.ch-social-prev { display: inline-flex; gap: 4px; align-items: center; font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }++@media (max-width: 640px) {++ .ch-social-row { grid-template-columns: 1fr 1fr; }++ .ch-social-row input[data-f=url] { grid-column: 1 / -1; }++ .ch-socials-grid { grid-template-columns: 1fr; }++}@@@ -14,7 +14,6 @@"/js/ov-odometer.js","/js/ov-density.js","/js/home-fx.js",-- "/js/home-tour.js","/shared/tooltip.js"],"idle": [@@@ -117,6 +116,7 @@"uploads"],"js": [++ "/js/channel-socials.js","/js/app-channel.js"]},@@@ -177,6 +177,11 @@"aivCloneChatter"]},++ "homeTour": {++ "js": [++ "/js/home-tour.js"++ ]++ },"guide": {"css": ["/css/ov-guide.css"@@@ -245,6 +245,7 @@<h1 id="ch-display-name-offline">Channel</h1><p class="ch-username" id="ch-username-offline">username</p><p class="ch-description" id="ch-description-offline"></p>++ <div class="ch-social-pills" id="ch-socials-offline" hidden></div><div class="ch-meta"><span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span><a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>@@@ -668,6 +668,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI// ── LIVE STATE ──document.getElementById('ch-live-area').style.display = '';document.getElementById('ch-offline-area').style.display = 'none';++ _placeBelowFold();// Populate streamer info bar (below video). Avatar image (letter fallback)// + display name both link to the channel; the @handle is dropped.@@@ -747,6 +748,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI// ── OFFLINE STATE ──document.getElementById('ch-live-area').style.display = 'none';document.getElementById('ch-offline-area').style.display = '';++ _placeBelowFold();// Populate offline headerdocument.getElementById('ch-avatar-offline').textContent = (ch.username || '?')[0].toUpperCase();@@@ -754,6 +756,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI_activeChannelIsOwnerRank = !!ch.is_owner;document.getElementById('ch-username-offline').textContent = '@' + ch.username;document.getElementById('ch-description-offline').textContent = ch.description || '';++ if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;_loadGameBadge(ch.username);_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);@@@ -1738,6 +1741,28 @@ function _effAiOverviewShow(ch) {return !_channelHasBio(ch); // auto}++// Wide screens: the tabs (About, Videos, …) live inside the visible main column, which scrolls next to a full-height++// chat, so nothing leaves an empty band beside the chat or a gutter around a capped container. Narrow screens keep++// them after the whole layout (chat before tabs, as the stacked layout reads).++const _BELOW_FOLD_WIDE = window.matchMedia('(min-width: 1181px)');++let _belowFoldHome = null;++function _placeBelowFold() {++ const fold = document.querySelector('#page-channel .channel-below-fold');++ if (!fold) return;++ if (!_belowFoldHome) { _belowFoldHome = document.createComment('channel-below-fold'); fold.parentNode.insertBefore(_belowFoldHome, fold); }++ const live = document.getElementById('ch-live-area');++ const area = live && live.style.display !== 'none' ? live : document.getElementById('ch-offline-area');++ const main = area && area.querySelector('.stream-main');++ if (_BELOW_FOLD_WIDE.matches && main) {++ if (fold.parentNode !== main) main.appendChild(fold);++ fold.classList.add('is-in-main');++ } else if (fold.previousSibling !== _belowFoldHome) {++ _belowFoldHome.parentNode.insertBefore(fold, _belowFoldHome.nextSibling);++ fold.classList.remove('is-in-main');++ } else fold.classList.remove('is-in-main');++}++if (_BELOW_FOLD_WIDE.addEventListener) _BELOW_FOLD_WIDE.addEventListener('change', _placeBelowFold);++// About tab visibility + default open tab depend on whether the streamer has any// About content (bio/panels) or weather enabled.function _resetChannelTabs(ch) {@@@ -1747,7 +1772,7 @@ function _resetChannelTabs(ch) {const bio = (ch.bio || ch.description || '').trim();let panels = [];try { panels = typeof ch.panels === 'string' ? JSON.parse(ch.panels || '[]') : (ch.panels || []); } catch { panels = []; }-- hasAbout = !!bio || (Array.isArray(panels) && panels.length > 0);++ hasAbout = !!bio || (Array.isArray(panels) && panels.length > 0) || (Array.isArray(ch.social_links) && ch.social_links.length > 0);}// The AI overview at the top of About also counts as About content, so the tab shows// even when the streamer hasn't written a bio (per the auto/show/hide preference).@@@ -2188,6 +2213,8 @@ function _applyChannelTabMeta(data) {/* ── About tab: inline live panel editor (Twitch/Kick-style under-stream area) ── */let _aboutPanels = []; // working array of panelslet _aboutBio = '';++let _aboutSocials = []; // the channel's social links as viewers see them (server/social/links.js)++let _aboutCh = null;let _aboutIsOwner = false; // is the viewer the streamer?let _aboutCanEdit = false; // can the viewer edit (streamer, or an allowed mod)?let _aboutEditMode = false;@@@ -2221,6 +2248,9 @@ function _renderChannelAbout(ch) {_aboutPanels = (Array.isArray(panels) ? panels : []).map(_normalizeAboutPanel);_aboutAiOverview = ch.ai_overview || '';_aboutAiPref = ch.ai_overview_pref || (ch.hide_ai_overview ? 'hide' : 'auto');++ _aboutSocials = Array.isArray(ch.social_links) ? ch.social_links : [];++ _aboutCh = ch;++ if (window.ChannelSocials) ChannelSocials.reset();_aboutEditMode = false;_renderAboutView();}@@@ -2246,7 +2276,7 @@ function _renderAboutView() {const host = document.getElementById('ch-about-content');if (!host) return;const aiHtml = _aboutAiOverviewHTML();-- const hasContent = _aboutBio || _aboutPanels.length || aiHtml;++ const hasContent = _aboutBio || _aboutPanels.length || aiHtml || _aboutSocials.length;let html = '';if (!hasContent) {html += _aboutCanEdit@@@ -2257,8 +2287,10 @@ function _renderAboutView() {}html += aiHtml; // AI overview card leads the About tabif (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;++ html += '<section class="ch-socials" id="ch-about-socials" hidden></section>';html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';host.innerHTML = html;++ if (window.ChannelSocials) ChannelSocials.cards(document.getElementById('ch-about-socials'), _aboutSocials, currentChannelUsername);_fillWeatherPanels();void _fillBioTranslation();}@@@ -2657,9 +2689,11 @@ function _renderAboutEdit() {<label class="ch-edit-label">Bio</label><textarea id="ch-about-bio-edit" rows="3" placeholder="Tell viewers about yourself…" oninput="_aboutBio=this.value;_markAboutDirty()">${esc(_aboutBio)}</textarea></div>++ <div class="ch-about-edit-socials" id="ch-about-socials-edit"></div><div class="ch-about-panels ch-about-panels-edit" id="ch-about-panels-edit">${_aboutPanels.map((p, i) => _aboutPanelEditHTML(p, i)).join('')}</div>`;++ if (window.ChannelSocials && _aboutCh) ChannelSocials.editor(document.getElementById('ch-about-socials-edit'), _aboutCh, _markAboutDirty);_wireAboutDrag();_updateAboutSaveBtn();if (_aboutIsOwner && _aboutChannelId === null) _loadAboutModsSetting();@@@ -2734,7 +2768,7 @@ function addAboutPanel(type) {_markAboutDirty();}function removeAboutPanel(i) { _aboutPanels.splice(i, 1); _renderAboutEdit(); _markAboutDirty(); }--function cancelAboutEdit() { _aboutEditMode = false; _aboutDirty = false; _renderAboutView(); }++function cancelAboutEdit() { _aboutEditMode = false; _aboutDirty = false; if (window.ChannelSocials) ChannelSocials.reset(); _renderAboutView(); }async function uploadAboutPanelImage(i, input) {const file = input.files && input.files[0];if (!file) return;@@@ -2753,9 +2787,15 @@ async function saveAboutInline() {try {// Targets the channel by username, so an allowed mod writes to the STREAMER's// channel (not their own). Server enforces the edit permission.-- await api(`/streams/channel/${encodeURIComponent(currentChannelUsername)}/about`, {-- method: 'PUT', body: { bio: _aboutBio, panels: JSON.stringify(_aboutPanels), ai_overview_pref: _aboutAiPref, hide_ai_overview: _aboutAiPref === 'hide' ? 1 : 0 },++ const socials = window.ChannelSocials ? ChannelSocials.payload() : undefined;++ const saved = await api(`/streams/channel/${encodeURIComponent(currentChannelUsername)}/about`, {++ method: 'PUT', body: { bio: _aboutBio, panels: JSON.stringify(_aboutPanels), ai_overview_pref: _aboutAiPref, hide_ai_overview: _aboutAiPref === 'hide' ? 1 : 0, ...(socials ? { social_links: socials } : {}) },});++ if (saved && Array.isArray(saved.social_links)) {++ _aboutSocials = saved.social_links;++ if (_aboutCh) { _aboutCh.social_links = saved.social_links; if (saved.social_links_editor) _aboutCh.social_links_editor = saved.social_links_editor; }++ if (window.ChannelSocials) { ChannelSocials.reset(); ChannelSocials.pills(document.getElementById('ch-socials-offline'), _aboutSocials); }++ }if (_aboutIsOwner && currentUser) currentUser.bio = _aboutBio;_aboutEditMode = false;_renderAboutView();@@@ -1162,7 +1162,20 @@ function _watchIntroSighting() {* .container on the home page — but the changelog lives in a later one, so "moved to the bottom"* actually landed them in the middle of the page, above Recently Online.*/++/** The restream tour (public/js/home-tour.js, feature homeTour) sits below the fold: its code loads when the mount nears the viewport. */++let _homeTourArmed = false;++function _lazyHomeTour() {++ const el = document.getElementById('home-tour-mount');++ if (_homeTourArmed || !el || !window.ov) return;++ _homeTourArmed = true;++ const go = () => window.ov.load('homeTour').catch(() => { _homeTourArmed = false; });++ if (!('IntersectionObserver' in window)) return go();++ const io = new IntersectionObserver((ents) => { if (ents.some((e) => e.isIntersecting)) { io.disconnect(); go(); } }, { rootMargin: '800px 0px' });++ io.observe(el);++}++function demoteHomeIntroSections() {++ _lazyHomeTour();_watchIntroSighting();if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;const containers = document.querySelectorAll('#page-home > .container, #page-home .container');nnew file mode 100644@@@ -0,0 +1,149 @@++/*++ OpenVibe.Live — a channel's social links (server/social/): brand-coloured pills on the offline screen, rich cards in++ About (live status, latest posts or videos, repositories, page previews, loaded when they scroll into view), and the++ editor in About's edit mode. Links come from GET /api/streams/channel/:username (social_links); previews from++ /api/social/preview/:username/:index.++*/++(function () {++ 'use strict';++ const escH = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ const ago = (iso) => {++ const t = Date.parse(iso || ''); if (!t) return '';++ const s = Math.max(1, Math.round((Date.now() - t) / 1000));++ for (const [n, u] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) if (s >= n) return `${Math.floor(s / n)}${u} ago`;++ return 'just now';++ };++ const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));++++ /** Pills: one line of links (the offline screen). */++ function pills(host, links) {++ if (!host) return;++ host.innerHTML = (links || []).map((l) => `<a class="ch-social-pill" style="--sc:${escH(l.color)}" href="${escH(l.url)}" target="_blank" rel="noopener me" title="${escH(l.name)}${l.auto ? ' (connected)' : ''}"><i class="${escH(l.icon)}" aria-hidden="true"></i><span>${escH(label(l))}</span></a>`).join('');++ host.hidden = !(links || []).length;++ }++++ /** Rich cards (About): the card is a link at once; its preview fills in when it scrolls into view. */++ function cards(host, links, username) {++ if (!host) return;++ if (!(links || []).length) { host.innerHTML = ''; host.hidden = true; return; }++ host.hidden = false;++ host.innerHTML = `<div class="ch-socials-head"><i class="fa-solid fa-link"></i> Links</div><div class="ch-socials-grid">${links.map((l, i) => `++ <article class="ch-social-card" style="--sc:${escH(l.color)}" data-i="${i}">++ <a class="ch-social-card-top" href="${escH(l.url)}" target="_blank" rel="noopener me">++ <span class="ch-social-ico"><i class="${escH(l.icon)}" aria-hidden="true"></i></span>++ <span class="ch-social-id"><b>${escH(l.name)}</b><small>${escH(label(l))}</small></span>++ <span class="ch-social-live" hidden>LIVE</span>++ <i class="fa-solid fa-arrow-up-right-from-square ch-social-out" aria-hidden="true"></i>++ </a>++ <div class="ch-social-body"></div>++ </article>`).join('')}</div>`;++ const load = (card) => {++ if (card.dataset.loaded) return;++ card.dataset.loaded = '1';++ const i = card.dataset.i;++ fetch(`/api/social/preview/${encodeURIComponent(username)}/${i}`, { headers: { Accept: 'application/json' } })++ .then((r) => (r.ok ? r.json() : null)).then((p) => fill(card, links[i], p)).catch(() => {});++ };++ const all = host.querySelectorAll('.ch-social-card');++ if ('IntersectionObserver' in window) {++ const io = new IntersectionObserver((ents) => { for (const e of ents) if (e.isIntersecting) { io.unobserve(e.target); load(e.target); } }, { rootMargin: '300px' });++ all.forEach((c) => io.observe(c));++ } else all.forEach(load);++ }++++ function fill(card, link, p) {++ if (!p || p.error || p.disabled) return;++ const body = card.querySelector('.ch-social-body');++ const bits = [];++ if (p.live && p.live.live) {++ card.querySelector('.ch-social-live').hidden = false;++ card.classList.add('is-live');++ if (p.live.thumbnail) bits.push(`<a class="ch-social-livethumb" href="${escH(link.url)}" target="_blank" rel="noopener"><img src="${escH(p.live.thumbnail)}" alt="" loading="lazy">${p.live.viewers != null ? `<span><i class="fa-solid fa-eye"></i> ${escH(Number(p.live.viewers).toLocaleString())}</span>` : ''}</a>`);++ if (p.live.title) bits.push(`<p class="ch-social-livetitle">${escH(p.live.title)}</p>`);++ }++ if (p.title || p.subtitle) {++ bits.push(`<div class="ch-social-meta">${p.image && !(p.live && p.live.live) ? `<img class="ch-social-avatar" src="${escH(p.image)}" alt="" loading="lazy">` : ''}<div>${p.title ? `<b>${escH(p.title)}</b>` : ''}${p.subtitle ? `<small>${escH(p.subtitle)}</small>` : ''}</div></div>`);++ }++ if ((p.items || []).length) {++ bits.push(`<ul class="ch-social-items">${p.items.slice(0, 3).map((it) => `<li><a href="${escH(it.url || link.url)}" target="_blank" rel="noopener">${it.image ? `<img src="${escH(it.image)}" alt="" loading="lazy">` : ''}<span>${escH(it.text || '')}${it.at ? `<time>${escH(ago(it.at))}</time>` : ''}</span></a></li>`).join('')}</ul>`);++ }++ if (p.embed) bits.push(`<button type="button" class="btn btn-sm btn-outline ch-social-embed-btn"><i class="fa-brands fa-x-twitter"></i> Show recent posts</button>`);++ body.innerHTML = bits.join('');++ const btn = body.querySelector('.ch-social-embed-btn');++ if (btn) btn.addEventListener('click', () => {++ const f = document.createElement('iframe');++ f.className = 'ch-social-embed';++ f.src = p.embed; f.loading = 'lazy'; f.title = `Posts by ${label(link)} on X`;++ f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox');++ btn.replaceWith(f);++ });++ }++++ // ── Editor (About → edit) ─────────────────────────────++ let _state = null; // { links: [{ kind, url, label, preview }], hidden_auto: [], connected: [], missing: [] }++ let _catalog = null;++ let _onDirty = () => {};++++ function editorState(ch) {++ const saved = (ch.social_links || []).filter((l) => !l.auto).map((l) => ({ kind: l.kind, url: l.url, label: l.label || '', preview: l.preview !== false }));++ const ed = ch.social_links_editor || {};++ _state = { links: saved, hidden_auto: (ed.hidden_auto || []).slice(), connected: ed.connected || [], missing: ed.restreams_without_link || [] };++ return _state;++ }++++ async function editor(host, ch, onDirty) {++ if (!host) return;++ _onDirty = onDirty || (() => {});++ if (!_state) editorState(ch);++ if (!_catalog) { try { _catalog = (await (await fetch('/api/social/catalog')).json()).platforms; } catch { _catalog = []; } }++ const opts = (sel) => _catalog.map((p) => `<option value="${escH(p.kind)}"${p.kind === sel ? ' selected' : ''}>${escH(p.name)}</option>`).join('');++ const s = _state;++ host.innerHTML = `++ <label class="ch-edit-label"><i class="fa-solid fa-link"></i> Social links</label>++ ${s.connected.length ? `<div class="ch-social-connected">${s.connected.map((c) => `++ <label class="ch-social-conn" style="--sc:${escH(c.color)}"><input type="checkbox" data-auto="${escH(c.kind)}" ${s.hidden_auto.includes(c.kind) ? '' : 'checked'}>++ <i class="${escH(c.icon)}"></i> Show your connected ${escH(c.name)} (${escH(label(c))})</label>`).join('')}</div>` : ''}++ ${s.missing.length ? `<p class="muted ch-social-hint">You restream to ${s.missing.map((k) => escH((_catalog.find((p) => p.kind === k) || {}).name || k)).join(', ')}: add ${s.missing.length === 1 ? 'that channel' : 'those channels'} below so viewers can find you there.</p>` : ''}++ <div class="ch-social-rows">${s.links.map((l, i) => `++ <div class="ch-social-row" data-i="${i}">++ <select data-f="kind" aria-label="Platform">${opts(l.kind)}</select>++ <input data-f="url" value="${escH(l.url)}" placeholder="Profile URL or @handle" maxlength="300" aria-label="Link">++ <input data-f="label" value="${escH(l.label)}" placeholder="Label (optional)" maxlength="40" aria-label="Label">++ <label class="ch-social-prev" title="Show a live preview (posts, videos, live status)"><input type="checkbox" data-f="preview" ${l.preview ? 'checked' : ''}> Preview</label>++ <button type="button" class="btn btn-sm btn-outline" data-act="up" title="Move up" ${i === 0 ? 'disabled' : ''}><i class="fa-solid fa-arrow-up"></i></button>++ <button type="button" class="btn btn-sm btn-outline" data-act="del" title="Remove"><i class="fa-solid fa-trash"></i></button>++ </div>`).join('')}</div>++ <button type="button" class="btn btn-sm btn-outline" data-act="add" ${s.links.length >= 16 ? 'disabled' : ''}><i class="fa-solid fa-plus"></i> Add a link</button>`;++ host.onchange = host.oninput = (e) => {++ const t = e.target;++ if (t.dataset.auto) { const k = t.dataset.auto; s.hidden_auto = t.checked ? s.hidden_auto.filter((x) => x !== k) : [...new Set([...s.hidden_auto, k])]; _onDirty(); return; }++ const row = t.closest('.ch-social-row'); if (!row) return;++ const l = s.links[Number(row.dataset.i)]; const f = t.dataset.f;++ if (!l || !f) return;++ l[f] = t.type === 'checkbox' ? t.checked : t.value;++ if (f === 'url' && /^https?:\/\//i.test(t.value)) {++ // Pick the platform from a pasted URL.++ try { const host2 = new URL(t.value).hostname.replace(/^www\./, ''); const hit = _catalog.find((p) => ({ 'x.com': 'x', 'twitter.com': 'x', 'bsky.app': 'bluesky', 'youtu.be': 'youtube' }[host2] || host2.split('.')[0]) === p.kind); if (hit && hit.kind !== l.kind) { l.kind = hit.kind; row.querySelector('select').value = hit.kind; } } catch { /* typing */ }++ }++ _onDirty();++ };++ host.onclick = (e) => {++ const b = e.target.closest('button[data-act]'); if (!b) return;++ const act = b.dataset.act; const row = b.closest('.ch-social-row'); const i = row ? Number(row.dataset.i) : -1;++ if (act === 'add') s.links.push({ kind: 'custom', url: '', label: '', preview: true });++ if (act === 'del') s.links.splice(i, 1);++ if (act === 'up' && i > 0) { const [m] = s.links.splice(i, 1); s.links.splice(i - 1, 0, m); }++ _onDirty();++ editor(host, ch, _onDirty);++ };++ }++++ /** What About's save sends. */++ function payload() {++ if (!_state) return undefined;++ return { links: _state.links.filter((l) => String(l.url || '').trim()), hidden_auto: _state.hidden_auto };++ }++ function reset() { _state = null; }++++ window.ChannelSocials = { pills, cards, editor, payload, reset };++})();@@@ -137,6 +137,7 @@if (!d) { host.innerHTML = ''; return; }const name = d.channel ? d.channel.display_name : 'this channel';const sections = [];++ let side = '';if ((d.live || []).length) {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}`)}>@@@ -158,14 +159,13 @@<span class="cc-grade" style="--g:${GRADE[r.grade] || GRADE.B}">${esc(r.grade)}</span><span class="dsc-recap-body"><b>${esc(r.headline || r.title)}</b><small>${esc(r.display_name)} · ${esc(ago(r.ended_at))} · ${durWords(r.duration_seconds)} · peak ${n(r.peak_viewers)}${r.chat_messages ? ` · ${n(r.chat_messages)} chat lines` : ''}</small></span></a>`).join('')}</div>` : '';-- sections.push(`<section class="dsc-sec dsc-sec--side">${star}${recaps}</section>`);++ side = `<aside class="dsc-side">${star}${recaps}</aside>`;}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>`);}-- if (!sections.length) { host.innerHTML = ''; return; }-- const noLeft = !(d.live || []).length && !(d.clips || []).length;++ if (!sections.length && !side) { host.innerHTML = ''; return; }// This board is a whole second page of content sitting under an offline channel. Someone// who came for this streamer should meet a single line about it, not scroll past six@@@ -189,7 +189,7 @@<span class="dsc-toggle-chev" aria-hidden="true"><i class="fa-solid fa-chevron-down"></i></span></button><div class="dsc-body" id="dsc-body"${startOpen ? '' : ' hidden'}>-- <div class="dsc${noLeft ? ' dsc--noleft' : ''}">${sections.join('')}</div>++ <div class="dsc${side ? '' : ' dsc--noside'}${sections.length ? '' : ' dsc--sideonly'}">${sections.length ? `<div class="dsc-main">${sections.join('')}</div>` : ''}${side}</div></div></div>`;@@@ -2345,6 +2345,10 @@ function initDb() {if (!cols.includes('offline_css')) {database.exec('ALTER TABLE channels ADD COLUMN offline_css TEXT');}++ // Social links shown on the offline screen and in About (server/social/links.js): { links, hidden_auto } JSON.++ if (!cols.includes('social_links')) {++ database.exec('ALTER TABLE channels ADD COLUMN social_links TEXT');++ }} catch (e) { console.warn('[DB] channel offline-screen migration:', e.message); }// Migrate: add control_config_id to streams for stream-scoped control profiles@@@ -4277,7 +4281,7 @@ function updateChannel(userId, fields) {const updates = [];const params = [];for (const [key, val] of Object.entries(fields)) {-- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled'].includes(key)) {++ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links'].includes(key)) {updates.push(`${key} = ?`);params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);}@@@ -698,6 +698,8 @@ app.use('/api/chat-ai', require('./ai/chat-ai-routes'));app.use('/api/easter-egg', require('./ai/easter-egg-routes'));app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)++app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)++app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);app.use('/api/comments', commentRoutes);app.use('/api/controls', controlRoutes);app.use('/api/onvif', onvifRoutes);nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * A channel's social links: the platforms it restreams to (from its OAuth platform connections), plus the links the++ * streamer adds (X, Bluesky, Instagram, TikTok, YouTube, Discord, GitHub, … or any custom https link). Stored as++ * JSON on channels.social_links; shown as pills on the offline screen and as rich cards in the About tab.++ *++ * cleanSocialLinks(input) → the list to store, or null when it is not a valid list++ * channelSocialLinks(channel, db) → what viewers see: saved links first, then connected platforms not already++ * listed and not hidden (channels.social_links.hidden_auto)++ */++++const MAX_LINKS = 16;++const MAX_LABEL = 40;++++// kind → how to recognise, normalise and show it. `hosts` are the only hosts a link of that kind may point at.++const PLATFORMS = {++ twitch: { name: 'Twitch', icon: 'fa-brands fa-twitch', color: '#9146ff', hosts: ['twitch.tv'], url: (h) => `https://www.twitch.tv/${h}`, handle: /^\/([A-Za-z0-9_]{2,25})\/?$/ },++ youtube: { name: 'YouTube', icon: 'fa-brands fa-youtube', color: '#ff0033', hosts: ['youtube.com', 'youtu.be'], url: (h) => `https://www.youtube.com/${h.startsWith('@') || h.startsWith('channel/') ? h : `@${h}`}`, handle: /^\/((?:@[A-Za-z0-9._-]{3,30})|(?:channel\/UC[A-Za-z0-9_-]{22})|(?:c\/[A-Za-z0-9._-]{1,60}))\/?$/ },++ kick: { name: 'Kick', icon: 'fa-brands fa-kickstarter-k', color: '#53fc18', hosts: ['kick.com'], url: (h) => `https://kick.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,25})\/?$/ },++ x: { name: 'X', icon: 'fa-brands fa-x-twitter', color: '#e7e9ea', hosts: ['x.com', 'twitter.com'], url: (h) => `https://x.com/${h}`, handle: /^\/([A-Za-z0-9_]{1,15})\/?$/ },++ bluesky: { name: 'Bluesky', icon: 'fa-brands fa-bluesky', color: '#1185fe', hosts: ['bsky.app'], url: (h) => `https://bsky.app/profile/${h}`, handle: /^\/profile\/([A-Za-z0-9.-]{3,253})\/?$/ },++ instagram: { name: 'Instagram', icon: 'fa-brands fa-instagram', color: '#e1306c', hosts: ['instagram.com'], url: (h) => `https://www.instagram.com/${h}`, handle: /^\/([A-Za-z0-9._]{1,30})\/?$/ },++ tiktok: { name: 'TikTok', icon: 'fa-brands fa-tiktok', color: '#25f4ee', hosts: ['tiktok.com'], url: (h) => `https://www.tiktok.com/@${h.replace(/^@/, '')}`, handle: /^\/@([A-Za-z0-9._]{2,24})\/?$/ },++ discord: { name: 'Discord', icon: 'fa-brands fa-discord', color: '#5865f2', hosts: ['discord.gg', 'discord.com'], url: (h) => `https://discord.gg/${h}`, handle: /^\/(?:invite\/)?([A-Za-z0-9-]{2,32})\/?$/ },++ github: { name: 'GitHub', icon: 'fa-brands fa-github', color: '#e6edf3', hosts: ['github.com'], url: (h) => `https://github.com/${h}`, handle: /^\/([A-Za-z0-9-]{1,39})\/?$/ },++ reddit: { name: 'Reddit', icon: 'fa-brands fa-reddit-alien', color: '#ff4500', hosts: ['reddit.com'], url: (h) => `https://www.reddit.com/user/${h}`, handle: /^\/(?:user|u)\/([A-Za-z0-9_-]{3,20})\/?$/ },++ threads: { name: 'Threads', icon: 'fa-brands fa-threads', color: '#e7e9ea', hosts: ['threads.net', 'threads.com'], url: (h) => `https://www.threads.net/@${h.replace(/^@/, '')}`, handle: /^\/@([A-Za-z0-9._]{1,30})\/?$/ },++ facebook: { name: 'Facebook', icon: 'fa-brands fa-facebook', color: '#1877f2', hosts: ['facebook.com', 'fb.com'], url: (h) => `https://www.facebook.com/${h}`, handle: /^\/([A-Za-z0-9.]{5,50})\/?$/ },++ rumble: { name: 'Rumble', icon: 'fa-solid fa-play', color: '#85c742', hosts: ['rumble.com'], url: (h) => `https://rumble.com/c/${h}`, handle: /^\/c\/([A-Za-z0-9_-]{2,50})\/?$/ },++ patreon: { name: 'Patreon', icon: 'fa-brands fa-patreon', color: '#ff424d', hosts: ['patreon.com'], url: (h) => `https://www.patreon.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },++ spotify: { name: 'Spotify', icon: 'fa-brands fa-spotify', color: '#1db954', hosts: ['open.spotify.com'], url: null, handle: null },++ soundcloud:{ name: 'SoundCloud',icon: 'fa-brands fa-soundcloud',color: '#ff5500', hosts: ['soundcloud.com'], url: (h) => `https://soundcloud.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },++ linkedin: { name: 'LinkedIn', icon: 'fa-brands fa-linkedin', color: '#0a66c2', hosts: ['linkedin.com'], url: (h) => `https://www.linkedin.com/in/${h}`, handle: /^\/in\/([A-Za-z0-9-]{3,100})\/?$/ },++ mastodon: { name: 'Mastodon', icon: 'fa-brands fa-mastodon', color: '#6364ff', hosts: null, url: null, handle: /^\/@([A-Za-z0-9_]{1,30})\/?$/ },++ website: { name: 'Website', icon: 'fa-solid fa-globe', color: '#60a5fa', hosts: null, url: null, handle: null },++ custom: { name: 'Link', icon: 'fa-solid fa-link', color: '#94a3b8', hosts: null, url: null, handle: null },++};++const AUTO_KINDS = ['twitch', 'youtube', 'kick'];++++const hostMatches = (host, hosts) => hosts.some((h) => host === h || host.endsWith(`.${h}`));++++/** Which platform a URL belongs to (by host), or null. */++function kindOfUrl(u) {++ let url; try { url = new URL(u); } catch { return null; }++ const host = url.hostname.toLowerCase();++ for (const [kind, p] of Object.entries(PLATFORMS)) if (p.hosts && hostMatches(host, p.hosts)) return kind;++ return null;++}++++/**++ * One link, validated: { kind, url, handle, label, preview }. Accepts a URL, or a handle for a platform with a++ * handle pattern ("@name", "name"). https only; a platform link must point at that platform's hosts; custom and++ * website take any public https URL (the preview fetch checks the address, server/net/egress.js).++ */++function cleanLink(raw) {++ if (!raw || typeof raw !== 'object') return null;++ let kind = String(raw.kind || '').toLowerCase();++ let value = String(raw.url || raw.handle || '').trim().slice(0, 300);++ if (!value) return null;++ if (!/^https?:\/\//i.test(value)) {++ const p = PLATFORMS[kind];++ if (!p || !p.url) {++ if (kind === 'custom' || kind === 'website' || !kind) value = `https://${value.replace(/^\/+/, '')}`;++ else return null;++ } else value = p.url(value.replace(/^@/, kind === 'youtube' ? '@' : ''));++ }++ let url; try { url = new URL(value.replace(/^http:\/\//i, 'https://')); } catch { return null; }++ if (url.protocol !== 'https:' || url.username || url.password || !url.hostname.includes('.')) return null;++ const detected = kindOfUrl(url.href);++ if (!PLATFORMS[kind] || kind === 'custom' || kind === 'website') kind = detected || (kind === 'website' ? 'website' : 'custom');++ else if (PLATFORMS[kind].hosts && detected !== kind) return null;++ const p = PLATFORMS[kind];++ const m = p.handle && p.handle.exec(url.pathname);++ const handle = kind === 'mastodon' ? (m ? `@${m[1]}@${url.hostname}` : null) : m ? m[1] : null;++ const label = raw.label ? String(raw.label).replace(/[<>]/g, '').trim().slice(0, MAX_LABEL) : '';++ return { kind, url: url.href.replace(/\/$/, ''), handle, label, preview: raw.preview !== false };++}++++/** The list to store: { links: [...], hidden_auto: [...] } as JSON, or null when invalid. */++function cleanSocialLinks(input) {++ if (input == null) return JSON.stringify({ links: [], hidden_auto: [] });++ const obj = Array.isArray(input) ? { links: input } : typeof input === 'object' ? input : null;++ if (!obj || !Array.isArray(obj.links || [])) return null;++ const links = [];++ const seen = new Set();++ for (const raw of (obj.links || []).slice(0, MAX_LINKS * 2)) {++ const l = cleanLink(raw);++ if (!l || seen.has(l.url.toLowerCase())) continue;++ seen.add(l.url.toLowerCase());++ links.push(l);++ if (links.length >= MAX_LINKS) break;++ }++ const hidden = Array.isArray(obj.hidden_auto) ? obj.hidden_auto.map(String).filter((k) => AUTO_KINDS.includes(k)) : [];++ return JSON.stringify({ links, hidden_auto: [...new Set(hidden)] });++}++++function parseStored(s) {++ try { const o = typeof s === 'string' ? JSON.parse(s) : s; return { links: Array.isArray(o && o.links) ? o.links : [], hidden_auto: Array.isArray(o && o.hidden_auto) ? o.hidden_auto : [] }; } catch { return { links: [], hidden_auto: [] }; }++}++++/** The platform's display fields for a link (name, icon, color). */++function decorate(l, extra = {}) {++ const p = PLATFORMS[l.kind] || PLATFORMS.custom;++ return { ...l, name: p.name, icon: p.icon, color: p.color, ...extra };++}++++/**++ * What viewers see: saved links (in the streamer's order), then the platforms the channel is connected to for++ * restreaming (Twitch, YouTube, Kick) that are not already listed and not hidden. `restreamsTo` (owner view only)++ * lists restream platforms that have no public channel URL yet, so the editor can ask for it.++ */++function channelSocialLinks(channel, db, { owner = false } = {}) {++ const stored = parseStored(channel && channel.social_links);++ const out = stored.links.map((l) => decorate(l));++ const kinds = new Set(out.map((l) => l.kind));++ let conns = [];++ try { conns = db.all('SELECT platform, platform_username, channel_url FROM platform_connections WHERE user_id = ?', [channel.user_id]) || []; } catch { conns = []; }++ const auto = [];++ for (const c of conns) {++ if (!AUTO_KINDS.includes(c.platform) || kinds.has(c.platform)) continue;++ const l = cleanLink({ kind: c.platform, url: c.channel_url || '', handle: c.platform_username || '' });++ if (!l) continue;++ const d = decorate(l, { auto: true, hidden: stored.hidden_auto.includes(c.platform) });++ auto.push(d);++ if (!d.hidden) { out.push(d); kinds.add(c.platform); }++ }++ const result = { links: out };++ if (owner) {++ let dests = [];++ try { dests = db.all("SELECT DISTINCT platform FROM restream_destinations WHERE user_id = ? AND platform IN ('twitch', 'youtube', 'kick')", [channel.user_id]) || []; } catch { dests = []; }++ result.connected = auto;++ result.restreams_without_link = dests.map((d) => d.platform).filter((p) => !kinds.has(p) && !auto.some((a) => a.kind === p));++ result.hidden_auto = stored.hidden_auto;++ }++ return result;++}++++function catalog() {++ return Object.entries(PLATFORMS).map(([kind, p]) => ({ kind, name: p.name, icon: p.icon, color: p.color, handle: !!p.handle && !!p.url }));++}++++module.exports = { PLATFORMS, cleanLink, cleanSocialLinks, channelSocialLinks, parseStored, kindOfUrl, catalog, MAX_LINKS };nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++/**++ * Rich previews for a channel's social links: live status, latest posts or videos, and page metadata, fetched on++ * the server through the egress guard (server/net/egress.js: public addresses only, redirects re-checked, size and++ * time caps) and cached ten minutes (errors one minute), so a busy channel page costs each upstream one request per++ * link per ten minutes.++ *++ * preview(link) → { kind, title, subtitle, image, live: { live, title, viewers, thumbnail }?, items: [{ text, url, at, image }] }++ *++ * Sources, all public and keyless: Bluesky (public.api.bsky.app), Mastodon (the instance's public API), GitHub++ * (api.github.com), YouTube (the channel's RSS feed), Twitch (the preview image redirects to a placeholder when the++ * channel is offline), Kick (its public channel API, best effort), and OpenGraph for everything else. X has no++ * usable free API: its card links out, and the channel page offers X's own timeline embed on click (isolated++ * frame, /embed/x-timeline).++ */++const egress = require('../net/egress');++++const TTL = 10 * 60 * 1000;++const ERROR_TTL = 60 * 1000;++const MAX_ENTRIES = 2000;++const UA = 'OpenVibe.Live/1.0 (+https://openvibe.live; channel link previews)';++const cache = new Map(); // url → { at, ttl, value }++++async function getJson(url, { timeoutMs = 6000 } = {}) {++ const r = await egress.fetchText(url, { timeoutMs, maxBytes: 768 * 1024, headers: { 'User-Agent': UA, Accept: 'application/json' } });++ if (r.status < 200 || r.status >= 300) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });++ return JSON.parse(r.text);++}++async function getText(url, { timeoutMs = 6000, accept = 'text/html,application/xhtml+xml' } = {}) {++ const r = await egress.fetchText(url, { timeoutMs, maxBytes: 768 * 1024, headers: { 'User-Agent': UA, Accept: accept } });++ if (r.status < 200 || r.status >= 300) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });++ return r;++}++++const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));++const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);++++/** OpenGraph / meta tags of a page. */++async function openGraph(url) {++ const r = await getText(url);++ const head = r.text.slice(0, 200000);++ const meta = (prop) => {++ const re = new RegExp(`<meta[^>]+(?:property|name)=["']${prop}["'][^>]*>`, 'i');++ const tag = re.exec(head);++ const c = tag && /content=["']([^"']*)["']/i.exec(tag[0]);++ return c ? decode(c[1]) : null;++ };++ const title = meta('og:title') || meta('twitter:title') || clip((/<title[^>]*>([^<]*)<\/title>/i.exec(head) || [])[1], 120);++ let image = meta('og:image') || meta('twitter:image');++ try { if (image) image = new URL(image, r.url).href; } catch { image = null; }++ return { title: title || null, subtitle: clip(meta('og:description') || meta('description') || meta('twitter:description'), 200) || null, image: httpsOnly(image) };++}++++const ADAPTERS = {++ async bluesky(l) {++ const actor = encodeURIComponent(l.handle);++ const [p, feed] = await Promise.all([++ getJson(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`),++ getJson(`https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${actor}&limit=6&filter=posts_no_replies`),++ ]);++ const items = (feed.feed || []).filter((f) => !f.reason).slice(0, 3).map((f) => {++ const rkey = String(f.post.uri || '').split('/').pop();++ const img = f.post.embed && f.post.embed.images && f.post.embed.images[0];++ return { text: clip(f.post.record && f.post.record.text), url: `https://bsky.app/profile/${l.handle}/post/${rkey}`, at: f.post.indexedAt || null, image: httpsOnly(img && img.thumb) };++ });++ return { title: p.displayName || p.handle, subtitle: `${Number(p.followersCount || 0).toLocaleString('en-US')} followers · ${Number(p.postsCount || 0).toLocaleString('en-US')} posts`, image: httpsOnly(p.avatar), items };++ },++ async mastodon(l) {++ const u = new URL(l.url);++ const acct = String(l.handle || '').replace(/^@/, '').split('@')[0];++ const a = await getJson(`https://${u.hostname}/api/v1/accounts/lookup?acct=${encodeURIComponent(acct)}`);++ const statuses = await getJson(`https://${u.hostname}/api/v1/accounts/${encodeURIComponent(a.id)}/statuses?limit=3&exclude_replies=true&exclude_reblogs=true`);++ return {++ title: a.display_name || a.username, subtitle: `${Number(a.followers_count || 0).toLocaleString('en-US')} followers`, image: httpsOnly(a.avatar),++ items: (statuses || []).slice(0, 3).map((s) => ({ text: clip(s.content), url: httpsOnly(s.url), at: s.created_at || null, image: httpsOnly(s.media_attachments && s.media_attachments[0] && s.media_attachments[0].preview_url) })),++ };++ },++ async github(l) {++ const [u, repos] = await Promise.all([++ getJson(`https://api.github.com/users/${encodeURIComponent(l.handle)}`),++ getJson(`https://api.github.com/users/${encodeURIComponent(l.handle)}/repos?sort=pushed&per_page=3`),++ ]);++ return {++ title: u.name || u.login, subtitle: `${Number(u.public_repos || 0)} repositories · ${Number(u.followers || 0).toLocaleString('en-US')} followers`, image: httpsOnly(u.avatar_url),++ items: (repos || []).slice(0, 3).map((r) => ({ text: `${r.name}${r.description ? ` — ${clip(r.description, 140)}` : ''}${r.stargazers_count ? ` · ★ ${r.stargazers_count}` : ''}`, url: httpsOnly(r.html_url), at: r.pushed_at || null })),++ };++ },++ async youtube(l) {++ let channelId = (/\/channel\/(UC[A-Za-z0-9_-]{22})/.exec(l.url) || [])[1];++ let og = null;++ if (!channelId) {++ const r = await getText(l.url);++ channelId = (/"channelId":"(UC[A-Za-z0-9_-]{22})"/.exec(r.text) || /channel\/(UC[A-Za-z0-9_-]{22})/.exec(r.text) || [])[1];++ og = null;++ }++ if (!channelId) throw new Error('no channel id');++ const feed = await getText(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, { accept: 'application/atom+xml,application/xml' });++ const entries = feed.text.split('<entry>').slice(1, 4);++ const title = clip((/<title>([^<]*)<\/title>/.exec(feed.text) || [])[1], 80);++ const items = entries.map((e) => {++ const vid = (/<yt:videoId>([^<]+)<\/yt:videoId>/.exec(e) || [])[1];++ return { text: clip((/<title>([^<]*)<\/title>/.exec(e) || [])[1], 140), url: vid ? `https://www.youtube.com/watch?v=${vid}` : null, at: (/<published>([^<]+)<\/published>/.exec(e) || [])[1] || null, image: vid ? `https://i.ytimg.com/vi/${vid}/mqdefault.jpg` : null };++ });++ return { title: title || (og && og.title), subtitle: 'Latest videos', image: null, items };++ },++ async twitch(l) {++ const login = String(l.handle || '').toLowerCase();++ const thumb = `https://static-cdn.jtvnw.net/previews-ttv/live_user_${login}-440x248.jpg`;++ // Offline channels' preview image redirects to a placeholder: follow it and look where it lands.++ const r = await egress.fetchBuffer(thumb, { timeoutMs: 5000, maxBytes: 256 * 1024, headers: { 'User-Agent': UA } });++ const live = r.status === 200 && !/404_preview/i.test(r.url);++ let og = {};++ try { og = await openGraph(l.url); } catch { og = {}; }++ return { title: og.title || login, subtitle: og.subtitle || null, image: og.image || null, live: { live, thumbnail: live ? `${thumb}?t=${Math.floor(Date.now() / 60000)}` : null }, items: [] };++ },++ async kick(l) {++ const c = await getJson(`https://kick.com/api/v2/channels/${encodeURIComponent(String(l.handle || '').toLowerCase())}`);++ const ls = c.livestream;++ return {++ title: (c.user && c.user.username) || l.handle, subtitle: c.followers_count != null ? `${Number(c.followers_count).toLocaleString('en-US')} followers` : null,++ image: httpsOnly(c.user && c.user.profile_pic),++ live: { live: !!(ls && ls.is_live), title: ls ? clip(ls.session_title, 140) : null, viewers: ls ? Number(ls.viewer_count || 0) : null, thumbnail: httpsOnly(ls && ls.thumbnail && ls.thumbnail.url) },++ items: [],++ };++ },++ async x(l) {++ // No free API: the card links out; the page offers X's own embed on click.++ return { title: `@${l.handle}`, subtitle: 'Posts on X', image: null, items: [], embed: l.handle ? `/embed/x-timeline?h=${encodeURIComponent(l.handle)}` : null };++ },++};++++/** A preview for one cleaned link (server/social/links.js cleanLink). Never throws: failures give { error }. */++async function preview(l) {++ const key = l.url;++ const hit = cache.get(key);++ if (hit && Date.now() - hit.at < hit.ttl) return hit.value;++ let value;++ try {++ const fn = ADAPTERS[l.kind] || (async (x) => openGraph(x.url));++ const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x'].includes(l.kind);++ value = needsHandle && !l.handle ? await openGraph(l.url) : await fn(l);++ value = { kind: l.kind, url: l.url, items: [], ...value };++ remember(key, value, TTL);++ } catch (err) {++ // Platform APIs refuse sometimes: fall back to the page's own metadata, else a bare link.++ try { value = { kind: l.kind, url: l.url, items: [], ...(await openGraph(l.url)) }; remember(key, value, TTL); } catch {++ value = { kind: l.kind, url: l.url, items: [], error: err instanceof egress.EgressDenied ? 'not allowed' : 'unavailable' };++ remember(key, value, ERROR_TTL);++ }++ }++ return value;++}++++function remember(key, value, ttl) {++ if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);++ cache.set(key, { at: Date.now(), ttl, value });++}++++module.exports = { preview, openGraph, ADAPTERS, _cache: cache };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * /api/social — channel social links (server/social/links.js) and their rich previews (server/social/preview.js).++ *++ * GET /api/social/catalog the platforms a link can be (name, icon, color, handle input)++ * GET /api/social/preview/:username/:index the preview of one link a channel saved or is connected to++ *++ * Previews fetch only links a channel shows (addressed by channel and position), never a URL from the query, so++ * this cannot be used to make the server fetch arbitrary addresses. Answers are cached on the server and for++ * five minutes in browsers and at the edge.++ */++const express = require('express');++const db = require('../db/database');++const { channelSocialLinks, catalog } = require('./links');++const { preview } = require('./preview');++++const router = express.Router();++++router.get('/catalog', (req, res) => {++ res.set('Cache-Control', 'public, max-age=3600');++ res.json({ platforms: catalog() });++});++++router.get('/preview/:username/:index', async (req, res) => {++ try {++ const channel = db.getChannelByUsername(String(req.params.username || ''));++ if (!channel) return res.status(404).json({ error: 'Channel not found' });++ const i = Number.parseInt(req.params.index, 10);++ const { links } = channelSocialLinks(channel, db);++ const link = Number.isInteger(i) && i >= 0 ? links[i] : null;++ if (!link) return res.status(404).json({ error: 'No such link' });++ if (link.preview === false) return res.json({ kind: link.kind, url: link.url, items: [], disabled: true });++ const p = await preview(link);++ res.set('Cache-Control', 'public, max-age=300');++ res.json(p);++ } catch (err) {++ console.error('[Social] preview error:', err.message);++ res.status(500).json({ error: 'Preview failed' });++ }++});++++/**++ * X's own timeline embed, alone in a page of ours so X's script never runs on the channel page: the channel page++ * frames this only after the viewer asks for it. Its CSP allows X's widget script and frames and nothing else.++ */++function xTimelinePage(req, res) {++ const h = String(req.query.h || '');++ if (!/^[A-Za-z0-9_]{1,15}$/.test(h)) return res.status(400).type('text/plain').send('bad handle');++ res.set('Content-Security-Policy', "default-src 'none'; script-src https://platform.twitter.com; frame-src https://platform.twitter.com https://syndication.twitter.com; img-src https: data:; style-src 'unsafe-inline' https://platform.twitter.com; frame-ancestors 'self'");++ res.set('Cache-Control', 'public, max-age=3600');++ res.type('html').send(`<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">++<style>html,body{margin:0;background:transparent;color:#e7e9ea;font:14px system-ui,sans-serif}a{color:#1d9bf0}</style></head>++<body><a class="twitter-timeline" data-theme="dark" data-chrome="noheader nofooter noborders transparent" data-height="520" href="https://twitter.com/${h}">Posts by @${h}</a>++<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></body></html>`);++}++++module.exports = { router, xTimelinePage };@@@ -174,6 +174,13 @@ function cleanTags(tags) {return cleaned;}++/** The social links an About editor sees after saving (server/social/links.js), for the response. */++function aboutSocials(channel) {++ if (!channel) return { social_links: [] };++ const s = require('../social/links').channelSocialLinks(channel, db, { owner: true });++ return { social_links: s.links, social_links_editor: { connected: s.connected, restreams_without_link: s.restreams_without_link, hidden_auto: s.hidden_auto } };++}++function cleanPanels(panels) {if (panels === undefined) return undefined;if (typeof panels === 'string') {@@@ -478,6 +485,13 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {const viewerIsChannelMod = !!(req.user && db.isChannelModerator(req.user.id, channel.id));publicChannel.mods_can_edit_about = modsCanEditAbout;publicChannel.viewer_can_edit_about = !!(isOwner || (modsCanEditAbout && viewerIsChannelMod));++ // Social links (server/social/links.js): saved links plus connected restream platforms. Editors also get++ // what the editor needs (connected platforms, restreams with no public link yet, hidden ones).++ try {++ const socials = require('../social/links').channelSocialLinks(channel, db, { owner: publicChannel.viewer_can_edit_about });++ publicChannel.social_links = socials.links;++ if (publicChannel.viewer_can_edit_about) publicChannel.social_links_editor = { connected: socials.connected, restreams_without_link: socials.restreams_without_link, hidden_auto: socials.hidden_auto };++ } catch { publicChannel.social_links = []; }// Streamer AI overview for the top of the About tab (unless the streamer hid it).// `hide_ai_overview` rides along on the channel row spread above.try {@@@ -829,6 +843,12 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {if (panels === null) return res.status(400).json({ error: 'Invalid panels' });if (panels !== undefined) db.updateChannel(channel.user_id, { panels });}++ // Social links: { links: [{ kind, url | handle, label, preview }], hidden_auto: ['twitch', …] }.++ if (hasOwn(req.body, 'social_links')) {++ const socials = require('../social/links').cleanSocialLinks(req.body.social_links);++ if (socials === null) return res.status(400).json({ error: 'Invalid social links' });++ db.updateChannel(channel.user_id, { social_links: socials });++ }// Show/hide the AI overview at the top of the About tab.if (hasOwn(req.body, 'hide_ai_overview')) {db.updateChannel(channel.user_id, { hide_ai_overview: req.body.hide_ai_overview ? 1 : 0 });@@@ -845,7 +865,7 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {// handed every mod the streamer's broadcast key — enough to publish to their channel. The// rest of this file already redacts it the same way before sending a channel or stream.if (updated) { delete updated.stream_key; delete updated.managed_stream_key; }-- res.json({ channel: updated, edited_by_mod: !isOwner });++ res.json({ channel: updated, ...aboutSocials(updated), edited_by_mod: !isOwner });} catch (err) {console.error('[Channel] about update error:', err.message);res.status(500).json({ error: 'Failed to save About section' });nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * Channel social links (server/social/): validation of saved links, the merge with connected restream platforms,++ * and the preview adapters (Bluesky posts, Twitch live status, OpenGraph fallback, bare link on failure), with the++ * egress fetch stubbed. Also: previews never take a URL from the request (only a channel's own links).++ */++const assert = require('assert');++const egress = require('../server/net/egress');++const { cleanSocialLinks, cleanLink, channelSocialLinks } = require('../server/social/links');++++let passed = 0;++async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name}`); }++++(async () => {++ await check('links: handles and URLs become canonical https links; wrong hosts, http-only tricks and junk are refused', async () => {++ assert.deepStrictEqual(cleanLink({ kind: 'x', handle: '@OpenVibe' }), { kind: 'x', url: 'https://x.com/OpenVibe', handle: 'OpenVibe', label: '', preview: true });++ assert.strictEqual(cleanLink({ kind: 'twitch', url: 'https://www.twitch.tv/goosely/' }).handle, 'goosely');++ assert.strictEqual(cleanLink({ kind: 'custom', url: 'https://twitter.com/someone' }).kind, 'x', 'a pasted URL finds its platform');++ assert.strictEqual(cleanLink({ kind: 'bluesky', handle: 'alice.bsky.social' }).url, 'https://bsky.app/profile/alice.bsky.social');++ assert.strictEqual(cleanLink({ kind: 'twitch', url: 'https://evil.example/twitch' }), null, 'a platform link must point at the platform');++ assert.strictEqual(cleanLink({ kind: 'custom', url: 'javascript:alert(1)' }), null);++ assert.strictEqual(cleanLink({ kind: 'custom', url: 'https://user:[email protected]/' }), null);++ assert.strictEqual(cleanLink({ kind: 'website', url: 'example.com/shop' }).url, 'https://example.com/shop');++ assert.strictEqual(cleanLink({ kind: 'custom', url: 'http://example.com' }).url, 'https://example.com', 'upgraded to https');++ const stored = JSON.parse(cleanSocialLinks({ links: [{ kind: 'x', handle: 'a' }, { kind: 'x', url: 'https://x.com/a' }, { kind: 'custom', url: 'nope' }], hidden_auto: ['twitch', 'root'] }));++ assert.strictEqual(stored.links.length, 1, 'duplicates and invalid links dropped');++ assert.deepStrictEqual(stored.hidden_auto, ['twitch']);++ assert.strictEqual(cleanSocialLinks('not a list'), null);++ });++++ await check('merge: saved links first, then connected Twitch/YouTube/Kick not already listed and not hidden; editors see what is missing', async () => {++ const db = {++ all(sql) {++ if (/platform_connections/.test(sql)) return [{ platform: 'twitch', platform_username: 'goosely', channel_url: 'https://www.twitch.tv/goosely' }, { platform: 'kick', platform_username: 'goosely', channel_url: null }];++ if (/restream_destinations/.test(sql)) return [{ platform: 'twitch' }, { platform: 'youtube' }];++ return [];++ },++ };++ const channel = { user_id: 1, social_links: cleanSocialLinks({ links: [{ kind: 'x', handle: 'goosely' }], hidden_auto: ['kick'] }) };++ const viewer = channelSocialLinks(channel, db);++ assert.deepStrictEqual(viewer.links.map((l) => [l.kind, !!l.auto]), [['x', false], ['twitch', true]]);++ assert.ok(viewer.links[0].icon && viewer.links[0].color);++ assert.strictEqual(viewer.connected, undefined, 'viewers get no editor data');++ const owner = channelSocialLinks(channel, db, { owner: true });++ assert.deepStrictEqual(owner.restreams_without_link, ['youtube']);++ assert.deepStrictEqual(owner.connected.map((c) => [c.kind, c.hidden]), [['twitch', false], ['kick', true]]);++ });++++ const { preview, _cache } = require('../server/social/preview');++ const real = { fetchText: egress.fetchText, fetchBuffer: egress.fetchBuffer };++ const calls = [];++ egress.fetchText = async (url) => {++ calls.push(url);++ if (url.includes('getProfile')) return { status: 200, url, headers: {}, text: JSON.stringify({ handle: 'alice.bsky.social', displayName: 'Alice', followersCount: 1200, postsCount: 30, avatar: 'https://cdn.bsky.app/a.jpg' }) };++ if (url.includes('getAuthorFeed')) return { status: 200, url, headers: {}, text: JSON.stringify({ feed: [{ post: { uri: 'at://did/app.bsky.feed.post/3k1', indexedAt: '2026-09-28T10:00:00Z', record: { text: 'Going live at 8 <3' } } }] }) };++ if (url.includes('twitch.tv')) return { status: 200, url, headers: {}, text: '<meta property="og:title" content="goosely - Twitch"><meta property="og:image" content="https://static.twitch.tv/x.png">' };++ if (url.includes('shop.example')) return { status: 200, url, headers: {}, text: '<title>My shop</title><meta name="description" content="Stickers & shirts">' };++ return { status: 503, url, headers: {}, text: '' };++ };++ egress.fetchBuffer = async (url) => ({ status: 200, url: url.includes('goosely') ? url : 'https://static-cdn.jtvnw.net/ttv-static/404_preview-440x248.jpg', headers: {}, body: Buffer.alloc(0) });++ try {++ await check('previews: Bluesky profile and latest posts; Twitch live from the preview image; OpenGraph for a custom link', async () => {++ const b = await preview(cleanLink({ kind: 'bluesky', handle: 'alice.bsky.social' }));++ assert.strictEqual(b.title, 'Alice');++ assert.match(b.subtitle, /1,200 followers/);++ assert.deepStrictEqual(b.items.map((i) => [i.text, i.url]), [['Going live at 8 <3', 'https://bsky.app/profile/alice.bsky.social/post/3k1']]);++ const t = await preview(cleanLink({ kind: 'twitch', handle: 'goosely' }));++ assert.strictEqual(t.live.live, true);++ const off = await preview(cleanLink({ kind: 'twitch', handle: 'sleepy' }));++ assert.strictEqual(off.live.live, false, 'the placeholder image means offline');++ const c = await preview(cleanLink({ kind: 'custom', url: 'https://shop.example/' }));++ assert.deepStrictEqual([c.title, c.subtitle], ['My shop', 'Stickers & shirts']);++ });++ await check('previews: an unavailable platform falls back to its page, else a bare link; answers are cached', async () => {++ const n = calls.length;++ const g = await preview(cleanLink({ kind: 'github', handle: 'nobody' }));++ assert.strictEqual(g.error, 'unavailable');++ await preview(cleanLink({ kind: 'bluesky', handle: 'alice.bsky.social' }));++ assert.ok(calls.slice(n).every((u) => !u.includes('bsky')), 'the Bluesky preview came from the cache');++ assert.ok(_cache.size >= 4);++ });++ } finally { Object.assign(egress, real); }++++ await check('the preview route addresses a channel\'s own link by position, never a URL from the request', async () => {++ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'social', 'routes.js'), 'utf8');++ assert.ok(/\/preview\/:username\/:index/.test(src));++ assert.ok(!/req\.query\.url/.test(src));++ });++ console.log(`social-links: ${passed} checks passed`);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
"sharp": "^0.33.0",
@@ -2859,9 +2859,9 @@
@@ -2859,9 +2859,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.28.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.1",
- "integrity": "sha512-AV39sRi9hm8QETRLM1c6q7nrU8XWPUZzCxZP+h7Cho+LsJ47szHtMk3YdCQHT8+aSC5Hpu68EU8sCvbagR+a0g==",
+ "version": "1.29.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.1",
+ "integrity": "sha512-HI1aySq1Lipd/GRGI0exL98LVQ2UXWeIlYk3TaTgMsjV/S1L5tTyaI+tNKJ8q8zKpMNdXj0hSflENBE97i/CxQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.16.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
"sharp": "^0.33.0",
@@ -56,13 +56,19 @@
@@ -56,13 +56,19 @@
/* ── Offline channel: discover board — a full-width dashboard grid ── */
/* ── Offline channel: discover board — a full-width dashboard grid ── */
#ch-discover { width: 100%; }
#ch-discover { width: 100%; }
.dsc { width: 100%; max-width: none; margin: 18px 0 0; padding: 0 clamp(4px, 1.5vw, 12px); box-sizing: border-box; text-align: left;
.dsc { width: 100%; max-width: none; margin: 18px 0 0; padding: 0 clamp(4px, 1.5vw, 12px); box-sizing: border-box; text-align: left;
- display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); grid-template-areas: "head head" "live side" "clips side" "people people"; gap: 14px 18px; align-items: start; }
-.dsc-head { grid-area: head; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin: 0; }
+ display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(280px, 1fr); gap: 18px 22px; align-items: start; }
+/* Two independent columns: the main one (live, clips, streamers) and the side (star, reports). They share no rows, so a
+ missing section never leaves a hole the other column's height stretches open. */
+.dsc-main { display: grid; gap: 20px; min-width: 0; align-content: start; }
+.dsc-side { display: grid; gap: 12px; min-width: 0; align-content: start; }
+.dsc--noside, .dsc--sideonly { grid-template-columns: 1fr; }
+.dsc--sideonly .dsc-side { grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); }
+.dsc-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin: 0; }
.dsc-title { font-size: 1rem; font-weight: 900; color: var(--text-primary); }
.dsc-title { font-size: 1rem; font-weight: 900; color: var(--text-primary); }
.dsc-title i { color: var(--accent); margin-right: 6px; }
.dsc-title i { color: var(--accent); margin-right: 6px; }
.dsc-sub { font-size: 0.8rem; color: var(--text-muted); }
.dsc-sub { font-size: 0.8rem; color: var(--text-muted); }
.dsc-sec { margin: 0; min-width: 0; }
.dsc-sec { margin: 0; min-width: 0; }
-.dsc-sec--live { grid-area: live; } .dsc-sec--clips { grid-area: clips; } .dsc-sec--side { grid-area: side; display: grid; gap: 12px; align-content: start; } .dsc-sec--people { grid-area: people; }
+/* Sections stack inside .dsc-main (no named grid areas: they would place items on implicit lines). */
.dsc-sec h4 { margin: 0 0 8px; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.dsc-sec h4 { margin: 0 0 8px; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.dsc-sec h4 i { color: var(--accent); }
.dsc-sec h4 i { color: var(--accent); }
.dsc-sec h4 .muted { text-transform: none; letter-spacing: 0; font-weight: 600; }
.dsc-sec h4 .muted { text-transform: none; letter-spacing: 0; font-weight: 600; }
@@ -102,9 +108,8 @@
@@ -102,9 +108,8 @@
.dsc-person em { grid-column: 2; font-style: normal; font-size: 0.62rem; color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.dsc-person em { grid-column: 2; font-style: normal; font-size: 0.62rem; color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.dsc-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 3px; animation: ccMark 1.4s ease-in-out infinite; }
.dsc-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 3px; animation: ccMark 1.4s ease-in-out infinite; }
/* No live streams and no clips to show: let the side column use the full width, two-up. */
/* No live streams and no clips to show: let the side column use the full width, two-up. */
-.dsc--noleft { grid-template-columns: 1fr; grid-template-areas: "head" "side" "people"; }
-.dsc--noleft .dsc-sec--side { grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); align-items: start; }
-@media (max-width: 980px) { .dsc { grid-template-columns: 1fr; grid-template-areas: "head" "live" "clips" "side" "people"; } .dsc--noleft { grid-template-areas: "head" "side" "people"; } .dsc--noleft .dsc-sec--side { grid-template-columns: 1fr; } }
+@media (max-width: 980px) { .dsc { grid-template-columns: 1fr; } .dsc--sideonly .dsc-side { grid-template-columns: 1fr; } }
+.dsc-people { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; }
@media (max-width: 640px) { .dsc-live { grid-template-columns: 1fr 1fr; } .dsc-clips { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } }
@media (max-width: 640px) { .dsc-live { grid-template-columns: 1fr 1fr; } .dsc-clips { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } }
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
@@ -118,17 +123,17 @@
@@ -118,17 +123,17 @@
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
.dsc-wrap { width: 100%; margin: 14px 0 0; }
.dsc-wrap { width: 100%; margin: 14px 0 0; }
.dsc-toggle {
.dsc-toggle {
- position: relative; overflow: hidden; isolation: isolate;
- display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 11px;
- width: 100%; padding: 10px 13px; cursor: pointer; text-align: left; font: inherit;
- color: var(--text-primary); border-radius: 14px;
- border: 1px solid var(--border);
- background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--bg-card)), var(--bg-secondary));
- transition: border-color .22s, box-shadow .22s, transform .18s;
+ position: relative; isolation: isolate;
+ display: grid; grid-template-columns: auto minmax(0, max-content) 1fr auto auto; align-items: center; gap: 12px;
+ width: 100%; padding: 6px 2px 10px; cursor: pointer; text-align: left; font: inherit; color: var(--text-primary);
+ background: none; border: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));
+ transition: border-color .22s;
}
}
-.dsc-toggle:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 8px 22px rgba(0,0,0,0.26); }
-.dsc-toggle:active { transform: scale(0.995); }
-.dsc-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+/* A section heading of the offline page (not a card): the rule under it runs the full width. */
+.dsc-toggle::after { content: ''; grid-column: 3; height: 1px; align-self: center;
+ background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 40%, transparent), transparent 85%); }
+.dsc-toggle:hover { border-bottom-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
+.dsc-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.dsc-toggle-ico {
.dsc-toggle-ico {
width: 32px; height: 32px; flex: none; border-radius: 10px; display: grid; place-items: center;
width: 32px; height: 32px; flex: none; border-radius: 10px; display: grid; place-items: center;
font-size: 0.8rem; color: var(--accent);
font-size: 0.8rem; color: var(--accent);
@@ -136,7 +141,7 @@
@@ -136,7 +141,7 @@
border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
}
}
.dsc-toggle-text { min-width: 0; display: grid; gap: 1px; }
.dsc-toggle-text { min-width: 0; display: grid; gap: 1px; }
-.dsc-toggle-text b { font-size: 0.88rem; font-weight: 850; letter-spacing: -0.01em; }
+.dsc-toggle-text b { font-size: 1rem; font-weight: 850; letter-spacing: -0.01em; }
/* One line, clipped — the sub-line is context, never a reason for the row to grow taller. */
/* One line, clipped — the sub-line is context, never a reason for the row to grow taller. */
.dsc-toggle-text small { font-size: 0.72rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dsc-toggle-text small { font-size: 0.72rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dsc-toggle-count {
.dsc-toggle-count {
@@ -160,7 +165,8 @@
@@ -160,7 +165,8 @@
.dsc-wrap .dsc { margin-top: 10px; }
.dsc-wrap .dsc { margin-top: 10px; }
@media (max-width: 560px) {
@media (max-width: 560px) {
- .dsc-toggle { gap: 9px; padding: 9px 11px; }
+ .dsc-toggle { gap: 9px; grid-template-columns: auto minmax(0, 1fr) auto auto; }
+ .dsc-toggle::after { display: none; }
.dsc-toggle-ico { width: 29px; height: 29px; font-size: 0.74rem; }
.dsc-toggle-ico { width: 29px; height: 29px; font-size: 0.74rem; }
.dsc-toggle-text b { font-size: 0.83rem; }
.dsc-toggle-text b { font-size: 0.83rem; }
.dsc-toggle-text small { font-size: 0.68rem; }
.dsc-toggle-text small { font-size: 0.68rem; }
@@ -977,7 +977,7 @@
@@ -977,7 +977,7 @@
.ch-offline-screen:has(.ch-offline-default) { aspect-ratio: auto; max-height: none; height: auto; min-height: 0; align-items: stretch; }
.ch-offline-screen:has(.ch-offline-default) { aspect-ratio: auto; max-height: none; height: auto; min-height: 0; align-items: stretch; }
.ch-offline-screen:has(.ch-offline-default) .ch-offline-default { justify-content: flex-start; padding-top: clamp(20px, 5vh, 44px); padding-bottom: clamp(18px, 4vh, 36px); }
.ch-offline-screen:has(.ch-offline-default) .ch-offline-default { justify-content: flex-start; padding-top: clamp(20px, 5vh, 44px); padding-bottom: clamp(18px, 4vh, 36px); }
.ch-offline-explore { width: 100%; margin-top: 22px; }
.ch-offline-explore { width: 100%; margin-top: 22px; }
-.off-cyc { width: 100%; max-width: 760px; margin: 0 auto; padding: 0 clamp(10px, 3vw, 24px); box-sizing: border-box; }
+.off-cyc { width: 100%; max-width: 980px; margin: 0 auto; padding: 0 clamp(10px, 3vw, 24px); box-sizing: border-box; }
.off-cyc-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.off-cyc-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.off-cyc-title { display: inline-flex; align-items: center; gap: 7px; font-size: 0.82rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-primary); }
.off-cyc-title { display: inline-flex; align-items: center; gap: 7px; font-size: 0.82rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-primary); }
.off-cyc-ranges { display: inline-flex; gap: 4px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 999px; padding: 3px; }
.off-cyc-ranges { display: inline-flex; gap: 4px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 999px; padding: 3px; }
@@ -1111,10 +1111,13 @@
@@ -1111,10 +1111,13 @@
#ch-weather-widget-offline { max-width: 500px; }
#ch-weather-widget-offline { max-width: 500px; }
.ch-offline-area .stream-main { padding-bottom: 14px; }
.ch-offline-area .stream-main { padding-bottom: 14px; }
.ch-offline-header {
.ch-offline-header {
- display: flex; align-items: center; gap: 20px;
- padding: 24px; background: var(--bg-card); border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
+ padding: 14px clamp(12px, 2vw, 20px); background: var(--bg-card); border-bottom: 1px solid var(--border);
}
}
-.ch-avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
+.ch-avatar-lg { width: 64px; height: 64px; font-size: 1.6rem; }
+.ch-offline-header .ch-username { margin-bottom: 2px; }
+.ch-offline-header .ch-description:empty { display: none; }
+.ch-offline-header .ch-actions { display: flex; gap: 6px; margin-left: auto; }
.ch-username { color: var(--text-muted); margin: 0 0 4px; font-size: 0.9rem; }
.ch-username { color: var(--text-muted); margin: 0 0 4px; font-size: 0.9rem; }
.ch-description { color: var(--text-secondary); margin: 0 0 8px; font-size: 0.9rem; }
.ch-description { color: var(--text-secondary); margin: 0 0 8px; font-size: 0.9rem; }
.ch-meta { display: flex; gap: 12px; align-items: center; font-size: 0.85rem; }
.ch-meta { display: flex; gap: 12px; align-items: center; font-size: 0.85rem; }
@@ -1252,6 +1255,14 @@
@@ -1252,6 +1255,14 @@
.ai-tl-tr-text { color: var(--text-secondary); }
.ai-tl-tr-text { color: var(--text-secondary); }
.ai-tl-sentinel { text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; }
.ai-tl-sentinel { text-align: center; padding: 20px; color: var(--text-muted); font-size: 0.85rem; }
.about-ai-overview { margin: 0 0 16px; }
.about-ai-overview { margin: 0 0 16px; }
+/* In About the overview is part of the page, not a card: no fill, no frame, a thin accent rule beside it. */
+.ai-tl-overview-card.about-ai-overview { background: transparent; border: none; border-radius: 0; padding: 2px 0 2px 14px;
+ border-left: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 55%, transparent); }
+.ai-tl-overview-card.about-ai-overview .ai-tl-overview-label { margin-bottom: 5px; }
+
+/* Wide screens: the tabs sit in the main column (app-channel.js _placeBelowFold) and use its full width. */
+.channel-below-fold.is-in-main { padding: 4px 0 28px; }
+.channel-below-fold.is-in-main > .container { max-width: none; width: auto; margin: 0; padding: 0 18px; }
.ch-about-ai-toggle { display: flex; flex-direction: column; gap: 3px; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px; background: var(--bg-elevated, rgba(255,255,255,0.03)); border: 1px solid var(--border, rgba(255,255,255,0.07)); }
.ch-about-ai-toggle { display: flex; flex-direction: column; gap: 3px; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px; background: var(--bg-elevated, rgba(255,255,255,0.03)); border: 1px solid var(--border, rgba(255,255,255,0.07)); }
.ch-about-ai-hint { font-size: 0.78rem; }
.ch-about-ai-hint { font-size: 0.78rem; }
.ai-tl-side-label { font-size: 0.95rem; font-weight: 800; color: var(--text-primary); margin: 26px 0 14px; display: flex; align-items: center; gap: 9px; padding-bottom: 7px; border-bottom: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 30%, transparent); }
.ai-tl-side-label { font-size: 0.95rem; font-weight: 800; color: var(--text-primary); margin: 26px 0 14px; display: flex; align-items: center; gap: 9px; padding-bottom: 7px; border-bottom: 2px solid color-mix(in srgb, var(--accent, #8b5cf6) 30%, transparent); }
@@ -1389,3 +1400,59 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -1389,3 +1400,59 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
.ch-game-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--text-muted); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; }
.ch-game-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--text-muted); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; }
.ch-game-badge:hover { color: var(--text-primary); border-color: var(--accent); }
.ch-game-badge:hover { color: var(--text-primary); border-color: var(--accent); }
.ch-game-badge[hidden] { display: none; }
.ch-game-badge[hidden] { display: none; }
+
+/* ── Social links (public/js/channel-socials.js) ── */
+.ch-social-pills { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 8px; }
+.ch-social-pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px 4px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 700;
+ color: var(--text-primary); text-decoration: none; background: color-mix(in srgb, var(--sc) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--sc) 38%, transparent); transition: background .15s, transform .1s; max-width: 220px; }
+.ch-social-pill i { color: var(--sc); font-size: 0.95em; }
+.ch-social-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ch-social-pill:hover { background: color-mix(in srgb, var(--sc) 22%, transparent); transform: translateY(-1px); }
+.ch-socials { margin: 4px 0 18px; }
+.ch-socials-head { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 10px; display: flex; gap: 6px; align-items: center; }
+.ch-socials-head i { color: var(--accent); }
+.ch-socials-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
+.ch-social-card { border-radius: 14px; background: var(--bg-card); border: 1px solid color-mix(in srgb, var(--sc) 28%, var(--border)); overflow: hidden; min-width: 0;
+ box-shadow: inset 0 3px 0 color-mix(in srgb, var(--sc) 70%, transparent); transition: border-color .15s, transform .15s; }
+.ch-social-card:hover { border-color: color-mix(in srgb, var(--sc) 60%, var(--border)); transform: translateY(-2px); }
+.ch-social-card.is-live { border-color: #e0245e; }
+.ch-social-card-top { display: grid; grid-template-columns: auto 1fr auto auto; gap: 10px; align-items: center; padding: 12px 14px; color: var(--text-primary); text-decoration: none; }
+.ch-social-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--sc); background: color-mix(in srgb, var(--sc) 14%, transparent); font-size: 1.05rem; }
+.ch-social-id { display: grid; min-width: 0; }
+.ch-social-id b { font-size: 0.9rem; }
+.ch-social-id small { color: var(--text-muted); font-size: 0.76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ch-social-live { font-size: 0.62rem; font-weight: 900; letter-spacing: 0.08em; padding: 2px 7px; border-radius: 5px; background: #e0245e; color: #fff; }
+.ch-social-out { color: var(--text-muted); font-size: 0.72rem; }
+.ch-social-body:empty { display: none; }
+.ch-social-body { padding: 0 14px 12px; display: grid; gap: 8px; }
+.ch-social-meta { display: flex; gap: 10px; align-items: center; min-width: 0; }
+.ch-social-meta > div { display: grid; min-width: 0; }
+.ch-social-meta b { font-size: 0.84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ch-social-meta small { color: var(--text-muted); font-size: 0.74rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.ch-social-avatar { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; flex: none; }
+.ch-social-livethumb { position: relative; display: block; border-radius: 10px; overflow: hidden; aspect-ratio: 16 / 9; background: #000; }
+.ch-social-livethumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.ch-social-livethumb span { position: absolute; right: 8px; bottom: 8px; font-size: 0.7rem; font-weight: 800; padding: 2px 7px; border-radius: 5px; background: rgba(0,0,0,.72); color: #fff; }
+.ch-social-livetitle { margin: 0; font-size: 0.82rem; font-weight: 700; }
+.ch-social-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.ch-social-items a { display: flex; gap: 8px; align-items: flex-start; padding: 7px 8px; border-radius: 9px; background: var(--bg-secondary); color: var(--text-secondary); text-decoration: none; font-size: 0.8rem; line-height: 1.4; }
+.ch-social-items a:hover { color: var(--text-primary); }
+.ch-social-items img { width: 64px; height: 36px; object-fit: cover; border-radius: 6px; flex: none; }
+.ch-social-items span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }
+.ch-social-items time { display: block; color: var(--text-muted); font-size: 0.7rem; margin-top: 2px; }
+.ch-social-embed { width: 100%; height: 520px; border: 0; border-radius: 10px; background: transparent; }
+.ch-about-edit-socials { margin: 14px 0; display: grid; gap: 8px; }
+.ch-social-connected { display: grid; gap: 6px; }
+.ch-social-conn { display: flex; align-items: center; gap: 8px; font-size: 0.84rem; }
+.ch-social-conn i { color: var(--sc); }
+.ch-social-hint { margin: 0; font-size: 0.8rem; }
+.ch-social-rows { display: grid; gap: 6px; }
+.ch-social-row { display: grid; grid-template-columns: 130px minmax(0, 2fr) minmax(0, 1fr) auto auto auto; gap: 6px; align-items: center; }
+.ch-social-row select, .ch-social-row input[type=text], .ch-social-row input:not([type]) { min-width: 0; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); }
+.ch-social-prev { display: inline-flex; gap: 4px; align-items: center; font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
+@media (max-width: 640px) {
+ .ch-social-row { grid-template-columns: 1fr 1fr; }
+ .ch-social-row input[data-f=url] { grid-column: 1 / -1; }
+ .ch-socials-grid { grid-template-columns: 1fr; }
+}
@@ -14,7 +14,6 @@
@@ -14,7 +14,6 @@
"/js/ov-odometer.js",
"/js/ov-odometer.js",
"/js/ov-density.js",
"/js/ov-density.js",
"/js/home-fx.js",
"/js/home-fx.js",
- "/js/home-tour.js",
"/shared/tooltip.js"
"/shared/tooltip.js"
],
],
"idle": [
"idle": [
@@ -117,6 +116,7 @@
@@ -117,6 +116,7 @@
"uploads"
"uploads"
],
],
"js": [
"js": [
+ "/js/channel-socials.js",
"/js/app-channel.js"
"/js/app-channel.js"
]
]
},
},
@@ -177,6 +177,11 @@
@@ -177,6 +177,11 @@
"aivCloneChatter"
"aivCloneChatter"
]
]
},
},
+ "homeTour": {
+ "js": [
+ "/js/home-tour.js"
+ ]
+ },
"guide": {
"guide": {
"css": [
"css": [
"/css/ov-guide.css"
"/css/ov-guide.css"
@@ -245,6 +245,7 @@
@@ -245,6 +245,7 @@
<h1 id="ch-display-name-offline">Channel</h1>
<h1 id="ch-display-name-offline">Channel</h1>
<p class="ch-username" id="ch-username-offline">username</p>
<p class="ch-username" id="ch-username-offline">username</p>
<p class="ch-description" id="ch-description-offline"></p>
<p class="ch-description" id="ch-description-offline"></p>
+ <div class="ch-social-pills" id="ch-socials-offline" hidden></div>
<div class="ch-meta">
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
@@ -668,6 +668,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -668,6 +668,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// ── LIVE STATE ──
// ── LIVE STATE ──
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-offline-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = 'none';
+ _placeBelowFold();
// Populate streamer info bar (below video). Avatar image (letter fallback)
// Populate streamer info bar (below video). Avatar image (letter fallback)
// + display name both link to the channel; the @handle is dropped.
// + display name both link to the channel; the @handle is dropped.
@@ -747,6 +748,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -747,6 +748,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// ── OFFLINE STATE ──
// ── OFFLINE STATE ──
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = '';
document.getElementById('ch-offline-area').style.display = '';
+ _placeBelowFold();
// Populate offline header
// Populate offline header
document.getElementById('ch-avatar-offline').textContent = (ch.username || '?')[0].toUpperCase();
document.getElementById('ch-avatar-offline').textContent = (ch.username || '?')[0].toUpperCase();
@@ -754,6 +756,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -754,6 +756,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_activeChannelIsOwnerRank = !!ch.is_owner;
_activeChannelIsOwnerRank = !!ch.is_owner;
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-description-offline').textContent = ch.description || '';
+ if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-offline'), ch.social_links || []);
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
_loadGameBadge(ch.username);
_loadGameBadge(ch.username);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
@@ -1738,6 +1741,28 @@ function _effAiOverviewShow(ch) {
@@ -1738,6 +1741,28 @@ function _effAiOverviewShow(ch) {
return !_channelHasBio(ch); // auto
return !_channelHasBio(ch); // auto
}
}
+// Wide screens: the tabs (About, Videos, …) live inside the visible main column, which scrolls next to a full-height
+// chat, so nothing leaves an empty band beside the chat or a gutter around a capped container. Narrow screens keep
+// them after the whole layout (chat before tabs, as the stacked layout reads).
+const _BELOW_FOLD_WIDE = window.matchMedia('(min-width: 1181px)');
+let _belowFoldHome = null;
+function _placeBelowFold() {
+ const fold = document.querySelector('#page-channel .channel-below-fold');
+ if (!fold) return;
+ if (!_belowFoldHome) { _belowFoldHome = document.createComment('channel-below-fold'); fold.parentNode.insertBefore(_belowFoldHome, fold); }
+ const live = document.getElementById('ch-live-area');
+ const area = live && live.style.display !== 'none' ? live : document.getElementById('ch-offline-area');
+ const main = area && area.querySelector('.stream-main');
+ if (_BELOW_FOLD_WIDE.matches && main) {
+ if (fold.parentNode !== main) main.appendChild(fold);
+ fold.classList.add('is-in-main');
+ } else if (fold.previousSibling !== _belowFoldHome) {
+ _belowFoldHome.parentNode.insertBefore(fold, _belowFoldHome.nextSibling);
+ fold.classList.remove('is-in-main');
+ } else fold.classList.remove('is-in-main');
+}
+if (_BELOW_FOLD_WIDE.addEventListener) _BELOW_FOLD_WIDE.addEventListener('change', _placeBelowFold);
+
// About tab visibility + default open tab depend on whether the streamer has any
// About tab visibility + default open tab depend on whether the streamer has any
// About content (bio/panels) or weather enabled.
// About content (bio/panels) or weather enabled.
function _resetChannelTabs(ch) {
function _resetChannelTabs(ch) {
@@ -1747,7 +1772,7 @@ function _resetChannelTabs(ch) {
@@ -1747,7 +1772,7 @@ function _resetChannelTabs(ch) {
const bio = (ch.bio || ch.description || '').trim();
const bio = (ch.bio || ch.description || '').trim();
let panels = [];
let panels = [];
try { panels = typeof ch.panels === 'string' ? JSON.parse(ch.panels || '[]') : (ch.panels || []); } catch { panels = []; }
try { panels = typeof ch.panels === 'string' ? JSON.parse(ch.panels || '[]') : (ch.panels || []); } catch { panels = []; }
- hasAbout = !!bio || (Array.isArray(panels) && panels.length > 0);
+ hasAbout = !!bio || (Array.isArray(panels) && panels.length > 0) || (Array.isArray(ch.social_links) && ch.social_links.length > 0);
}
}
// The AI overview at the top of About also counts as About content, so the tab shows
// The AI overview at the top of About also counts as About content, so the tab shows
// even when the streamer hasn't written a bio (per the auto/show/hide preference).
// even when the streamer hasn't written a bio (per the auto/show/hide preference).
@@ -2188,6 +2213,8 @@ function _applyChannelTabMeta(data) {
@@ -2188,6 +2213,8 @@ function _applyChannelTabMeta(data) {
/* ── About tab: inline live panel editor (Twitch/Kick-style under-stream area) ── */
/* ── About tab: inline live panel editor (Twitch/Kick-style under-stream area) ── */
let _aboutPanels = []; // working array of panels
let _aboutPanels = []; // working array of panels
let _aboutBio = '';
let _aboutBio = '';
+let _aboutSocials = []; // the channel's social links as viewers see them (server/social/links.js)
+let _aboutCh = null;
let _aboutIsOwner = false; // is the viewer the streamer?
let _aboutIsOwner = false; // is the viewer the streamer?
let _aboutCanEdit = false; // can the viewer edit (streamer, or an allowed mod)?
let _aboutCanEdit = false; // can the viewer edit (streamer, or an allowed mod)?
let _aboutEditMode = false;
let _aboutEditMode = false;
@@ -2221,6 +2248,9 @@ function _renderChannelAbout(ch) {
@@ -2221,6 +2248,9 @@ function _renderChannelAbout(ch) {
_aboutPanels = (Array.isArray(panels) ? panels : []).map(_normalizeAboutPanel);
_aboutPanels = (Array.isArray(panels) ? panels : []).map(_normalizeAboutPanel);
_aboutAiOverview = ch.ai_overview || '';
_aboutAiOverview = ch.ai_overview || '';
_aboutAiPref = ch.ai_overview_pref || (ch.hide_ai_overview ? 'hide' : 'auto');
_aboutAiPref = ch.ai_overview_pref || (ch.hide_ai_overview ? 'hide' : 'auto');
+ _aboutSocials = Array.isArray(ch.social_links) ? ch.social_links : [];
+ _aboutCh = ch;
+ if (window.ChannelSocials) ChannelSocials.reset();
_aboutEditMode = false;
_aboutEditMode = false;
_renderAboutView();
_renderAboutView();
}
}
@@ -2246,7 +2276,7 @@ function _renderAboutView() {
@@ -2246,7 +2276,7 @@ function _renderAboutView() {
const host = document.getElementById('ch-about-content');
const host = document.getElementById('ch-about-content');
if (!host) return;
if (!host) return;
const aiHtml = _aboutAiOverviewHTML();
const aiHtml = _aboutAiOverviewHTML();
- const hasContent = _aboutBio || _aboutPanels.length || aiHtml;
+ const hasContent = _aboutBio || _aboutPanels.length || aiHtml || _aboutSocials.length;
let html = '';
let html = '';
if (!hasContent) {
if (!hasContent) {
html += _aboutCanEdit
html += _aboutCanEdit
@@ -2257,8 +2287,10 @@ function _renderAboutView() {
@@ -2257,8 +2287,10 @@ function _renderAboutView() {
}
}
html += aiHtml; // AI overview card leads the About tab
html += aiHtml; // AI overview card leads the About tab
if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;
if (_aboutBio) html += `<div class="ch-about-bio">${_linkify(esc(_aboutBio))}</div><div class="ch-about-bio-en" id="ch-about-bio-en"></div>`;
+ html += '<section class="ch-socials" id="ch-about-socials" hidden></section>';
html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';
html += '<div class="ch-about-panels">' + _aboutPanels.map((p, i) => _aboutPanelViewHTML(p, i)).join('') + '</div>';
host.innerHTML = html;
host.innerHTML = html;
+ if (window.ChannelSocials) ChannelSocials.cards(document.getElementById('ch-about-socials'), _aboutSocials, currentChannelUsername);
_fillWeatherPanels();
_fillWeatherPanels();
void _fillBioTranslation();
void _fillBioTranslation();
}
}
@@ -2657,9 +2689,11 @@ function _renderAboutEdit() {
@@ -2657,9 +2689,11 @@ function _renderAboutEdit() {
<label class="ch-edit-label">Bio</label>
<label class="ch-edit-label">Bio</label>
<textarea id="ch-about-bio-edit" rows="3" placeholder="Tell viewers about yourself…" oninput="_aboutBio=this.value;_markAboutDirty()">${esc(_aboutBio)}</textarea>
<textarea id="ch-about-bio-edit" rows="3" placeholder="Tell viewers about yourself…" oninput="_aboutBio=this.value;_markAboutDirty()">${esc(_aboutBio)}</textarea>
</div>
</div>
+ <div class="ch-about-edit-socials" id="ch-about-socials-edit"></div>
<div class="ch-about-panels ch-about-panels-edit" id="ch-about-panels-edit">
<div class="ch-about-panels ch-about-panels-edit" id="ch-about-panels-edit">
${_aboutPanels.map((p, i) => _aboutPanelEditHTML(p, i)).join('')}
${_aboutPanels.map((p, i) => _aboutPanelEditHTML(p, i)).join('')}
</div>`;
</div>`;
+ if (window.ChannelSocials && _aboutCh) ChannelSocials.editor(document.getElementById('ch-about-socials-edit'), _aboutCh, _markAboutDirty);
_wireAboutDrag();
_wireAboutDrag();
_updateAboutSaveBtn();
_updateAboutSaveBtn();
if (_aboutIsOwner && _aboutChannelId === null) _loadAboutModsSetting();
if (_aboutIsOwner && _aboutChannelId === null) _loadAboutModsSetting();
@@ -2734,7 +2768,7 @@ function addAboutPanel(type) {
@@ -2734,7 +2768,7 @@ function addAboutPanel(type) {
_markAboutDirty();
_markAboutDirty();
}
}
function removeAboutPanel(i) { _aboutPanels.splice(i, 1); _renderAboutEdit(); _markAboutDirty(); }
function removeAboutPanel(i) { _aboutPanels.splice(i, 1); _renderAboutEdit(); _markAboutDirty(); }
-function cancelAboutEdit() { _aboutEditMode = false; _aboutDirty = false; _renderAboutView(); }
+function cancelAboutEdit() { _aboutEditMode = false; _aboutDirty = false; if (window.ChannelSocials) ChannelSocials.reset(); _renderAboutView(); }
async function uploadAboutPanelImage(i, input) {
async function uploadAboutPanelImage(i, input) {
const file = input.files && input.files[0];
const file = input.files && input.files[0];
if (!file) return;
if (!file) return;
@@ -2753,9 +2787,15 @@ async function saveAboutInline() {
@@ -2753,9 +2787,15 @@ async function saveAboutInline() {
try {
try {
// Targets the channel by username, so an allowed mod writes to the STREAMER's
// Targets the channel by username, so an allowed mod writes to the STREAMER's
// channel (not their own). Server enforces the edit permission.
// channel (not their own). Server enforces the edit permission.
- await api(`/streams/channel/${encodeURIComponent(currentChannelUsername)}/about`, {
- method: 'PUT', body: { bio: _aboutBio, panels: JSON.stringify(_aboutPanels), ai_overview_pref: _aboutAiPref, hide_ai_overview: _aboutAiPref === 'hide' ? 1 : 0 },
+ const socials = window.ChannelSocials ? ChannelSocials.payload() : undefined;
+ const saved = await api(`/streams/channel/${encodeURIComponent(currentChannelUsername)}/about`, {
+ method: 'PUT', body: { bio: _aboutBio, panels: JSON.stringify(_aboutPanels), ai_overview_pref: _aboutAiPref, hide_ai_overview: _aboutAiPref === 'hide' ? 1 : 0, ...(socials ? { social_links: socials } : {}) },
});
});
+ if (saved && Array.isArray(saved.social_links)) {
+ _aboutSocials = saved.social_links;
+ if (_aboutCh) { _aboutCh.social_links = saved.social_links; if (saved.social_links_editor) _aboutCh.social_links_editor = saved.social_links_editor; }
+ if (window.ChannelSocials) { ChannelSocials.reset(); ChannelSocials.pills(document.getElementById('ch-socials-offline'), _aboutSocials); }
+ }
if (_aboutIsOwner && currentUser) currentUser.bio = _aboutBio;
if (_aboutIsOwner && currentUser) currentUser.bio = _aboutBio;
_aboutEditMode = false;
_aboutEditMode = false;
_renderAboutView();
_renderAboutView();
@@ -1162,7 +1162,20 @@ function _watchIntroSighting() {
@@ -1162,7 +1162,20 @@ function _watchIntroSighting() {
* .container on the home page — but the changelog lives in a later one, so "moved to the bottom"
* .container on the home page — but the changelog lives in a later one, so "moved to the bottom"
* actually landed them in the middle of the page, above Recently Online.
* actually landed them in the middle of the page, above Recently Online.
*/
*/
+/** The restream tour (public/js/home-tour.js, feature homeTour) sits below the fold: its code loads when the mount nears the viewport. */
+let _homeTourArmed = false;
+function _lazyHomeTour() {
+ const el = document.getElementById('home-tour-mount');
+ if (_homeTourArmed || !el || !window.ov) return;
+ _homeTourArmed = true;
+ const go = () => window.ov.load('homeTour').catch(() => { _homeTourArmed = false; });
+ if (!('IntersectionObserver' in window)) return go();
+ const io = new IntersectionObserver((ents) => { if (ents.some((e) => e.isIntersecting)) { io.disconnect(); go(); } }, { rootMargin: '800px 0px' });
+ io.observe(el);
+}
+
function demoteHomeIntroSections() {
function demoteHomeIntroSections() {
+ _lazyHomeTour();
_watchIntroSighting();
_watchIntroSighting();
if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
const containers = document.querySelectorAll('#page-home > .container, #page-home .container');
const containers = document.querySelectorAll('#page-home > .container, #page-home .container');
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+/*
+ OpenVibe.Live — a channel's social links (server/social/): brand-coloured pills on the offline screen, rich cards in
+ About (live status, latest posts or videos, repositories, page previews, loaded when they scroll into view), and the
+ editor in About's edit mode. Links come from GET /api/streams/channel/:username (social_links); previews from
+ /api/social/preview/:username/:index.
+*/
+(function () {
+ 'use strict';
+ const escH = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const ago = (iso) => {
+ const t = Date.parse(iso || ''); if (!t) return '';
+ const s = Math.max(1, Math.round((Date.now() - t) / 1000));
+ for (const [n, u] of [[31536000, 'y'], [2592000, 'mo'], [86400, 'd'], [3600, 'h'], [60, 'm']]) if (s >= n) return `${Math.floor(s / n)}${u} ago`;
+ return 'just now';
+ };
+ const label = (l) => l.label || (l.handle ? (['x', 'tiktok', 'threads', 'instagram', 'twitch', 'kick', 'github', 'bluesky', 'mastodon'].includes(l.kind) && !String(l.handle).startsWith('@') ? `@${l.handle}` : l.handle) : (l.kind === 'website' || l.kind === 'custom' ? l.url.replace(/^https:\/\/(www\.)?/, '') : l.name));
+
+ /** Pills: one line of links (the offline screen). */
+ function pills(host, links) {
+ if (!host) return;
+ host.innerHTML = (links || []).map((l) => `<a class="ch-social-pill" style="--sc:${escH(l.color)}" href="${escH(l.url)}" target="_blank" rel="noopener me" title="${escH(l.name)}${l.auto ? ' (connected)' : ''}"><i class="${escH(l.icon)}" aria-hidden="true"></i><span>${escH(label(l))}</span></a>`).join('');
+ host.hidden = !(links || []).length;
+ }
+
+ /** Rich cards (About): the card is a link at once; its preview fills in when it scrolls into view. */
+ function cards(host, links, username) {
+ if (!host) return;
+ if (!(links || []).length) { host.innerHTML = ''; host.hidden = true; return; }
+ host.hidden = false;
+ host.innerHTML = `<div class="ch-socials-head"><i class="fa-solid fa-link"></i> Links</div><div class="ch-socials-grid">${links.map((l, i) => `
+ <article class="ch-social-card" style="--sc:${escH(l.color)}" data-i="${i}">
+ <a class="ch-social-card-top" href="${escH(l.url)}" target="_blank" rel="noopener me">
+ <span class="ch-social-ico"><i class="${escH(l.icon)}" aria-hidden="true"></i></span>
+ <span class="ch-social-id"><b>${escH(l.name)}</b><small>${escH(label(l))}</small></span>
+ <span class="ch-social-live" hidden>LIVE</span>
+ <i class="fa-solid fa-arrow-up-right-from-square ch-social-out" aria-hidden="true"></i>
+ </a>
+ <div class="ch-social-body"></div>
+ </article>`).join('')}</div>`;
+ const load = (card) => {
+ if (card.dataset.loaded) return;
+ card.dataset.loaded = '1';
+ const i = card.dataset.i;
+ fetch(`/api/social/preview/${encodeURIComponent(username)}/${i}`, { headers: { Accept: 'application/json' } })
+ .then((r) => (r.ok ? r.json() : null)).then((p) => fill(card, links[i], p)).catch(() => {});
+ };
+ const all = host.querySelectorAll('.ch-social-card');
+ if ('IntersectionObserver' in window) {
+ const io = new IntersectionObserver((ents) => { for (const e of ents) if (e.isIntersecting) { io.unobserve(e.target); load(e.target); } }, { rootMargin: '300px' });
+ all.forEach((c) => io.observe(c));
+ } else all.forEach(load);
+ }
+
+ function fill(card, link, p) {
+ if (!p || p.error || p.disabled) return;
+ const body = card.querySelector('.ch-social-body');
+ const bits = [];
+ if (p.live && p.live.live) {
+ card.querySelector('.ch-social-live').hidden = false;
+ card.classList.add('is-live');
+ if (p.live.thumbnail) bits.push(`<a class="ch-social-livethumb" href="${escH(link.url)}" target="_blank" rel="noopener"><img src="${escH(p.live.thumbnail)}" alt="" loading="lazy">${p.live.viewers != null ? `<span><i class="fa-solid fa-eye"></i> ${escH(Number(p.live.viewers).toLocaleString())}</span>` : ''}</a>`);
+ if (p.live.title) bits.push(`<p class="ch-social-livetitle">${escH(p.live.title)}</p>`);
+ }
+ if (p.title || p.subtitle) {
+ bits.push(`<div class="ch-social-meta">${p.image && !(p.live && p.live.live) ? `<img class="ch-social-avatar" src="${escH(p.image)}" alt="" loading="lazy">` : ''}<div>${p.title ? `<b>${escH(p.title)}</b>` : ''}${p.subtitle ? `<small>${escH(p.subtitle)}</small>` : ''}</div></div>`);
+ }
+ if ((p.items || []).length) {
+ bits.push(`<ul class="ch-social-items">${p.items.slice(0, 3).map((it) => `<li><a href="${escH(it.url || link.url)}" target="_blank" rel="noopener">${it.image ? `<img src="${escH(it.image)}" alt="" loading="lazy">` : ''}<span>${escH(it.text || '')}${it.at ? `<time>${escH(ago(it.at))}</time>` : ''}</span></a></li>`).join('')}</ul>`);
+ }
+ if (p.embed) bits.push(`<button type="button" class="btn btn-sm btn-outline ch-social-embed-btn"><i class="fa-brands fa-x-twitter"></i> Show recent posts</button>`);
+ body.innerHTML = bits.join('');
+ const btn = body.querySelector('.ch-social-embed-btn');
+ if (btn) btn.addEventListener('click', () => {
+ const f = document.createElement('iframe');
+ f.className = 'ch-social-embed';
+ f.src = p.embed; f.loading = 'lazy'; f.title = `Posts by ${label(link)} on X`;
+ f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox');
+ btn.replaceWith(f);
+ });
+ }
+
+ // ── Editor (About → edit) ─────────────────────────────
+ let _state = null; // { links: [{ kind, url, label, preview }], hidden_auto: [], connected: [], missing: [] }
+ let _catalog = null;
+ let _onDirty = () => {};
+
+ function editorState(ch) {
+ const saved = (ch.social_links || []).filter((l) => !l.auto).map((l) => ({ kind: l.kind, url: l.url, label: l.label || '', preview: l.preview !== false }));
+ const ed = ch.social_links_editor || {};
+ _state = { links: saved, hidden_auto: (ed.hidden_auto || []).slice(), connected: ed.connected || [], missing: ed.restreams_without_link || [] };
+ return _state;
+ }
+
+ async function editor(host, ch, onDirty) {
+ if (!host) return;
+ _onDirty = onDirty || (() => {});
+ if (!_state) editorState(ch);
+ if (!_catalog) { try { _catalog = (await (await fetch('/api/social/catalog')).json()).platforms; } catch { _catalog = []; } }
+ const opts = (sel) => _catalog.map((p) => `<option value="${escH(p.kind)}"${p.kind === sel ? ' selected' : ''}>${escH(p.name)}</option>`).join('');
+ const s = _state;
+ host.innerHTML = `
+ <label class="ch-edit-label"><i class="fa-solid fa-link"></i> Social links</label>
+ ${s.connected.length ? `<div class="ch-social-connected">${s.connected.map((c) => `
+ <label class="ch-social-conn" style="--sc:${escH(c.color)}"><input type="checkbox" data-auto="${escH(c.kind)}" ${s.hidden_auto.includes(c.kind) ? '' : 'checked'}>
+ <i class="${escH(c.icon)}"></i> Show your connected ${escH(c.name)} (${escH(label(c))})</label>`).join('')}</div>` : ''}
+ ${s.missing.length ? `<p class="muted ch-social-hint">You restream to ${s.missing.map((k) => escH((_catalog.find((p) => p.kind === k) || {}).name || k)).join(', ')}: add ${s.missing.length === 1 ? 'that channel' : 'those channels'} below so viewers can find you there.</p>` : ''}
+ <div class="ch-social-rows">${s.links.map((l, i) => `
+ <div class="ch-social-row" data-i="${i}">
+ <select data-f="kind" aria-label="Platform">${opts(l.kind)}</select>
+ <input data-f="url" value="${escH(l.url)}" placeholder="Profile URL or @handle" maxlength="300" aria-label="Link">
+ <input data-f="label" value="${escH(l.label)}" placeholder="Label (optional)" maxlength="40" aria-label="Label">
+ <label class="ch-social-prev" title="Show a live preview (posts, videos, live status)"><input type="checkbox" data-f="preview" ${l.preview ? 'checked' : ''}> Preview</label>
+ <button type="button" class="btn btn-sm btn-outline" data-act="up" title="Move up" ${i === 0 ? 'disabled' : ''}><i class="fa-solid fa-arrow-up"></i></button>
+ <button type="button" class="btn btn-sm btn-outline" data-act="del" title="Remove"><i class="fa-solid fa-trash"></i></button>
+ </div>`).join('')}</div>
+ <button type="button" class="btn btn-sm btn-outline" data-act="add" ${s.links.length >= 16 ? 'disabled' : ''}><i class="fa-solid fa-plus"></i> Add a link</button>`;
+ host.onchange = host.oninput = (e) => {
+ const t = e.target;
+ if (t.dataset.auto) { const k = t.dataset.auto; s.hidden_auto = t.checked ? s.hidden_auto.filter((x) => x !== k) : [...new Set([...s.hidden_auto, k])]; _onDirty(); return; }
+ const row = t.closest('.ch-social-row'); if (!row) return;
+ const l = s.links[Number(row.dataset.i)]; const f = t.dataset.f;
+ if (!l || !f) return;
+ l[f] = t.type === 'checkbox' ? t.checked : t.value;
+ if (f === 'url' && /^https?:\/\//i.test(t.value)) {
+ // Pick the platform from a pasted URL.
+ try { const host2 = new URL(t.value).hostname.replace(/^www\./, ''); const hit = _catalog.find((p) => ({ 'x.com': 'x', 'twitter.com': 'x', 'bsky.app': 'bluesky', 'youtu.be': 'youtube' }[host2] || host2.split('.')[0]) === p.kind); if (hit && hit.kind !== l.kind) { l.kind = hit.kind; row.querySelector('select').value = hit.kind; } } catch { /* typing */ }
+ }
+ _onDirty();
+ };
+ host.onclick = (e) => {
+ const b = e.target.closest('button[data-act]'); if (!b) return;
+ const act = b.dataset.act; const row = b.closest('.ch-social-row'); const i = row ? Number(row.dataset.i) : -1;
+ if (act === 'add') s.links.push({ kind: 'custom', url: '', label: '', preview: true });
+ if (act === 'del') s.links.splice(i, 1);
+ if (act === 'up' && i > 0) { const [m] = s.links.splice(i, 1); s.links.splice(i - 1, 0, m); }
+ _onDirty();
+ editor(host, ch, _onDirty);
+ };
+ }
+
+ /** What About's save sends. */
+ function payload() {
+ if (!_state) return undefined;
+ return { links: _state.links.filter((l) => String(l.url || '').trim()), hidden_auto: _state.hidden_auto };
+ }
+ function reset() { _state = null; }
+
+ window.ChannelSocials = { pills, cards, editor, payload, reset };
+})();
@@ -137,6 +137,7 @@
@@ -137,6 +137,7 @@
if (!d) { host.innerHTML = ''; return; }
if (!d) { host.innerHTML = ''; return; }
const name = d.channel ? d.channel.display_name : 'this channel';
const name = d.channel ? d.channel.display_name : 'this channel';
const sections = [];
const sections = [];
+ let side = '';
if ((d.live || []).length) {
if ((d.live || []).length) {
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}`)}>
@@ -158,14 +159,13 @@
@@ -158,14 +159,13 @@
<span class="cc-grade" style="--g:${GRADE[r.grade] || GRADE.B}">${esc(r.grade)}</span>
<span class="cc-grade" style="--g:${GRADE[r.grade] || GRADE.B}">${esc(r.grade)}</span>
<span class="dsc-recap-body"><b>${esc(r.headline || r.title)}</b><small>${esc(r.display_name)} · ${esc(ago(r.ended_at))} · ${durWords(r.duration_seconds)} · peak ${n(r.peak_viewers)}${r.chat_messages ? ` · ${n(r.chat_messages)} chat lines` : ''}</small></span>
<span class="dsc-recap-body"><b>${esc(r.headline || r.title)}</b><small>${esc(r.display_name)} · ${esc(ago(r.ended_at))} · ${durWords(r.duration_seconds)} · peak ${n(r.peak_viewers)}${r.chat_messages ? ` · ${n(r.chat_messages)} chat lines` : ''}</small></span>
</a>`).join('')}</div>` : '';
</a>`).join('')}</div>` : '';
- sections.push(`<section class="dsc-sec dsc-sec--side">${star}${recaps}</section>`);
+ side = `<aside class="dsc-side">${star}${recaps}</aside>`;
}
}
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.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>`);
}
}
- if (!sections.length) { host.innerHTML = ''; return; }
- const noLeft = !(d.live || []).length && !(d.clips || []).length;
+ if (!sections.length && !side) { host.innerHTML = ''; return; }
// This board is a whole second page of content sitting under an offline channel. Someone
// This board is a whole second page of content sitting under an offline channel. Someone
// who came for this streamer should meet a single line about it, not scroll past six
// who came for this streamer should meet a single line about it, not scroll past six
@@ -189,7 +189,7 @@
@@ -189,7 +189,7 @@
<span class="dsc-toggle-chev" aria-hidden="true"><i class="fa-solid fa-chevron-down"></i></span>
<span class="dsc-toggle-chev" aria-hidden="true"><i class="fa-solid fa-chevron-down"></i></span>
</button>
</button>
<div class="dsc-body" id="dsc-body"${startOpen ? '' : ' hidden'}>
<div class="dsc-body" id="dsc-body"${startOpen ? '' : ' hidden'}>
- <div class="dsc${noLeft ? ' dsc--noleft' : ''}">${sections.join('')}</div>
+ <div class="dsc${side ? '' : ' dsc--noside'}${sections.length ? '' : ' dsc--sideonly'}">${sections.length ? `<div class="dsc-main">${sections.join('')}</div>` : ''}${side}</div>
</div>
</div>
</div>`;
</div>`;
@@ -2345,6 +2345,10 @@ function initDb() {
@@ -2345,6 +2345,10 @@ function initDb() {
if (!cols.includes('offline_css')) {
if (!cols.includes('offline_css')) {
database.exec('ALTER TABLE channels ADD COLUMN offline_css TEXT');
database.exec('ALTER TABLE channels ADD COLUMN offline_css TEXT');
}
}
+ // Social links shown on the offline screen and in About (server/social/links.js): { links, hidden_auto } JSON.
+ if (!cols.includes('social_links')) {
+ database.exec('ALTER TABLE channels ADD COLUMN social_links TEXT');
+ }
} catch (e) { console.warn('[DB] channel offline-screen migration:', e.message); }
} catch (e) { console.warn('[DB] channel offline-screen migration:', e.message); }
// Migrate: add control_config_id to streams for stream-scoped control profiles
// Migrate: add control_config_id to streams for stream-scoped control profiles
@@ -4277,7 +4281,7 @@ function updateChannel(userId, fields) {
@@ -4277,7 +4281,7 @@ function updateChannel(userId, fields) {
const updates = [];
const updates = [];
const params = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
for (const [key, val] of Object.entries(fields)) {
- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled'].includes(key)) {
+ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled', 'social_links'].includes(key)) {
updates.push(`${key} = ?`);
updates.push(`${key} = ?`);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
}
}
@@ -698,6 +698,8 @@ app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
@@ -698,6 +698,8 @@ app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)
app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
+app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
+app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
app.use('/api/comments', commentRoutes);
app.use('/api/comments', commentRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/controls', controlRoutes);
app.use('/api/onvif', onvifRoutes);
app.use('/api/onvif', onvifRoutes);
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * A channel's social links: the platforms it restreams to (from its OAuth platform connections), plus the links the
+ * streamer adds (X, Bluesky, Instagram, TikTok, YouTube, Discord, GitHub, … or any custom https link). Stored as
+ * JSON on channels.social_links; shown as pills on the offline screen and as rich cards in the About tab.
+ *
+ * cleanSocialLinks(input) → the list to store, or null when it is not a valid list
+ * channelSocialLinks(channel, db) → what viewers see: saved links first, then connected platforms not already
+ * listed and not hidden (channels.social_links.hidden_auto)
+ */
+
+const MAX_LINKS = 16;
+const MAX_LABEL = 40;
+
+// kind → how to recognise, normalise and show it. `hosts` are the only hosts a link of that kind may point at.
+const PLATFORMS = {
+ twitch: { name: 'Twitch', icon: 'fa-brands fa-twitch', color: '#9146ff', hosts: ['twitch.tv'], url: (h) => `https://www.twitch.tv/${h}`, handle: /^\/([A-Za-z0-9_]{2,25})\/?$/ },
+ youtube: { name: 'YouTube', icon: 'fa-brands fa-youtube', color: '#ff0033', hosts: ['youtube.com', 'youtu.be'], url: (h) => `https://www.youtube.com/${h.startsWith('@') || h.startsWith('channel/') ? h : `@${h}`}`, handle: /^\/((?:@[A-Za-z0-9._-]{3,30})|(?:channel\/UC[A-Za-z0-9_-]{22})|(?:c\/[A-Za-z0-9._-]{1,60}))\/?$/ },
+ kick: { name: 'Kick', icon: 'fa-brands fa-kickstarter-k', color: '#53fc18', hosts: ['kick.com'], url: (h) => `https://kick.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,25})\/?$/ },
+ x: { name: 'X', icon: 'fa-brands fa-x-twitter', color: '#e7e9ea', hosts: ['x.com', 'twitter.com'], url: (h) => `https://x.com/${h}`, handle: /^\/([A-Za-z0-9_]{1,15})\/?$/ },
+ bluesky: { name: 'Bluesky', icon: 'fa-brands fa-bluesky', color: '#1185fe', hosts: ['bsky.app'], url: (h) => `https://bsky.app/profile/${h}`, handle: /^\/profile\/([A-Za-z0-9.-]{3,253})\/?$/ },
+ instagram: { name: 'Instagram', icon: 'fa-brands fa-instagram', color: '#e1306c', hosts: ['instagram.com'], url: (h) => `https://www.instagram.com/${h}`, handle: /^\/([A-Za-z0-9._]{1,30})\/?$/ },
+ tiktok: { name: 'TikTok', icon: 'fa-brands fa-tiktok', color: '#25f4ee', hosts: ['tiktok.com'], url: (h) => `https://www.tiktok.com/@${h.replace(/^@/, '')}`, handle: /^\/@([A-Za-z0-9._]{2,24})\/?$/ },
+ discord: { name: 'Discord', icon: 'fa-brands fa-discord', color: '#5865f2', hosts: ['discord.gg', 'discord.com'], url: (h) => `https://discord.gg/${h}`, handle: /^\/(?:invite\/)?([A-Za-z0-9-]{2,32})\/?$/ },
+ github: { name: 'GitHub', icon: 'fa-brands fa-github', color: '#e6edf3', hosts: ['github.com'], url: (h) => `https://github.com/${h}`, handle: /^\/([A-Za-z0-9-]{1,39})\/?$/ },
+ reddit: { name: 'Reddit', icon: 'fa-brands fa-reddit-alien', color: '#ff4500', hosts: ['reddit.com'], url: (h) => `https://www.reddit.com/user/${h}`, handle: /^\/(?:user|u)\/([A-Za-z0-9_-]{3,20})\/?$/ },
+ threads: { name: 'Threads', icon: 'fa-brands fa-threads', color: '#e7e9ea', hosts: ['threads.net', 'threads.com'], url: (h) => `https://www.threads.net/@${h.replace(/^@/, '')}`, handle: /^\/@([A-Za-z0-9._]{1,30})\/?$/ },
+ facebook: { name: 'Facebook', icon: 'fa-brands fa-facebook', color: '#1877f2', hosts: ['facebook.com', 'fb.com'], url: (h) => `https://www.facebook.com/${h}`, handle: /^\/([A-Za-z0-9.]{5,50})\/?$/ },
+ rumble: { name: 'Rumble', icon: 'fa-solid fa-play', color: '#85c742', hosts: ['rumble.com'], url: (h) => `https://rumble.com/c/${h}`, handle: /^\/c\/([A-Za-z0-9_-]{2,50})\/?$/ },
+ patreon: { name: 'Patreon', icon: 'fa-brands fa-patreon', color: '#ff424d', hosts: ['patreon.com'], url: (h) => `https://www.patreon.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },
+ spotify: { name: 'Spotify', icon: 'fa-brands fa-spotify', color: '#1db954', hosts: ['open.spotify.com'], url: null, handle: null },
+ soundcloud:{ name: 'SoundCloud',icon: 'fa-brands fa-soundcloud',color: '#ff5500', hosts: ['soundcloud.com'], url: (h) => `https://soundcloud.com/${h}`, handle: /^\/([A-Za-z0-9_-]{2,64})\/?$/ },
+ linkedin: { name: 'LinkedIn', icon: 'fa-brands fa-linkedin', color: '#0a66c2', hosts: ['linkedin.com'], url: (h) => `https://www.linkedin.com/in/${h}`, handle: /^\/in\/([A-Za-z0-9-]{3,100})\/?$/ },
+ mastodon: { name: 'Mastodon', icon: 'fa-brands fa-mastodon', color: '#6364ff', hosts: null, url: null, handle: /^\/@([A-Za-z0-9_]{1,30})\/?$/ },
+ website: { name: 'Website', icon: 'fa-solid fa-globe', color: '#60a5fa', hosts: null, url: null, handle: null },
+ custom: { name: 'Link', icon: 'fa-solid fa-link', color: '#94a3b8', hosts: null, url: null, handle: null },
+};
+const AUTO_KINDS = ['twitch', 'youtube', 'kick'];
+
+const hostMatches = (host, hosts) => hosts.some((h) => host === h || host.endsWith(`.${h}`));
+
+/** Which platform a URL belongs to (by host), or null. */
+function kindOfUrl(u) {
+ let url; try { url = new URL(u); } catch { return null; }
+ const host = url.hostname.toLowerCase();
+ for (const [kind, p] of Object.entries(PLATFORMS)) if (p.hosts && hostMatches(host, p.hosts)) return kind;
+ return null;
+}
+
+/**
+ * One link, validated: { kind, url, handle, label, preview }. Accepts a URL, or a handle for a platform with a
+ * handle pattern ("@name", "name"). https only; a platform link must point at that platform's hosts; custom and
+ * website take any public https URL (the preview fetch checks the address, server/net/egress.js).
+ */
+function cleanLink(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ let kind = String(raw.kind || '').toLowerCase();
+ let value = String(raw.url || raw.handle || '').trim().slice(0, 300);
+ if (!value) return null;
+ if (!/^https?:\/\//i.test(value)) {
+ const p = PLATFORMS[kind];
+ if (!p || !p.url) {
+ if (kind === 'custom' || kind === 'website' || !kind) value = `https://${value.replace(/^\/+/, '')}`;
+ else return null;
+ } else value = p.url(value.replace(/^@/, kind === 'youtube' ? '@' : ''));
+ }
+ let url; try { url = new URL(value.replace(/^http:\/\//i, 'https://')); } catch { return null; }
+ if (url.protocol !== 'https:' || url.username || url.password || !url.hostname.includes('.')) return null;
+ const detected = kindOfUrl(url.href);
+ if (!PLATFORMS[kind] || kind === 'custom' || kind === 'website') kind = detected || (kind === 'website' ? 'website' : 'custom');
+ else if (PLATFORMS[kind].hosts && detected !== kind) return null;
+ const p = PLATFORMS[kind];
+ const m = p.handle && p.handle.exec(url.pathname);
+ const handle = kind === 'mastodon' ? (m ? `@${m[1]}@${url.hostname}` : null) : m ? m[1] : null;
+ const label = raw.label ? String(raw.label).replace(/[<>]/g, '').trim().slice(0, MAX_LABEL) : '';
+ return { kind, url: url.href.replace(/\/$/, ''), handle, label, preview: raw.preview !== false };
+}
+
+/** The list to store: { links: [...], hidden_auto: [...] } as JSON, or null when invalid. */
+function cleanSocialLinks(input) {
+ if (input == null) return JSON.stringify({ links: [], hidden_auto: [] });
+ const obj = Array.isArray(input) ? { links: input } : typeof input === 'object' ? input : null;
+ if (!obj || !Array.isArray(obj.links || [])) return null;
+ const links = [];
+ const seen = new Set();
+ for (const raw of (obj.links || []).slice(0, MAX_LINKS * 2)) {
+ const l = cleanLink(raw);
+ if (!l || seen.has(l.url.toLowerCase())) continue;
+ seen.add(l.url.toLowerCase());
+ links.push(l);
+ if (links.length >= MAX_LINKS) break;
+ }
+ const hidden = Array.isArray(obj.hidden_auto) ? obj.hidden_auto.map(String).filter((k) => AUTO_KINDS.includes(k)) : [];
+ return JSON.stringify({ links, hidden_auto: [...new Set(hidden)] });
+}
+
+function parseStored(s) {
+ try { const o = typeof s === 'string' ? JSON.parse(s) : s; return { links: Array.isArray(o && o.links) ? o.links : [], hidden_auto: Array.isArray(o && o.hidden_auto) ? o.hidden_auto : [] }; } catch { return { links: [], hidden_auto: [] }; }
+}
+
+/** The platform's display fields for a link (name, icon, color). */
+function decorate(l, extra = {}) {
+ const p = PLATFORMS[l.kind] || PLATFORMS.custom;
+ return { ...l, name: p.name, icon: p.icon, color: p.color, ...extra };
+}
+
+/**
+ * What viewers see: saved links (in the streamer's order), then the platforms the channel is connected to for
+ * restreaming (Twitch, YouTube, Kick) that are not already listed and not hidden. `restreamsTo` (owner view only)
+ * lists restream platforms that have no public channel URL yet, so the editor can ask for it.
+ */
+function channelSocialLinks(channel, db, { owner = false } = {}) {
+ const stored = parseStored(channel && channel.social_links);
+ const out = stored.links.map((l) => decorate(l));
+ const kinds = new Set(out.map((l) => l.kind));
+ let conns = [];
+ try { conns = db.all('SELECT platform, platform_username, channel_url FROM platform_connections WHERE user_id = ?', [channel.user_id]) || []; } catch { conns = []; }
+ const auto = [];
+ for (const c of conns) {
+ if (!AUTO_KINDS.includes(c.platform) || kinds.has(c.platform)) continue;
+ const l = cleanLink({ kind: c.platform, url: c.channel_url || '', handle: c.platform_username || '' });
+ if (!l) continue;
+ const d = decorate(l, { auto: true, hidden: stored.hidden_auto.includes(c.platform) });
+ auto.push(d);
+ if (!d.hidden) { out.push(d); kinds.add(c.platform); }
+ }
+ const result = { links: out };
+ if (owner) {
+ let dests = [];
+ try { dests = db.all("SELECT DISTINCT platform FROM restream_destinations WHERE user_id = ? AND platform IN ('twitch', 'youtube', 'kick')", [channel.user_id]) || []; } catch { dests = []; }
+ result.connected = auto;
+ result.restreams_without_link = dests.map((d) => d.platform).filter((p) => !kinds.has(p) && !auto.some((a) => a.kind === p));
+ result.hidden_auto = stored.hidden_auto;
+ }
+ return result;
+}
+
+function catalog() {
+ return Object.entries(PLATFORMS).map(([kind, p]) => ({ kind, name: p.name, icon: p.icon, color: p.color, handle: !!p.handle && !!p.url }));
+}
+
+module.exports = { PLATFORMS, cleanLink, cleanSocialLinks, channelSocialLinks, parseStored, kindOfUrl, catalog, MAX_LINKS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+/**
+ * Rich previews for a channel's social links: live status, latest posts or videos, and page metadata, fetched on
+ * the server through the egress guard (server/net/egress.js: public addresses only, redirects re-checked, size and
+ * time caps) and cached ten minutes (errors one minute), so a busy channel page costs each upstream one request per
+ * link per ten minutes.
+ *
+ * preview(link) → { kind, title, subtitle, image, live: { live, title, viewers, thumbnail }?, items: [{ text, url, at, image }] }
+ *
+ * Sources, all public and keyless: Bluesky (public.api.bsky.app), Mastodon (the instance's public API), GitHub
+ * (api.github.com), YouTube (the channel's RSS feed), Twitch (the preview image redirects to a placeholder when the
+ * channel is offline), Kick (its public channel API, best effort), and OpenGraph for everything else. X has no
+ * usable free API: its card links out, and the channel page offers X's own timeline embed on click (isolated
+ * frame, /embed/x-timeline).
+ */
+const egress = require('../net/egress');
+
+const TTL = 10 * 60 * 1000;
+const ERROR_TTL = 60 * 1000;
+const MAX_ENTRIES = 2000;
+const UA = 'OpenVibe.Live/1.0 (+https://openvibe.live; channel link previews)';
+const cache = new Map(); // url → { at, ttl, value }
+
+async function getJson(url, { timeoutMs = 6000 } = {}) {
+ const r = await egress.fetchText(url, { timeoutMs, maxBytes: 768 * 1024, headers: { 'User-Agent': UA, Accept: 'application/json' } });
+ if (r.status < 200 || r.status >= 300) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
+ return JSON.parse(r.text);
+}
+async function getText(url, { timeoutMs = 6000, accept = 'text/html,application/xhtml+xml' } = {}) {
+ const r = await egress.fetchText(url, { timeoutMs, maxBytes: 768 * 1024, headers: { 'User-Agent': UA, Accept: accept } });
+ if (r.status < 200 || r.status >= 300) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
+ return r;
+}
+
+const decode = (s) => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'|'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
+const clip = (s, n = 280) => { const t = decode(String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+const httpsOnly = (u) => (typeof u === 'string' && /^https:\/\//i.test(u) ? u : null);
+
+/** OpenGraph / meta tags of a page. */
+async function openGraph(url) {
+ const r = await getText(url);
+ const head = r.text.slice(0, 200000);
+ const meta = (prop) => {
+ const re = new RegExp(`<meta[^>]+(?:property|name)=["']${prop}["'][^>]*>`, 'i');
+ const tag = re.exec(head);
+ const c = tag && /content=["']([^"']*)["']/i.exec(tag[0]);
+ return c ? decode(c[1]) : null;
+ };
+ const title = meta('og:title') || meta('twitter:title') || clip((/<title[^>]*>([^<]*)<\/title>/i.exec(head) || [])[1], 120);
+ let image = meta('og:image') || meta('twitter:image');
+ try { if (image) image = new URL(image, r.url).href; } catch { image = null; }
+ return { title: title || null, subtitle: clip(meta('og:description') || meta('description') || meta('twitter:description'), 200) || null, image: httpsOnly(image) };
+}
+
+const ADAPTERS = {
+ async bluesky(l) {
+ const actor = encodeURIComponent(l.handle);
+ const [p, feed] = await Promise.all([
+ getJson(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`),
+ getJson(`https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${actor}&limit=6&filter=posts_no_replies`),
+ ]);
+ const items = (feed.feed || []).filter((f) => !f.reason).slice(0, 3).map((f) => {
+ const rkey = String(f.post.uri || '').split('/').pop();
+ const img = f.post.embed && f.post.embed.images && f.post.embed.images[0];
+ return { text: clip(f.post.record && f.post.record.text), url: `https://bsky.app/profile/${l.handle}/post/${rkey}`, at: f.post.indexedAt || null, image: httpsOnly(img && img.thumb) };
+ });
+ return { title: p.displayName || p.handle, subtitle: `${Number(p.followersCount || 0).toLocaleString('en-US')} followers · ${Number(p.postsCount || 0).toLocaleString('en-US')} posts`, image: httpsOnly(p.avatar), items };
+ },
+ async mastodon(l) {
+ const u = new URL(l.url);
+ const acct = String(l.handle || '').replace(/^@/, '').split('@')[0];
+ const a = await getJson(`https://${u.hostname}/api/v1/accounts/lookup?acct=${encodeURIComponent(acct)}`);
+ const statuses = await getJson(`https://${u.hostname}/api/v1/accounts/${encodeURIComponent(a.id)}/statuses?limit=3&exclude_replies=true&exclude_reblogs=true`);
+ return {
+ title: a.display_name || a.username, subtitle: `${Number(a.followers_count || 0).toLocaleString('en-US')} followers`, image: httpsOnly(a.avatar),
+ items: (statuses || []).slice(0, 3).map((s) => ({ text: clip(s.content), url: httpsOnly(s.url), at: s.created_at || null, image: httpsOnly(s.media_attachments && s.media_attachments[0] && s.media_attachments[0].preview_url) })),
+ };
+ },
+ async github(l) {
+ const [u, repos] = await Promise.all([
+ getJson(`https://api.github.com/users/${encodeURIComponent(l.handle)}`),
+ getJson(`https://api.github.com/users/${encodeURIComponent(l.handle)}/repos?sort=pushed&per_page=3`),
+ ]);
+ return {
+ title: u.name || u.login, subtitle: `${Number(u.public_repos || 0)} repositories · ${Number(u.followers || 0).toLocaleString('en-US')} followers`, image: httpsOnly(u.avatar_url),
+ items: (repos || []).slice(0, 3).map((r) => ({ text: `${r.name}${r.description ? ` — ${clip(r.description, 140)}` : ''}${r.stargazers_count ? ` · ★ ${r.stargazers_count}` : ''}`, url: httpsOnly(r.html_url), at: r.pushed_at || null })),
+ };
+ },
+ async youtube(l) {
+ let channelId = (/\/channel\/(UC[A-Za-z0-9_-]{22})/.exec(l.url) || [])[1];
+ let og = null;
+ if (!channelId) {
+ const r = await getText(l.url);
+ channelId = (/"channelId":"(UC[A-Za-z0-9_-]{22})"/.exec(r.text) || /channel\/(UC[A-Za-z0-9_-]{22})/.exec(r.text) || [])[1];
+ og = null;
+ }
+ if (!channelId) throw new Error('no channel id');
+ const feed = await getText(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`, { accept: 'application/atom+xml,application/xml' });
+ const entries = feed.text.split('<entry>').slice(1, 4);
+ const title = clip((/<title>([^<]*)<\/title>/.exec(feed.text) || [])[1], 80);
+ const items = entries.map((e) => {
+ const vid = (/<yt:videoId>([^<]+)<\/yt:videoId>/.exec(e) || [])[1];
+ return { text: clip((/<title>([^<]*)<\/title>/.exec(e) || [])[1], 140), url: vid ? `https://www.youtube.com/watch?v=${vid}` : null, at: (/<published>([^<]+)<\/published>/.exec(e) || [])[1] || null, image: vid ? `https://i.ytimg.com/vi/${vid}/mqdefault.jpg` : null };
+ });
+ return { title: title || (og && og.title), subtitle: 'Latest videos', image: null, items };
+ },
+ async twitch(l) {
+ const login = String(l.handle || '').toLowerCase();
+ const thumb = `https://static-cdn.jtvnw.net/previews-ttv/live_user_${login}-440x248.jpg`;
+ // Offline channels' preview image redirects to a placeholder: follow it and look where it lands.
+ const r = await egress.fetchBuffer(thumb, { timeoutMs: 5000, maxBytes: 256 * 1024, headers: { 'User-Agent': UA } });
+ const live = r.status === 200 && !/404_preview/i.test(r.url);
+ let og = {};
+ try { og = await openGraph(l.url); } catch { og = {}; }
+ return { title: og.title || login, subtitle: og.subtitle || null, image: og.image || null, live: { live, thumbnail: live ? `${thumb}?t=${Math.floor(Date.now() / 60000)}` : null }, items: [] };
+ },
+ async kick(l) {
+ const c = await getJson(`https://kick.com/api/v2/channels/${encodeURIComponent(String(l.handle || '').toLowerCase())}`);
+ const ls = c.livestream;
+ return {
+ title: (c.user && c.user.username) || l.handle, subtitle: c.followers_count != null ? `${Number(c.followers_count).toLocaleString('en-US')} followers` : null,
+ image: httpsOnly(c.user && c.user.profile_pic),
+ live: { live: !!(ls && ls.is_live), title: ls ? clip(ls.session_title, 140) : null, viewers: ls ? Number(ls.viewer_count || 0) : null, thumbnail: httpsOnly(ls && ls.thumbnail && ls.thumbnail.url) },
+ items: [],
+ };
+ },
+ async x(l) {
+ // No free API: the card links out; the page offers X's own embed on click.
+ return { title: `@${l.handle}`, subtitle: 'Posts on X', image: null, items: [], embed: l.handle ? `/embed/x-timeline?h=${encodeURIComponent(l.handle)}` : null };
+ },
+};
+
+/** A preview for one cleaned link (server/social/links.js cleanLink). Never throws: failures give { error }. */
+async function preview(l) {
+ const key = l.url;
+ const hit = cache.get(key);
+ if (hit && Date.now() - hit.at < hit.ttl) return hit.value;
+ let value;
+ try {
+ const fn = ADAPTERS[l.kind] || (async (x) => openGraph(x.url));
+ const needsHandle = ['bluesky', 'github', 'twitch', 'kick', 'mastodon', 'x'].includes(l.kind);
+ value = needsHandle && !l.handle ? await openGraph(l.url) : await fn(l);
+ value = { kind: l.kind, url: l.url, items: [], ...value };
+ remember(key, value, TTL);
+ } catch (err) {
+ // Platform APIs refuse sometimes: fall back to the page's own metadata, else a bare link.
+ try { value = { kind: l.kind, url: l.url, items: [], ...(await openGraph(l.url)) }; remember(key, value, TTL); } catch {
+ value = { kind: l.kind, url: l.url, items: [], error: err instanceof egress.EgressDenied ? 'not allowed' : 'unavailable' };
+ remember(key, value, ERROR_TTL);
+ }
+ }
+ return value;
+}
+
+function remember(key, value, ttl) {
+ if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
+ cache.set(key, { at: Date.now(), ttl, value });
+}
+
+module.exports = { preview, openGraph, ADAPTERS, _cache: cache };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * /api/social — channel social links (server/social/links.js) and their rich previews (server/social/preview.js).
+ *
+ * GET /api/social/catalog the platforms a link can be (name, icon, color, handle input)
+ * GET /api/social/preview/:username/:index the preview of one link a channel saved or is connected to
+ *
+ * Previews fetch only links a channel shows (addressed by channel and position), never a URL from the query, so
+ * this cannot be used to make the server fetch arbitrary addresses. Answers are cached on the server and for
+ * five minutes in browsers and at the edge.
+ */
+const express = require('express');
+const db = require('../db/database');
+const { channelSocialLinks, catalog } = require('./links');
+const { preview } = require('./preview');
+
+const router = express.Router();
+
+router.get('/catalog', (req, res) => {
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.json({ platforms: catalog() });
+});
+
+router.get('/preview/:username/:index', async (req, res) => {
+ try {
+ const channel = db.getChannelByUsername(String(req.params.username || ''));
+ if (!channel) return res.status(404).json({ error: 'Channel not found' });
+ const i = Number.parseInt(req.params.index, 10);
+ const { links } = channelSocialLinks(channel, db);
+ const link = Number.isInteger(i) && i >= 0 ? links[i] : null;
+ if (!link) return res.status(404).json({ error: 'No such link' });
+ if (link.preview === false) return res.json({ kind: link.kind, url: link.url, items: [], disabled: true });
+ const p = await preview(link);
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json(p);
+ } catch (err) {
+ console.error('[Social] preview error:', err.message);
+ res.status(500).json({ error: 'Preview failed' });
+ }
+});
+
+/**
+ * X's own timeline embed, alone in a page of ours so X's script never runs on the channel page: the channel page
+ * frames this only after the viewer asks for it. Its CSP allows X's widget script and frames and nothing else.
+ */
+function xTimelinePage(req, res) {
+ const h = String(req.query.h || '');
+ if (!/^[A-Za-z0-9_]{1,15}$/.test(h)) return res.status(400).type('text/plain').send('bad handle');
+ res.set('Content-Security-Policy', "default-src 'none'; script-src https://platform.twitter.com; frame-src https://platform.twitter.com https://syndication.twitter.com; img-src https: data:; style-src 'unsafe-inline' https://platform.twitter.com; frame-ancestors 'self'");
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.type('html').send(`<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1">
+<style>html,body{margin:0;background:transparent;color:#e7e9ea;font:14px system-ui,sans-serif}a{color:#1d9bf0}</style></head>
+<body><a class="twitter-timeline" data-theme="dark" data-chrome="noheader nofooter noborders transparent" data-height="520" href="https://twitter.com/${h}">Posts by @${h}</a>
+<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></body></html>`);
+}
+
+module.exports = { router, xTimelinePage };
@@ -174,6 +174,13 @@ function cleanTags(tags) {
@@ -174,6 +174,13 @@ function cleanTags(tags) {
return cleaned;
return cleaned;
}
}
+/** The social links an About editor sees after saving (server/social/links.js), for the response. */
+function aboutSocials(channel) {
+ if (!channel) return { social_links: [] };
+ const s = require('../social/links').channelSocialLinks(channel, db, { owner: true });
+ return { social_links: s.links, social_links_editor: { connected: s.connected, restreams_without_link: s.restreams_without_link, hidden_auto: s.hidden_auto } };
+}
+
function cleanPanels(panels) {
function cleanPanels(panels) {
if (panels === undefined) return undefined;
if (panels === undefined) return undefined;
if (typeof panels === 'string') {
if (typeof panels === 'string') {
@@ -478,6 +485,13 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -478,6 +485,13 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
const viewerIsChannelMod = !!(req.user && db.isChannelModerator(req.user.id, channel.id));
const viewerIsChannelMod = !!(req.user && db.isChannelModerator(req.user.id, channel.id));
publicChannel.mods_can_edit_about = modsCanEditAbout;
publicChannel.mods_can_edit_about = modsCanEditAbout;
publicChannel.viewer_can_edit_about = !!(isOwner || (modsCanEditAbout && viewerIsChannelMod));
publicChannel.viewer_can_edit_about = !!(isOwner || (modsCanEditAbout && viewerIsChannelMod));
+ // Social links (server/social/links.js): saved links plus connected restream platforms. Editors also get
+ // what the editor needs (connected platforms, restreams with no public link yet, hidden ones).
+ try {
+ const socials = require('../social/links').channelSocialLinks(channel, db, { owner: publicChannel.viewer_can_edit_about });
+ publicChannel.social_links = socials.links;
+ if (publicChannel.viewer_can_edit_about) publicChannel.social_links_editor = { connected: socials.connected, restreams_without_link: socials.restreams_without_link, hidden_auto: socials.hidden_auto };
+ } catch { publicChannel.social_links = []; }
// Streamer AI overview for the top of the About tab (unless the streamer hid it).
// Streamer AI overview for the top of the About tab (unless the streamer hid it).
// `hide_ai_overview` rides along on the channel row spread above.
// `hide_ai_overview` rides along on the channel row spread above.
try {
try {
@@ -829,6 +843,12 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
@@ -829,6 +843,12 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
if (panels === null) return res.status(400).json({ error: 'Invalid panels' });
if (panels === null) return res.status(400).json({ error: 'Invalid panels' });
if (panels !== undefined) db.updateChannel(channel.user_id, { panels });
if (panels !== undefined) db.updateChannel(channel.user_id, { panels });
}
}
+ // Social links: { links: [{ kind, url | handle, label, preview }], hidden_auto: ['twitch', …] }.
+ if (hasOwn(req.body, 'social_links')) {
+ const socials = require('../social/links').cleanSocialLinks(req.body.social_links);
+ if (socials === null) return res.status(400).json({ error: 'Invalid social links' });
+ db.updateChannel(channel.user_id, { social_links: socials });
+ }
// Show/hide the AI overview at the top of the About tab.
// Show/hide the AI overview at the top of the About tab.
if (hasOwn(req.body, 'hide_ai_overview')) {
if (hasOwn(req.body, 'hide_ai_overview')) {
db.updateChannel(channel.user_id, { hide_ai_overview: req.body.hide_ai_overview ? 1 : 0 });
db.updateChannel(channel.user_id, { hide_ai_overview: req.body.hide_ai_overview ? 1 : 0 });
@@ -845,7 +865,7 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
@@ -845,7 +865,7 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
// handed every mod the streamer's broadcast key — enough to publish to their channel. The
// handed every mod the streamer's broadcast key — enough to publish to their channel. The
// rest of this file already redacts it the same way before sending a channel or stream.
// rest of this file already redacts it the same way before sending a channel or stream.
if (updated) { delete updated.stream_key; delete updated.managed_stream_key; }
if (updated) { delete updated.stream_key; delete updated.managed_stream_key; }
- res.json({ channel: updated, edited_by_mod: !isOwner });
+ res.json({ channel: updated, ...aboutSocials(updated), edited_by_mod: !isOwner });
} catch (err) {
} catch (err) {
console.error('[Channel] about update error:', err.message);
console.error('[Channel] about update error:', err.message);
res.status(500).json({ error: 'Failed to save About section' });
res.status(500).json({ error: 'Failed to save About section' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * Channel social links (server/social/): validation of saved links, the merge with connected restream platforms,
+ * and the preview adapters (Bluesky posts, Twitch live status, OpenGraph fallback, bare link on failure), with the
+ * egress fetch stubbed. Also: previews never take a URL from the request (only a channel's own links).
+ */
+const assert = require('assert');
+const egress = require('../server/net/egress');
+const { cleanSocialLinks, cleanLink, channelSocialLinks } = require('../server/social/links');
+
+let passed = 0;
+async function check(name, fn) { await fn(); passed++; console.log(` ✓ ${name}`); }
+
+(async () => {
+ await check('links: handles and URLs become canonical https links; wrong hosts, http-only tricks and junk are refused', async () => {
+ assert.deepStrictEqual(cleanLink({ kind: 'x', handle: '@OpenVibe' }), { kind: 'x', url: 'https://x.com/OpenVibe', handle: 'OpenVibe', label: '', preview: true });
+ assert.strictEqual(cleanLink({ kind: 'twitch', url: 'https://www.twitch.tv/goosely/' }).handle, 'goosely');
+ assert.strictEqual(cleanLink({ kind: 'custom', url: 'https://twitter.com/someone' }).kind, 'x', 'a pasted URL finds its platform');
+ assert.strictEqual(cleanLink({ kind: 'bluesky', handle: 'alice.bsky.social' }).url, 'https://bsky.app/profile/alice.bsky.social');
+ assert.strictEqual(cleanLink({ kind: 'twitch', url: 'https://evil.example/twitch' }), null, 'a platform link must point at the platform');
+ assert.strictEqual(cleanLink({ kind: 'custom', url: 'javascript:alert(1)' }), null);
+ assert.strictEqual(cleanLink({ kind: 'custom', url: 'https://user:[email protected]/' }), null);
+ assert.strictEqual(cleanLink({ kind: 'website', url: 'example.com/shop' }).url, 'https://example.com/shop');
+ assert.strictEqual(cleanLink({ kind: 'custom', url: 'http://example.com' }).url, 'https://example.com', 'upgraded to https');
+ const stored = JSON.parse(cleanSocialLinks({ links: [{ kind: 'x', handle: 'a' }, { kind: 'x', url: 'https://x.com/a' }, { kind: 'custom', url: 'nope' }], hidden_auto: ['twitch', 'root'] }));
+ assert.strictEqual(stored.links.length, 1, 'duplicates and invalid links dropped');
+ assert.deepStrictEqual(stored.hidden_auto, ['twitch']);
+ assert.strictEqual(cleanSocialLinks('not a list'), null);
+ });
+
+ await check('merge: saved links first, then connected Twitch/YouTube/Kick not already listed and not hidden; editors see what is missing', async () => {
+ const db = {
+ all(sql) {
+ if (/platform_connections/.test(sql)) return [{ platform: 'twitch', platform_username: 'goosely', channel_url: 'https://www.twitch.tv/goosely' }, { platform: 'kick', platform_username: 'goosely', channel_url: null }];
+ if (/restream_destinations/.test(sql)) return [{ platform: 'twitch' }, { platform: 'youtube' }];
+ return [];
+ },
+ };
+ const channel = { user_id: 1, social_links: cleanSocialLinks({ links: [{ kind: 'x', handle: 'goosely' }], hidden_auto: ['kick'] }) };
+ const viewer = channelSocialLinks(channel, db);
+ assert.deepStrictEqual(viewer.links.map((l) => [l.kind, !!l.auto]), [['x', false], ['twitch', true]]);
+ assert.ok(viewer.links[0].icon && viewer.links[0].color);
+ assert.strictEqual(viewer.connected, undefined, 'viewers get no editor data');
+ const owner = channelSocialLinks(channel, db, { owner: true });
+ assert.deepStrictEqual(owner.restreams_without_link, ['youtube']);
+ assert.deepStrictEqual(owner.connected.map((c) => [c.kind, c.hidden]), [['twitch', false], ['kick', true]]);
+ });
+
+ const { preview, _cache } = require('../server/social/preview');
+ const real = { fetchText: egress.fetchText, fetchBuffer: egress.fetchBuffer };
+ const calls = [];
+ egress.fetchText = async (url) => {
+ calls.push(url);
+ if (url.includes('getProfile')) return { status: 200, url, headers: {}, text: JSON.stringify({ handle: 'alice.bsky.social', displayName: 'Alice', followersCount: 1200, postsCount: 30, avatar: 'https://cdn.bsky.app/a.jpg' }) };
+ if (url.includes('getAuthorFeed')) return { status: 200, url, headers: {}, text: JSON.stringify({ feed: [{ post: { uri: 'at://did/app.bsky.feed.post/3k1', indexedAt: '2026-09-28T10:00:00Z', record: { text: 'Going live at 8 <3' } } }] }) };
+ if (url.includes('twitch.tv')) return { status: 200, url, headers: {}, text: '<meta property="og:title" content="goosely - Twitch"><meta property="og:image" content="https://static.twitch.tv/x.png">' };
+ if (url.includes('shop.example')) return { status: 200, url, headers: {}, text: '<title>My shop</title><meta name="description" content="Stickers & shirts">' };
+ return { status: 503, url, headers: {}, text: '' };
+ };
+ egress.fetchBuffer = async (url) => ({ status: 200, url: url.includes('goosely') ? url : 'https://static-cdn.jtvnw.net/ttv-static/404_preview-440x248.jpg', headers: {}, body: Buffer.alloc(0) });
+ try {
+ await check('previews: Bluesky profile and latest posts; Twitch live from the preview image; OpenGraph for a custom link', async () => {
+ const b = await preview(cleanLink({ kind: 'bluesky', handle: 'alice.bsky.social' }));
+ assert.strictEqual(b.title, 'Alice');
+ assert.match(b.subtitle, /1,200 followers/);
+ assert.deepStrictEqual(b.items.map((i) => [i.text, i.url]), [['Going live at 8 <3', 'https://bsky.app/profile/alice.bsky.social/post/3k1']]);
+ const t = await preview(cleanLink({ kind: 'twitch', handle: 'goosely' }));
+ assert.strictEqual(t.live.live, true);
+ const off = await preview(cleanLink({ kind: 'twitch', handle: 'sleepy' }));
+ assert.strictEqual(off.live.live, false, 'the placeholder image means offline');
+ const c = await preview(cleanLink({ kind: 'custom', url: 'https://shop.example/' }));
+ assert.deepStrictEqual([c.title, c.subtitle], ['My shop', 'Stickers & shirts']);
+ });
+ await check('previews: an unavailable platform falls back to its page, else a bare link; answers are cached', async () => {
+ const n = calls.length;
+ const g = await preview(cleanLink({ kind: 'github', handle: 'nobody' }));
+ assert.strictEqual(g.error, 'unavailable');
+ await preview(cleanLink({ kind: 'bluesky', handle: 'alice.bsky.social' }));
+ assert.ok(calls.slice(n).every((u) => !u.includes('bsky')), 'the Bluesky preview came from the cache');
+ assert.ok(_cache.size >= 4);
+ });
+ } finally { Object.assign(egress, real); }
+
+ await check('the preview route addresses a channel\'s own link by position, never a URL from the request', async () => {
+ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'social', 'routes.js'), 'utf8');
+ assert.ok(/\/preview\/:username\/:index/.test(src));
+ assert.ok(!/req\.query\.url/.test(src));
+ });
+ console.log(`social-links: ${passed} checks passed`);
+})().catch((err) => { console.error(err); process.exit(1); });