openvibers/openvibe.live Commits

Channel social links, and the offline channel page reorganized for wide screens.

OP OpenVibers committed 29 Sept 2026 · 14f16182e7 · 17 files, +795 −39

Social links (server/social/): a channel's restream destinations (Twitch, Kick, YouTube and the rest in platform_connections) show as links on their own, and streamers add X, Bluesky, Mastodon, Instagram, TikTok, Threads, GitHub, Discord, websites and custom links in About's editor (up to 16, ordered, each with an optional label and preview; any auto link can be hidden). Links are validated per platform (https, the platform's own hosts, handles parsed out). The offline screen shows them as brand-coloured pills; About shows rich cards that fill in when they scroll into view: live status with thumbnail and viewers (Twitch, Kick), latest posts (Bluesky, Mastodon), latest videos (YouTube RSS), recent repositories (GitHub), OpenGraph for everything else, and X's own timeline on click in an isolated frame (/embed/x-timeline, its own CSP). Previews are fetched on the server through server/net/egress.js and cached ten minutes (errors one minute); the endpoint takes a channel and a link index, never a URL, so it is not an open fetch proxy.

Offline page: "While X is away" is a section of the page instead of a boxed card (heading as a toggle, main and side columns, no empty grid areas); on screens 1181px and wider the tabs and their panels move beside the chat column so there is no dead space under the chat or beside the tabs; the header is more compact. The AI overview in About blends in: transparent, less padding. Home: the restream tour loads when it nears the viewport (feature homeTour), so the home page's eager JS stays in budget. openvibe-shared v1.29.1 (themes repaint the canvas, readable text on bright accents, compact navbar brand).

npm test 150/151, 1 skipped.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a401f046..964aa2da 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 1542b447..3fdde17e 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/css/context-cards.css b/public/css/context-cards.css
iindex af293ee4..8cde6a1a 100644
---- a/public/css/context-cards.css
++++ b/public/css/context-cards.css
@@@ -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; }
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 75af0ae5..907caf4a 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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; }
++}
ddiff --git a/public/features.json b/public/features.json
iindex 8e71b38b..d23cd188 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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"
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 877fa196..57971631 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex cf73a615..a6803457 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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 header
document.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 panels
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 _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 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>`;
++ 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();
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 12b27637..bd9bf0e8 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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');
ddiff --git a/public/js/channel-socials.js b/public/js/channel-socials.js
nnew file mode 100644
iindex 00000000..7554d06d
---- /dev/null
++++ b/public/js/channel-socials.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
++})();
ddiff --git a/public/js/context-cards.js b/public/js/context-cards.js
iindex d5f2aab7..0f119d13 100644
---- a/public/js/context-cards.js
++++ b/public/js/context-cards.js
@@@ -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>`;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 534ac52b..6950d049 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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);
}
ddiff --git a/server/index.js b/server/index.js
iindex 6d30d96e..8da5694a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
ddiff --git a/server/social/links.js b/server/social/links.js
nnew file mode 100644
iindex 00000000..658c2724
---- /dev/null
++++ b/server/social/links.js
@@@ -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 };
ddiff --git a/server/social/preview.js b/server/social/preview.js
nnew file mode 100644
iindex 00000000..4e285985
---- /dev/null
++++ b/server/social/preview.js
@@@ -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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;|&#x27;/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 };
ddiff --git a/server/social/routes.js b/server/social/routes.js
nnew file mode 100644
iindex 00000000..d53cea14
---- /dev/null
++++ b/server/social/routes.js
@@@ -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 };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 72fb1927..b3396a13 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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' });
ddiff --git a/test/social-links.test.js b/test/social-links.test.js
nnew file mode 100644
iindex 00000000..b71de310
---- /dev/null
++++ b/test/social-links.test.js
@@@ -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 &lt;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 &amp; 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); });