openvibers/openvibe.chat Commits
Chat V2: the app shell (rail, side column, members, phone panels), Explore and the Start sheet (#432)
Changes
nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- A person's saved presentation choice for each DM conversation.++CREATE TABLE IF NOT EXISTS dm_view_preferences (++ user_id bigint NOT NULL,++ conversation_id bigint NOT NULL REFERENCES dm_conversations(id) ON DELETE CASCADE,++ view text COLLATE "C" NOT NULL CHECK (view IN ('bubbles', 'room')),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (user_id, conversation_id)++);nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- The app shell (the plan, 2026-11-09, stage V2): a channel's category in the side column, and the people you++-- talk to (Friends and Requests on Home).++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS category text COLLATE "C";++++CREATE TABLE IF NOT EXISTS dm_contacts (++ user_id bigint NOT NULL,++ contact_id bigint NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'friend' CHECK (status IN ('friend', 'outgoing', 'incoming')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (user_id, contact_id)++);++++CREATE INDEX IF NOT EXISTS idx_dm_contacts_user ON dm_contacts(user_id);nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- The default view for each kind of conversation (bubbles or a chat room): the plan, 2026-11-09, stage V1.++CREATE TABLE IF NOT EXISTS dm_view_defaults (++ user_id bigint NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('one_to_one', 'ai', 'group', 'channel')),++ view text COLLATE "C" NOT NULL CHECK (view IN ('bubbles', 'room')),++ PRIMARY KEY (user_id, kind)++);nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Explore (the plan, 2026-11-09, stage V3): a server's category, its picture, and the template it started from.++ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS category text COLLATE "C";++ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS picture_url text;++ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS template text COLLATE "C";nnew file mode 100644@@@ -0,0 +1,204 @@++/* openvibe.chat — the app shell (the plan, 2026-11-09, stage V2): the rail, the side column, the members column and++ the phone panels. Discord's four columns, our data; the look is Opus's (openvibe.design), this is the structure.++ One icon button size: 40 px on a computer, 44 px on a phone. One primary style. Labels beside icons where there++ is room. */++++.oc-app { display: grid; grid-template-columns: 72px 240px minmax(0, 1fr) 0; height: calc(100dvh - var(--ovnav-h, 56px)); min-height: 420px; background: var(--oc-panel); overflow: hidden; }++.oc-app.is-members { grid-template-columns: 72px 240px minmax(0, 1fr) 240px; }++.oc-app > .oc-col-members { overflow: hidden; border-left: 0; }++.oc-app.is-members > .oc-col-members { overflow-y: auto; border-left: 1px solid var(--oc-line); }++++/* ── The rail (72 px): Home, Vibe, your servers, then Add and Explore ── */++.oc-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }++.oc-rail::-webkit-scrollbar { display: none; }++.oc-rail-icon { position: relative; display: grid; place-items: center; width: 48px; height: 48px; flex: 0 0 auto; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 800; font-size: .95rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }++.oc-rail-icon:hover, .oc-rail-icon:focus-visible { border-radius: 16px; }++.oc-rail-icon.is-active { border-radius: 16px; background: var(--oc-panel-2); }++.oc-rail .oc-rail-icon.is-active::before { content: ''; position: absolute; left: -12px; top: 10px; bottom: 10px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }++.oc-rail-home, .oc-rail-vibe { background: var(--oc-panel-2); }++.oc-rail-home .oc-i, .oc-rail-vibe .oc-i { width: 22px; height: 22px; }++.oc-rail-divider { width: 32px; height: 2px; flex: 0 0 2px; border-radius: 2px; background: var(--oc-line); }++.oc-rail-dot { position: absolute; right: -6px; bottom: -4px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; }++.oc-rail-add { background: var(--oc-panel-2); color: var(--oc-ok); font-size: 1.5rem; font-weight: 500; }++.oc-rail-explore { background: var(--oc-panel-2); color: var(--oc-accent); }++.oc-rail-add:hover, .oc-rail-explore:hover { background: var(--oc-panel); }++.oc-rail-explore .oc-i { width: 20px; height: 20px; }++++/* On a phone the side column opens from the rail: this button. */++.oc-rail-menu { display: none; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border: 0; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); cursor: pointer; }++.oc-rail-menu .oc-i { width: 20px; height: 20px; }++++/* ── The side column (240 px) ── */++.oc-side { display: flex; flex-direction: column; min-width: 0; padding: 12px 8px 0; overflow-y: auto; border-right: 1px solid var(--oc-line); background: var(--oc-bg); }++.oc-side-top { padding: 0 0 10px; }++.oc-side-find { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 10px; border-radius: 10px; background: var(--oc-panel-2); color: var(--oc-text); text-decoration: none; font-size: .92rem; }++.oc-side-find:hover { background: var(--oc-panel); }++.oc-side-find .oc-i { width: 18px; height: 18px; color: var(--oc-muted); }++.oc-side-sec { margin-bottom: 12px; }++.oc-side-group { display: flex; align-items: center; gap: 6px; margin: 0; padding: 8px 8px 4px; font-size: .72rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--oc-muted); }++.oc-side-group > summary { cursor: pointer; list-style: none; }++.oc-side-group > summary::-webkit-details-marker { display: none; }++.oc-side-cat details > summary::after { content: '▾'; margin-left: auto; font-size: .8rem; }++.oc-side-cat details:not([open]) > summary::after { content: '▸'; }++.oc-side-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }++.oc-side-list .oc-row > a, .oc-side-list .oc-row > span { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 8px; color: var(--oc-text); text-decoration: none; }++.oc-side-list .oc-row > a:hover { background: var(--oc-panel-2); }++.oc-side-list .oc-row-main { min-width: 0; }++.oc-side-list .oc-row-top { display: flex; align-items: center; gap: 6px; min-width: 0; }++.oc-side-list .oc-row-top strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-side-list .oc-row-sub { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .8rem; }++.oc-side-empty { margin: 0; padding: 2px 8px 6px; font-size: .84rem; }++.oc-side-head { padding: 0 6px 8px; }++.oc-side-name { font-size: 1.05rem; line-height: 1.3; margin: 0 0 2px; overflow-wrap: anywhere; }++.oc-side-head .oc-badge { margin-left: 0; }++.oc-side-meta { margin: 2px 0 0; font-size: .82rem; }++.oc-side-desc { margin: 6px 0 0; font-size: .86rem; color: var(--oc-muted); }++.oc-side-more { position: absolute; right: 8px; top: 8px; }++.oc-side-event { display: flex; align-items: center; gap: 8px; min-height: 40px; margin: 0 0 6px; padding: 6px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; font-size: .9rem; }++.oc-side-event:hover { background: var(--oc-panel-2); color: var(--oc-text); }++.oc-side-event .oc-i { width: 18px; height: 18px; }++++/* Channels: text (#) and voice (a speaker), with the people in each voice channel. */++.oc-chan { display: flex; align-items: center; gap: 7px; min-height: 36px; padding: 4px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; }++.oc-chan:hover, .oc-chan:focus-visible { background: var(--oc-panel-2); color: var(--oc-text); }++.oc-chan.is-active { background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-text); }++.oc-chan.is-unread { color: var(--oc-text); font-weight: 700; }++.oc-chan-hash { width: 16px; text-align: center; font-weight: 700; opacity: .75; }++.oc-chan-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-chan .oc-i { width: 16px; height: 16px; }++.oc-voice-people { list-style: none; margin: 0 0 4px; padding: 0 8px 0 32px; }++.oc-voice-people li { font-size: .8rem; color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++++/* The you-bar at the foot of the side column. */++.oc-you { position: sticky; bottom: 0; display: flex; align-items: center; gap: 8px; margin-top: auto; padding: 8px; border-top: 1px solid var(--oc-line); background: var(--oc-bg); }++.oc-you-id { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--oc-text); text-decoration: none; }++.oc-you-names { display: grid; min-width: 0; }++.oc-you-names strong { font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-you-names .oc-muted { font-size: .74rem; }++.oc-you-tools { display: flex; gap: 2px; margin-left: auto; }++++/* ── The members column ── */++.oc-members-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 0; }++.oc-members-sec { margin-bottom: 10px; }++.oc-member-row strong { color: color-mix(in oklab, var(--nc, var(--oc-text)) 50%, var(--oc-text)); }++++/* The shell fills the page: no gutter, no card. */++.oc-main--shell { max-width: none; padding: 0; margin: 0; }++++/* ── The middle column ── */++.oc-col-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: color-mix(in srgb, var(--oc-panel-2) 60%, var(--oc-panel)); }++.oc-chan-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 10px 16px; border-bottom: 1px solid var(--oc-line); }++.oc-chan-title { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 1.04rem; }++.oc-chan-tools { display: flex; align-items: center; gap: 4px; margin-left: auto; }++.oc-col-main > .oc-notice, .oc-col-main > .oc-ai-here { margin: 10px 18px 0; }++.oc-col-main > .oc-call { margin: 12px 18px 0; }++.oc-col-main > .oc-compose, .oc-col-main > .oc-signin, .oc-col-main > form, .oc-col-main > .oc-dock { margin: 0; padding: 12px 18px 16px; border-top: 1px solid var(--oc-line); }++/* A conversation's head (a DM) sits like a channel's: inside the gutter, a line under it. */++.oc-col-main > .oc-page-head { margin: 0; padding: 10px 18px; border-bottom: 1px solid var(--oc-line); }++.oc-side-join { margin: 0 0 8px; }++.oc-side-join button, .oc-side-join .oc-button { width: 100%; text-align: center; }++++/* One icon button size: 40 px on a computer, 44 px on a phone. */++.oc-icon-btn { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: transparent; color: var(--oc-muted); text-decoration: none; cursor: pointer; border: 0; }++.oc-icon-btn:hover { background: var(--oc-panel-2); color: var(--oc-text); }++.oc-icon-btn .oc-i { width: 20px; height: 20px; }++++/* A page in the middle column (Explore, the Messages list): it scrolls on its own, inside the page gutter. */++.oc-page { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 18px 24px 28px; }++.oc-page > .oc-page-head:first-child { margin-top: 0; }++.oc-page > .oc-list { margin-left: -8px; margin-right: -8px; }++++/* ── The phone: the columns are panels you swipe between ── */++@media (max-width: 760px) {++ /* The shell takes what the navbar and the app bar leave, so a channel fills the screen and its composer sits at the++ bottom, above the app bar (the body keeps --ovnav-appbar-h of padding for it). */++ .oc-app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);++ height: calc(100dvh - var(--ovnav-h, 56px) - var(--ovnav-appbar-h, 0px)); min-height: 360px; }++ .oc-page { padding: 14px 16px 20px; }++ .oc-app.is-members { grid-template-columns: minmax(0, 1fr); }++ .oc-rail-menu { display: grid; }++ .oc-app > .oc-rail { flex-direction: row; justify-content: flex-start; gap: 10px; padding: 8px 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); min-width: 0; }++ .oc-app > .oc-side, .oc-app > .oc-col-main, .oc-app > .oc-col-members { min-width: 0; }++ .oc-app .oc-rail .oc-rail-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -8px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }++ .oc-app > .oc-side, .oc-app > .oc-col-members { display: none; }++ .oc-app[data-oc-panel="side"] > .oc-side { display: flex; border-right: 0; }++ .oc-app[data-oc-panel="members"] > .oc-col-members { display: block; border-left: 0; }++ .oc-app[data-oc-panel="side"] > .oc-col-main, .oc-app[data-oc-panel="members"] > .oc-col-main { display: none; }++ .oc-icon-btn { width: 44px; height: 44px; }++ .oc-col-main > .oc-page-head, .oc-col-main > .oc-chan-head { padding-left: 12px; padding-right: 12px; }++ .oc-col-main > .oc-compose, .oc-col-main > .oc-dock, .oc-col-main > .oc-signin { padding-left: 12px; padding-right: 12px; }++ .oc-chan { min-height: 44px; }++ .oc-side-list .oc-row > a, .oc-side-list .oc-row > span { min-height: 44px; }++}++++/* A friend row is a button that opens the conversation; the form posts the username. */++.oc-side-list .oc-row form { width: 100%; }++.oc-side-person { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; padding: 4px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--oc-text); text-align: left; cursor: pointer; font: inherit; }++.oc-side-person:hover { background: var(--oc-panel-2); }++.oc-side-add { display: flex; gap: 6px; margin: 6px 0 0; }++.oc-side-add input { flex: 1; min-width: 0; min-height: 40px; }++.oc-side-add button { min-height: 40px; }++++/* ── Explore: the cards, the chips and the Start sheet ── */++.oc-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 14px; }++.oc-chip { display: inline-flex; align-items: center; min-height: 36px; padding: 6px 14px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); text-decoration: none; font-size: .9rem; }++.oc-chip:hover { border-color: var(--oc-accent); color: var(--oc-text); }++.oc-chip.is-active { background: var(--oc-accent); border-color: var(--oc-accent); color: var(--oc-on-accent); font-weight: 700; }++.oc-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }++.oc-card { display: flex; flex-direction: column; gap: 6px; padding: 0 0 14px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); overflow: hidden; }++.oc-card-banner { height: 64px; background: linear-gradient(120deg, hsl(var(--oc-hue, 215) 52% 34%), hsl(var(--oc-hue, 215) 52% 22%)); }++.oc-card .oc-srv-icon { margin: -24px 0 0 14px; border: 3px solid var(--oc-panel); }++.oc-card h3 { margin: 4px 14px 0; font-size: 1.02rem; }++.oc-card-line { margin: 0 14px; font-size: .88rem; color: var(--oc-muted); }++.oc-card-meta { margin: 0 14px; font-size: .8rem; }++.oc-card form, .oc-card > .oc-button { margin: 6px 14px 0; }++.oc-card form button { width: 100%; }++.oc-card > .oc-button { text-align: center; }++.oc-server-card--new { justify-content: center; border-style: dashed; }++++/* The sheet: a template, a name and a picture, public or private. */++.oc-sheet { margin: 18px 0 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }++.oc-sheet > summary { display: block; padding: 14px 18px; cursor: pointer; font-weight: 700; list-style: none; }++.oc-sheet > summary::-webkit-details-marker { display: none; }++.oc-sheet-form { padding: 0 18px 18px; }++.oc-sheet-step { border: 0; padding: 0; margin: 0 0 18px; }++.oc-sheet-step legend { padding: 0 0 8px; font-weight: 700; }++.oc-templates { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }++.oc-template { display: block; }++.oc-template input { position: absolute; opacity: 0; width: 1px; height: 1px; }++.oc-template-body { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--oc-line); border-radius: 12px; cursor: pointer; }++.oc-template input:checked + .oc-template-body { border-color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 14%, transparent); }++.oc-template input:focus-visible + .oc-template-body { outline: 2px solid var(--oc-accent); outline-offset: 2px; }++.oc-template-channels { font-size: .78rem; color: var(--oc-muted); }++.oc-seg { display: inline-flex; border: 1px solid var(--oc-line); border-radius: 10px; overflow: hidden; }++.oc-seg label { position: relative; }++.oc-seg input { position: absolute; opacity: 0; width: 1px; height: 1px; }++.oc-seg span { display: block; min-width: 96px; min-height: 40px; padding: 8px 16px; text-align: center; cursor: pointer; color: var(--oc-muted); }++.oc-seg input:checked + span { background: var(--oc-accent); color: var(--oc-on-accent); font-weight: 700; }++.oc-seg input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: -2px; }++++/* The invite link at the end of the Start sheet. */++.oc-invite-new { margin: 0 18px 14px; padding: 12px 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }++.oc-invite-new h2 { margin: 0 0 4px; font-size: 1rem; }++.oc-invite-url { display: block; overflow-wrap: anywhere; padding: 8px 10px; border-radius: 8px; background: var(--oc-bg); }++.oc-invite-tools { display: flex; gap: 8px; margin: 10px 0 0; }++++/* A server card is not a message attachment card (chat.css .oc-card): no 380 px cap, no feed margins; the banner bleeds. */++.oc-server-card { max-width: none; margin: 0; background: var(--oc-panel); }++.oc-server-card > .oc-card-banner { align-self: stretch; margin: 0; }++++/* With the shell script running, links open the Start sheet: its own bar is not needed while it is closed. */++.oc-js .oc-sheet:not([open]) { display: none; }++.oc-js .oc-sheet[open] > summary { display: none; }++++/* The Start sheet's fields: the label above, two columns on a computer, one on a phone. */++.oc-sheet-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; max-width: 640px; }++.oc-field { display: grid; gap: 6px; min-width: 0; }++.oc-field > span { font-weight: 600; font-size: .9rem; }++.oc-field--wide { grid-column: 1 / -1; }++.oc-field input:not([type=file]), .oc-field select { width: 100%; min-height: 44px; box-sizing: border-box; }++.oc-field small { font-size: .8rem; }++@media (max-width: 760px) { .oc-sheet-fields { grid-template-columns: minmax(0, 1fr); } }++++/* Absolutely placed inputs (the template radios) stay inside the page that scrolls, not the document. */++.oc-page, .oc-template { position: relative; }nnew file mode 100644@@@ -0,0 +1,165 @@++/* openvibe.chat — the app shell on the page (the plan, 2026-11-09, stage V2): the phone's panels and the swipe++ * between them, the members toggle, and the shared user popout on every name.++ *++ * The page reads and works without this file: the shell is server-rendered, and the panels are links to the same++ * places. This adds the gestures and the popout. */++(function () {++ 'use strict';++++ var app = document.querySelector('[data-oc-app]');++ if (!app) return;++++ // ── The Start sheet opens from any link to it (the rail's +, the side column, the Start card), so it needs no bar++ // of its own while closed (chat-shell.css hides that bar once this runs; without this file the bar stays) ──++ document.documentElement.classList.add('oc-js');++ function openSheet() {++ var sheet = document.getElementById('oc-new-server');++ if (!sheet) return false;++ sheet.open = true;++ var page = sheet.closest('.oc-page');++ if (page) page.scrollTo({ top: Math.max(0, sheet.offsetTop - page.offsetTop - 12) });++ var first = sheet.querySelector('input:not([type=hidden]):not([type=radio]), input[type=radio]:checked, select, textarea');++ if (first) first.focus({ preventScroll: true });++ return true;++ }++ if (location.hash === '#oc-new-server') openSheet();++ window.addEventListener('hashchange', function () { if (location.hash === '#oc-new-server') openSheet(); });++ document.addEventListener('click', function (e) {++ var a = e.target && e.target.closest && e.target.closest('a[href$="#oc-new-server"]');++ if (!a || !document.getElementById('oc-new-server')) return;++ e.preventDefault();++ if (location.hash !== '#oc-new-server') history.replaceState(null, '', '#oc-new-server');++ openSheet();++ });++++ // ── The panels: the side column on the left, the people on the right, the conversation between them ──++ function panel(name) {++ app.setAttribute('data-oc-panel', name);++ app.classList.toggle('is-members', name === 'members');++ document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {++ b.setAttribute('aria-pressed', b.getAttribute('data-oc-panel-toggle') === name ? 'true' : 'false');++ });++ }++ document.addEventListener('click', function (e) {++ var b = e.target.closest && e.target.closest('[data-oc-panel]');++ if (!b || b === app) return;++ var to = b.getAttribute('data-oc-panel');++ if (!to) return;++ e.preventDefault();++ panel(app.getAttribute('data-oc-panel') === to ? 'main' : to);++ });++ document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {++ b.addEventListener('click', function () { panel(b.getAttribute('data-oc-panel-toggle')); });++ });++++ // ── The phone: swipe right from the conversation for the side column, left for the people ──++ if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches && window.PointerEvent) {++ var startX = 0, startY = 0, tracking = false;++ app.addEventListener('pointerdown', function (e) {++ if (e.pointerType !== 'touch') return;++ startX = e.clientX; startY = e.clientY; tracking = true;++ }, { passive: true });++ app.addEventListener('pointerup', function (e) {++ if (!tracking) return;++ tracking = false;++ var dx = e.clientX - startX, dy = e.clientY - startY;++ if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx)) return;++ var here = app.getAttribute('data-oc-panel');++ if (dx > 0 && here === 'main') panel('side');++ else if (dx > 0 && here === 'members') panel('main');++ else if (dx < 0 && here === 'main') panel('members');++ else if (dx < 0 && here === 'side') panel('main');++ }, { passive: true });++ }++++ // ── Copy a link (the invite at the end of the Start sheet) ──++ document.addEventListener('click', function (e) {++ var b = e.target.closest && e.target.closest('[data-oc-copy]');++ if (!b) return;++ e.preventDefault();++ var text = b.getAttribute('data-oc-copy') || '';++ var done = function () { var old = b.textContent; b.textContent = 'Copied'; setTimeout(function () { b.textContent = old; }, 1400); };++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { });++ else {++ var input = document.createElement('textarea');++ input.value = text; document.body.appendChild(input); input.select();++ try { document.execCommand('copy'); done(); } catch (err) { /* nothing to copy with */ }++ input.remove();++ }++ });++++ // ── The shared user popout (openvibe-shared/user-card.js): every name and avatar opens it ──++ function token() {++ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');++ return m ? decodeURIComponent(m[1]) : null;++ }++ var people = Array.prototype.slice.call(document.querySelectorAll('[data-oc-user]'));++ // A message's author opens the card too: the message carries data-user, live messages included (one listener).++ var hasMessages = !!document.querySelector('.oc-msg[data-user]');++ if (!people.length && !hasMessages) return;++ var tok = token();++ if (!tok) return; // signed out: no card to open++ document.addEventListener('click', function (e) {++ var name = e.target && e.target.closest && e.target.closest('.oc-msg[data-user] .oc-name');++ if (!name || !window.OpenVibeUserCard || e.ctrlKey || e.metaKey || e.shiftKey) return; // a modifier opens the profile page++ var id = name.closest('.oc-msg').getAttribute('data-user');++ if (!id || id === '0') return;++ e.preventDefault();++ var box = name.getBoundingClientRect();++ window.OpenVibeUserCard.show(id, box.left + 8, box.bottom + 8);++ });++++ function openCard(el) {++ if (window.OpenVibeUserCard && window.OpenVibeUserCard.show) {++ var box = el.getBoundingClientRect();++ window.OpenVibeUserCard.show(el.getAttribute('data-oc-user'), box.left + 8, box.bottom + 8);++ return;++ }++ if (window.OpenVibeUserMenu && window.OpenVibeUserMenu.show) {++ var box2 = el.getBoundingClientRect();++ window.OpenVibeUserMenu.show(el.getAttribute('data-oc-user'), box2.left + 8, box2.bottom + 8);++ }++ }++ function wire() {++ if (!window.OpenVibeUserCard) return;++ window.OpenVibeUserCard.init({ apiBase: 'https://openvibe.network', token: tok });++ var phone = window.matchMedia && window.matchMedia('(max-width: 760px)').matches;++ people.forEach(function (el) {++ var id = el.getAttribute('data-oc-user');++ if (!id) return;++ if (phone) {++ el.addEventListener('click', function (e) { e.preventDefault(); openCard(el); });++ } else {++ window.OpenVibeUserCard.attach(el, id);++ el.addEventListener('click', function (e) {++ if (e.ctrlKey || e.metaKey) { e.preventDefault(); openCard(el); }++ });++ }++ });++ // Chat's own actions in the shared card: Message, Call, Add friend, Mute, Block, Report.++ if (window.OpenVibeUserMenu && window.OpenVibeUserMenu.register) {++ window.OpenVibeUserMenu.register({++ id: 'chat-message', label: 'Message', icon: '💬',++ onSelect: function (user) { window.location.href = '/messages/new?to=' + encodeURIComponent(user.username || ''); },++ });++ window.OpenVibeUserMenu.register({++ id: 'chat-friend', label: 'Add friend', icon: '+',++ onSelect: function (user) {++ fetch('/api/dm/contacts', {++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ username: user.username }),++ }).then(function (r) { if (r.ok) window.location.reload(); });++ },++ });++ }++ }++ if (window.OpenVibeUserCard) wire();++ else {++ var s = document.createElement('script');++ s.src = '/shared/user-card.js';++ s.async = true;++ s.onload = wire;++ s.onerror = function () { /* the card is optional */ };++ document.head.appendChild(s);++ }++})();@@@ -117,6 +117,37 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-dm-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }.oc-dm-head h1 { margin: 0; }.oc-dm-call button { padding: 7px 14px; }++.oc-page-tools #oc-view-toggle, .oc-page-tools .oc-dm-call .oc-icon-btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; width:auto; min-width:40px; padding:0 8px; white-space:nowrap; }++.oc-view-label, .oc-call-label { font-size:.8rem; }++.oc-av img { width:100%; height:100%; border-radius:inherit; object-fit:cover; display:block; }++.oc-bubbles { display:flex; flex-direction:column; align-items:stretch; gap:5px; padding:16px clamp(12px,3vw,28px) 22px; }++.oc-bubble-row { list-style:none; display:flex; flex-direction:column; align-items:flex-start; max-width:min(78%,680px); margin:5px 0; }++.oc-bubble-row.oc-bubble-mine { align-self:flex-end; align-items:flex-end; }++.oc-bubble-author { color:var(--oc-muted); font-size:.78rem; font-weight:650; margin:0 0 3px 10px; }++.oc-bubble { background:var(--oc-panel-raised,var(--oc-panel)); border-radius:17px 17px 17px 5px; padding:9px 12px; overflow-wrap:anywhere; white-space:pre-wrap; }++.oc-bubble-mine .oc-bubble { background:var(--accent,#5879f6); color:#fff; border-radius:17px 17px 5px 17px; }++.oc-bubble-time { font-size:.7rem; opacity:.65; margin:3px 4px 0; }++.oc-bubbles .oc-empty { align-self:center; }++.oc-list#oc-convs { border:0; border-radius:0; background:transparent; overflow:visible; }++.oc-list#oc-convs .oc-row + .oc-row { border-top:1px solid var(--oc-line); }++.oc-list#oc-convs .oc-row > a { padding:12px 4px; }++.oc-list#oc-convs .oc-av { width:48px; height:48px; }++.oc-new-picker { position:relative; }++.oc-new-picker > summary { list-style:none; display:inline-flex; align-items:center; justify-content:center; gap:7px; width:auto; min-width:44px; height:44px; padding:0 12px; cursor:pointer; border:1px solid var(--oc-line); background:var(--oc-panel); }++.oc-new-picker > summary::-webkit-details-marker { display:none; }++.oc-new-pop { position:absolute; z-index:8; right:0; top:48px; width:min(360px,calc(100vw - 28px)); padding:12px; border:1px solid var(--oc-line); border-radius:12px; background:var(--oc-panel); box-shadow:0 12px 36px #0005; }++.oc-new-pop label { display:block; font-weight:650; margin-bottom:6px; }++.oc-new-pop input { width:100%; }++.oc-new-pop ul { list-style:none; margin:8px 0 0; padding:0; max-height:45vh; overflow:auto; }++.oc-new-pop li + li { border-top:1px solid var(--oc-line); }++.oc-new-pop li button { width:100%; text-align:left; background:transparent; color:var(--oc-text); border-radius:6px; padding:8px; }++.oc-new-pop li button { display:flex; align-items:center; gap:10px; }++.oc-new-pop li small { display:block; color:var(--oc-muted); }++.oc-feed.oc-bubbles { max-height:none; flex:1; min-height:0; margin:0; padding:16px clamp(12px,3vw,28px) 22px; border:0; border-radius:0; background:transparent; }++@media (max-width:560px) {++ .oc-page-tools #oc-view-toggle, .oc-page-tools .oc-dm-call .oc-icon-btn, .oc-new-picker > summary { width:44px; min-width:44px; height:44px; padding:0; }++ .oc-view-label, .oc-call-label, .oc-new-picker > summary span { display:none; }++}/* A call coming in (public/web/ring.js): above everything, bottom right; full width on phones. */.oc-ring { position: fixed; right: 16px; bottom: 16px; z-index: 50; max-width: min(360px, calc(100vw - 32px)); margin: 0; padding: 14px 16px; background: var(--oc-panel); border: 1px solid var(--oc-accent); border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, .45); }.oc-ring-text { margin: 0 0 10px; }@@@ -153,6 +184,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-main--wide { max-width: 1440px; }.oc-srv { display: grid; grid-template-columns: 64px 232px minmax(0, 1fr) 212px; height: min(80vh, 900px); min-height: 480px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; overflow: hidden; }.oc-srv-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }++.oc-srv-divider { width: 30px; height: 1px; flex: 0 0 1px; background: var(--oc-line); }++.oc-srv-home, .oc-srv-vibe { background: var(--oc-panel-2); color: var(--oc-text); }++.oc-srv-home .oc-i, .oc-srv-vibe .oc-i { width: 21px; height: 21px; }++.oc-mobile-bar { display: none; }.oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; font-weight: 800; font-size: .92rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }.oc-srv-icon:hover, .oc-srv-icon:focus-visible, .oc-srv-icon.is-active { border-radius: 14px; }.oc-srv-rail .oc-srv-icon.is-active::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }@@@ -220,7 +255,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-srv-people-list { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }}@media (max-width: 760px) {-- .oc-srv { grid-template-columns: minmax(0, 1fr); border-radius: 12px; }++ .oc-srv { grid-template-columns: minmax(0, 1fr); border: 0; border-radius: 0; overflow: visible; padding-bottom: 68px; }.oc-srv-rail, .oc-srv-side, .oc-srv-main { height: auto; }.oc-srv-rail { flex-direction: row; justify-content: flex-start; padding: 10px 12px; gap: 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); }.oc-srv-rail .oc-srv-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -10px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }@@@ -230,6 +265,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-srv-feed { max-height: 62vh; min-height: 220px; }.oc-chan-head, .oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { padding-left: 12px; padding-right: 12px; }.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }++ .oc-mobile-bar { position: fixed; z-index: 20; inset: auto 0 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px max(8px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--oc-line); background: var(--oc-panel); }++ .oc-mobile-bar a { min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--oc-muted); text-decoration: none; font-size: .68rem; }++ .oc-mobile-bar a.is-active { color: var(--oc-accent); }++ .oc-mobile-bar .oc-i { width: 20px; height: 20px; }}.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }@@@ -14,6 +14,40 @@var input = document.getElementById('oc-input');var latest = Number(cfg.latest) || 0;var LIVE = 'https://openvibe.live';++ var viewToggle = document.getElementById('oc-view-toggle');++ if (viewToggle && cfg.conversation) viewToggle.addEventListener('click', function () {++ var next = viewToggle.dataset.view === 'bubbles' ? 'room' : 'bubbles';++ viewToggle.disabled = true;++ fetch('/api/dm/conversations/' + encodeURIComponent(cfg.conversation) + '/view', {++ method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ view: next })++ }).then(function (response) { if (!response.ok) throw new Error('Could not save view'); window.location.reload(); })++ .catch(function () { viewToggle.disabled = false; });++ });++ var peopleSearch = document.getElementById('oc-people-search');++ var peopleResults = document.getElementById('oc-people-results');++ if (peopleSearch && peopleResults) {++ var recentPeopleMarkup = peopleResults.innerHTML;++ var peopleTimer = 0;++ peopleSearch.addEventListener('input', function () {++ window.clearTimeout(peopleTimer);++ peopleTimer = window.setTimeout(function () {++ var q = peopleSearch.value.trim();++ peopleResults.replaceChildren();++ if (q.length < 2) { peopleResults.innerHTML = recentPeopleMarkup; return; }++ fetch('/api/dm/users/search?q=' + encodeURIComponent(q), { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : { users: [] }; }).then(function (data) {++ if (peopleSearch.value.trim() !== q) return;++ (data.users || []).forEach(function (person) {++ var li = document.createElement('li'), form = document.createElement('form'), username = String(person.username || '');++ form.method = 'post'; form.action = '/messages/new';++ var field = document.createElement('input'); field.type = 'hidden'; field.name = 'username'; field.value = username;++ var button = document.createElement('button'); button.type = 'submit'; button.textContent = String(person.display_name || username) + (username ? ' · @' + username : '');++ form.appendChild(field); form.appendChild(button); li.appendChild(form); peopleResults.appendChild(li);++ });++ if (!peopleResults.children.length) { var empty = el('li', 'oc-muted', 'No people found'); peopleResults.appendChild(empty); }++ }).catch(function () { peopleResults.replaceChildren(); });++ }, 180);++ });++ }// The session token (the ov_token cookie is JS-readable), presented in each socket's first message.function sessionToken() {@@@ -74,6 +74,10 @@ const EXPORTS = [// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['vibe_asks.json', `SELECT idempotency_key, room_id, state, answer, code, created_at FROM vibe_asks WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],++ // Friends and conversation view preferences from the Chat app shell (migrations 0051, 0052, 0049).++ ['dm_contacts.json', `SELECT user_id, contact_id, status, created_at FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],++ ['dm_view_defaults.json', `SELECT kind, view FROM dm_view_defaults WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],++ ['dm_view_preferences.json', `SELECT conversation_id, view, updated_at FROM dm_view_preferences WHERE user_id = ANY(@ids) ORDER BY updated_at DESC LIMIT ${LIMIT}`],['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],@@@ -138,6 +142,10 @@ const ERASE_AFTER_ROOMS = [['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],++ // Friends and conversation view preferences from the Chat app shell.++ ['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],++ ['dm_view_defaults', 'DELETE FROM dm_view_defaults WHERE user_id = ANY(@ids)'],++ ['dm_view_preferences', 'DELETE FROM dm_view_preferences WHERE user_id = ANY(@ids)'],// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,// their replies stay for the visitor without their name, and their place on the team goes.[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * Friends and requests (the plan, 2026-11-09, stage V2: the side column on Home carries "Friends, Requests and++ * Vibe, then DMs"). Migration 0051 keeps the contacts a person has chosen: a friend is a contact both ways, a++ * request is one the other person has not accepted yet.++ *++ * A friend is only ever a person on OpenVibe: the id comes from Network's user directory (ctx_users), never from++ * the request body. A blocked person is never a friend.++ */++const db = require('../db/database');++++/** The contacts of `userId`: their friends and the requests they sent or received. */++async function of(userId) {++ const rows = await db.all(`++ SELECT c.contact_id, c.status, u.username, u.display_name, u.avatar_url, u.subject_id++ FROM dm_contacts c LEFT JOIN ctx_users u ON u.id = c.contact_id++ WHERE c.user_id = ? ORDER BY lower(COALESCE(u.display_name, u.username, ''))`, [userId]);++ const friends = [];++ const requests = [];++ const asked = [];++ for (const r of rows) {++ const person = { id: Number(r.contact_id), username: r.username || '', display_name: r.display_name || r.username || '', avatar_url: r.avatar_url || null, subject_id: r.subject_id || null, status: r.status };++ if (r.status === 'friend') friends.push(person);++ else if (r.status === 'incoming') requests.push(person); // they asked: it waits for you++ else if (r.status === 'outgoing') asked.push(person); // you asked: it waits for them++ }++ return { friends, requests, asked };++}++++/** The people who asked `userId` to be their friend (they answer in the side column). */++async function incoming(userId) {++ const rows = await db.all(`++ SELECT c.user_id, u.username, u.display_name, u.avatar_url, u.subject_id++ FROM dm_contacts c LEFT JOIN ctx_users u ON u.id = c.user_id++ WHERE c.contact_id = ? AND c.status = 'incoming'`, [userId]);++ return rows.map((r) => ({ id: Number(r.user_id), username: r.username || '', display_name: r.display_name || r.username || '', avatar_url: r.avatar_url || null, subject_id: r.subject_id || null }));++}++++/** Ask someone to be a friend (or accept them: both people then carry the other as a friend). */++async function add(userId, contactId) {++ const a = Number(userId); const b = Number(contactId);++ if (!a || !b || a === b) return null;++ // The other way round already: both are friends.++ const mine = await db.get('SELECT status FROM dm_contacts WHERE user_id = ? AND contact_id = ?', [a, b]);++ const theirs = await db.get('SELECT status FROM dm_contacts WHERE user_id = ? AND contact_id = ?', [b, a]);++ if (mine && mine.status === 'friend') return 'friend';++ if (theirs && theirs.status === 'incoming') {++ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);++ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);++ return 'friend';++ }++ if (mine && mine.status === 'incoming') {++ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);++ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);++ return 'friend';++ }++ await db.run("INSERT INTO dm_contacts (user_id, contact_id, status) VALUES (?, ?, 'outgoing') ON CONFLICT (user_id, contact_id) DO UPDATE SET status = 'outgoing'", [a, b]);++ if (!theirs) await db.run("INSERT INTO dm_contacts (user_id, contact_id, status) VALUES (?, ?, 'incoming') ON CONFLICT (user_id, contact_id) DO NOTHING", [b, a]);++ return 'outgoing';++}++++/** Accept a request from someone (both ways become friends). */++async function accept(userId, contactId) {++ const a = Number(userId); const b = Number(contactId);++ // The request waits for them: their own row says so ('incoming' means they asked).++ const mine = await db.get('SELECT status FROM dm_contacts WHERE user_id = ? AND contact_id = ?', [a, b]);++ if (!mine || mine.status !== 'incoming') return false;++ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);++ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);++ return true;++}++++/** Drop a friend or a request (both ways). */++async function remove(userId, contactId) {++ const a = Number(userId); const b = Number(contactId);++ await db.run('DELETE FROM dm_contacts WHERE (user_id = ? AND contact_id = ?) OR (user_id = ? AND contact_id = ?)', [a, b, b, a]);++ return true;++}++++module.exports = { of, incoming, add, accept, remove };@@@ -18,8 +18,10 @@ const router = express.Router();const { requireAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const dm = require('./dm');++const contacts = require('./contacts');const ctx = require('../live-context');const prefStores = require('../prefs/stores');++const database = require('../db/database');const presence = require('../presence');const { idParam } = require('./route-params');@@@ -47,6 +49,25 @@ async function withPresence(viewerSubject, conversations) {// All DM routes require authenticationrouter.use(requireAuth);++// A view choice is private to the signed-in participant and this conversation.++router.get('/conversations/:id/view', async (req, res) => {++ const id = Number(req.params.id);++ if (!Number.isSafeInteger(id) || !await dm.isParticipant(id, req.user.id)) return res.status(404).json({ error: 'Conversation not found' });++ const saved = await database.get('SELECT view FROM dm_view_preferences WHERE user_id = ? AND conversation_id = ?', [req.user.id, id]);++ const participants = await dm.getParticipants(id);++ const defaults = await prefStores.settingsOf(prefStores.preferences, req.user.subject_id);++ const kind = participants.length <= 2 ? 'one_to_one' : 'group';++ const view = saved?.view || (defaults.view_defaults && defaults.view_defaults[kind]) || (kind === 'one_to_one' ? 'bubbles' : 'room');++ res.set('Cache-Control', 'private, no-store').json({ conversation_id: id, view });++});++router.put('/conversations/:id/view', async (req, res) => {++ const id = Number(req.params.id), view = req.body && req.body.view;++ if (!Number.isSafeInteger(id) || !await dm.isParticipant(id, req.user.id)) return res.status(404).json({ error: 'Conversation not found' });++ if (view !== 'bubbles' && view !== 'room') return res.status(400).json({ error: 'view must be bubbles or room' });++ await database.run('INSERT INTO dm_view_preferences (user_id, conversation_id, view, updated_at) VALUES (?, ?, ?, ov_now()) ON CONFLICT (user_id, conversation_id) DO UPDATE SET view = EXCLUDED.view, updated_at = ov_now()', [req.user.id, id, view]);++ res.set('Cache-Control', 'private, no-store').json({ conversation_id: id, view });++});++// Ids are validated before the handler (and its queries) see them, one rule per param name.router.param('id', idParam('invalid conversation id'));router.param('userId', idParam('invalid user id'));@@@ -419,6 +440,61 @@ router.get('/users/search', async (req, res) => {}});++// ── Friends and requests (the side column on Home) ─────────────++++// The people you talk to: your friends and the requests that wait for you.++router.get('/contacts', async (req, res) => {++ try {++ const list = await contacts.of(req.user.id);++ res.set('Cache-Control', 'private, no-store').json(list);++ } catch (err) {++ console.error('[Contacts] List error:', err.message);++ res.status(500).json({ error: 'Failed' });++ }++});++++// Ask someone to be a friend (or accept them, when they asked first).++router.post('/contacts', manageLimit, async (req, res) => {++ try {++ const username = String((req.body && req.body.username) || '').trim();++ if (!/^[A-Za-z0-9_]{3,24}$/.test(username)) return res.status(400).json({ error: 'That is not a username' });++ const target = await ctx.getUserByUsername(username) || await ctx.ensureUserByUsername(username).catch(() => null);++ if (!target) return res.status(404).json({ error: 'Nobody on OpenVibe goes by that name' });++ if (Number(target.id) === Number(req.user.id)) return res.status(400).json({ error: 'That is you' });++ if (await dm.hasBlocked(req.user.id, target.id) || await dm.hasBlocked(target.id, req.user.id)) {++ return res.status(403).json({ error: 'You cannot add this person' });++ }++ const status = await contacts.add(req.user.id, target.id);++ res.json({ ok: true, status });++ } catch (err) {++ console.error('[Contacts] Add error:', err.message);++ res.status(500).json({ error: 'Failed' });++ }++});++++// Accept a request.++router.post('/contacts/:userId/accept', manageLimit, async (req, res) => {++ try {++ const ok = await contacts.accept(req.user.id, Number(req.params.userId));++ if (!ok) return res.status(404).json({ error: 'No request from that person' });++ res.json({ ok: true });++ } catch (err) {++ console.error('[Contacts] Accept error:', err.message);++ res.status(500).json({ error: 'Failed' });++ }++});++++// Drop a friend or a request.++router.delete('/contacts/:userId', manageLimit, async (req, res) => {++ try {++ await contacts.remove(req.user.id, Number(req.params.userId));++ res.json({ ok: true });++ } catch (err) {++ console.error('[Contacts] Remove error:', err.message);++ res.status(500).json({ error: 'Failed' });++ }++});++// ── Block & message management ───────────────────────────────// Block a usernnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * The default view for each kind of conversation (bubbles or a chat room) — the plan, 2026-11-09, stage V1:++ * "You can toggle per conversation, and set a default per kind in settings, synced through Network so the Dock++ * follows it."++ *++ * The choice is kept in Chat (migration 0052) because the Network module `chat.preferences` has no field for it++ * yet: adding one is a Contracts change (chat.preferences v3) that only lands with a release and a pin. Until++ * then the choice is Chat's own, and `view_defaults` is written to the Network module only when that module++ * knows the field (it answers with "unknown field" otherwise).++ */++const db = require('../db/database');++++const KINDS = ['one_to_one', 'ai', 'group', 'channel'];++const FALLBACK = { one_to_one: 'bubbles', ai: 'bubbles', group: 'room', channel: 'room' };++++/** The default view for each kind: what the person chose, or the built-in default. */++async function of(userId) {++ const out = { ...FALLBACK };++ try {++ const rows = await db.all('SELECT kind, view FROM dm_view_defaults WHERE user_id = ?', [userId]);++ for (const r of rows) if (KINDS.includes(r.kind) && (r.view === 'bubbles' || r.view === 'room')) out[r.kind] = r.view;++ } catch { /* the table is not there yet: the defaults stand */ }++ return out;++}++++/** Save the defaults for the kinds that were chosen (null leaves a kind as it was). */++async function save(userId, values = {}) {++ for (const kind of KINDS) {++ const view = values[kind];++ if (view !== 'bubbles' && view !== 'room') continue;++ await db.run(`INSERT INTO dm_view_defaults (user_id, kind, view) VALUES (?, ?, ?)++ ON CONFLICT (user_id, kind) DO UPDATE SET view = EXCLUDED.view`, [userId, kind, view]);++ }++ return await of(userId);++}++++module.exports = { of, save, KINDS, FALLBACK };nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * The templates a new server starts from, and the categories Explore lists them under (the plan, 2026-11-09,++ * stage V3: "/servers becomes Explore … a template first (Friends, Gaming group, Stream community, Study group,++ * Creators, Blank), each with its channels listed; then a name and a picture; then Public/Private as a segmented++ * control. Done in under 10 seconds.").++ *++ * A template is a name, one line about it and the channels it starts with. Nothing here talks to the database:++ * server/rooms/servers.js creates the channels.++ */++++/** The categories Explore filters by. */++const CATEGORIES = ['Gaming', 'Streams', 'Dev', 'Art', 'Music', 'Study', 'AI'];++++/** The templates: what a new server starts with. */++const TEMPLATES = [++ {++ id: 'friends',++ name: 'Friends',++ category: 'Study',++ description: 'A small server for a few friends.',++ channels: [++ { name: 'general', kind: 'text' },++ { name: 'plans', kind: 'text' },++ { name: 'lounge', kind: 'voice' },++ ],++ },++ {++ id: 'gaming',++ name: 'Gaming group',++ category: 'Gaming',++ description: 'A team that plays together: a channel per game, and a voice channel to talk in.',++ channels: [++ { name: 'general', kind: 'text' },++ { name: 'looking-for-group', kind: 'text' },++ { name: 'clips', kind: 'text' },++ { name: 'voice', kind: 'voice' },++ ],++ },++ {++ id: 'streams',++ name: 'Stream community',++ category: 'Streams',++ description: 'For a streamer and their people: chat, clips, and a stage to talk from.',++ channels: [++ { name: 'chat', kind: 'text' },++ { name: 'clips', kind: 'text' },++ { name: 'announcements', kind: 'text' },++ { name: 'stage', kind: 'voice' },++ ],++ },++ {++ id: 'study',++ name: 'Study group',++ category: 'Study',++ description: 'A quiet place to learn together: a room per subject and a silent study room.',++ channels: [++ { name: 'general', kind: 'text' },++ { name: 'homework', kind: 'text' },++ { name: 'study-room', kind: 'voice' },++ ],++ },++ {++ id: 'creators',++ name: 'Creators',++ category: 'Art',++ description: 'For people who make things: work in progress, feedback, and a room to show off.',++ channels: [++ { name: 'general', kind: 'text' },++ { name: 'work-in-progress', kind: 'text' },++ { name: 'feedback', kind: 'text' },++ { name: 'lounge', kind: 'voice' },++ ],++ },++ {++ id: 'blank',++ name: 'Blank',++ category: 'Dev',++ description: 'Start empty: one #general, and you add the rest.',++ channels: [{ name: 'general', kind: 'text' }],++ },++];++++/** The template with this id, or null. */++const byId = (id) => TEMPLATES.find((t) => t.id === String(id || '')) || null;++++/** The category a server may be listed under, or null. */++const category = (name) => (CATEGORIES.includes(String(name || '')) ? String(name) : null);++++module.exports = { TEMPLATES, CATEGORIES, byId, category };@@@ -104,7 +104,8 @@ function publicServer(s, extra = {}) {if (!s) return null;return {id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,-- official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,++ official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at,++ category: s.category || null, picture_url: s.picture_url || null, template: s.template || null, ...extra,};}@@@ -113,6 +114,7 @@ function publicChannel(r, extra = {}) {return {slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,++ category: r.category || null,...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,};}@@@ -158,7 +160,7 @@ async function insertChannel(server, user, { name, kind, topic = null, positionreturn await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);}--async function create(user, { name, slug, description, visibility = 'public' } = {}) {++async function create(user, { name, slug, description, visibility = 'public', category = null, picture_url = null, template = null } = {}) {if (!user) fail(401, 'servers.sign_in', 'Sign in to create a server');if (rooms.siteBanned(user)) fail(403, 'servers.banned', 'Your account is banned from chat');const n = cleanName(name, 2, 40);@@@ -172,13 +174,18 @@ async function create(user, { name, slug, description, visibility = 'public' } =const owned = Number((await db.get('SELECT COUNT(*) AS n FROM chat_servers WHERE owner_id = ? AND archived_at IS NULL', [user.id]) || {}).n) || 0;if (owned >= MAX_SERVERS_PER_OWNER && !isStaff(user)) fail(429, 'servers.too_many', `You can own up to ${MAX_SERVERS_PER_OWNER} servers`);if (await db.get('SELECT 1 FROM chat_servers WHERE slug = ?', [s])) fail(409, 'servers.taken', 'That server address is taken');++ // The template decides the channels a new server starts with (server/rooms/server-templates.js); a blank++ // server keeps #general and the Lounge.++ const tpl = require('./server-templates').byId(template);++ const cat = require('./server-templates').category(category);++ const pic = picture_url ? String(picture_url).slice(0, 500) : null;const id = await db.tx(async () => {-- const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count) VALUES (?, ?, ?, ?, ?, ?, 1)',-- [s, n, d, visibility, user.id, user.subject_id || null]);++ const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count, category, picture_url, template) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)',++ [s, n, d, visibility, user.id, user.subject_id || null, cat, pic, tpl ? tpl.id : null]);const server = await db.get('SELECT * FROM chat_servers WHERE id = ?', [r.lastInsertRowid]);await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [server.id, user.id]);-- await insertChannel(server, user, { name: 'general', kind: 'text', topic: 'Say hello', position: 0 });-- await insertChannel(server, user, { name: 'Lounge', kind: 'voice', position: 0 });++ const channels = (tpl ? tpl.channels : [{ name: 'general', kind: 'text', topic: 'Say hello' }, { name: 'Lounge', kind: 'voice' }]);++ for (const [i, c] of channels.entries()) await insertChannel(server, user, { name: c.name, kind: c.kind, topic: c.topic || (i === 0 ? 'Say hello' : null), position: i });await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) SELECT id, ?, 'member', 0, ov_now() FROM rooms WHERE server_id = ?", [user.id, server.id]);return Number(server.id);});@@@ -303,7 +310,7 @@ async function setRole(server, actor, targetUserId, role) {/** The server's people by rank (blocked last, only for moderators). */async function members(server, { blocked = false, limit = 1000, subjects = false } = {}) {const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;-- return await db.all(`SELECT m.user_id${subjects ? ', u.subject_id' : ''}, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m++ return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color, u.subject_id, u.avatar_url FROM chat_server_members mLEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);}@@@ -37,6 +37,9 @@ const ICONS = {key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),help: svg('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>'),more: svg('<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>'),++ view: svg('<path d="M4 5h7a3 3 0 0 1 3 3v3H7a3 3 0 0 1-3-3zM20 19h-7a3 3 0 0 1-3-3v-3h7a3 3 0 0 1 3 3z"/>'),++ menu: svg('<path d="M4 7h16M4 12h16M4 17h16"/>'),++ voice: svg('<path d="M3 9v6h4l5 4V5L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/>'),download: svg('<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>'),chevron: svg('<path d="m9 6 6 6-6 6"/>'),hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),@@@ -63,6 +63,9 @@ function navConfig(o, config, site = {}, siteLinks = null) {apiBase: NETWORK_URL,links: (siteLinks || site.links || links(o)).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),history: { type: 'page', title: o.title || site.name || SITE_NAME },++ // The phone's bottom bar (Shared's app bar, appbar.js): Home, Servers, Vibe, Activity and You — one++ // bar for every OpenVibe app (the plan, 2026-11-09, stage V2).++ appBar: require('./shell').APP_BAR.map((l) => ({ ...l, active: o.active === l.page })),silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,@@@ -145,7 +148,9 @@ function renderPage(o) {const p = o.prefs || {};const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';-- const footerVariant = o.footer === 'compact' ? 'compact' : 'full';++ // 'none': the app shell's own pages (the plan, 2026-11-09, stage V2) have no footer: the conversation++ // fills the screen, and the phone's bottom bar is Shared's app bar.++ const footerVariant = o.footer === 'compact' ? 'compact' : (o.footer === 'none' ? 'none' : 'full');const html = `<!DOCTYPE html><html lang="en"><head>@@@ -155,6 +160,7 @@ ${head}${appIcon.headTags({ site: 'network' })}${INSTALL_LINKS}<link rel="stylesheet" href="${asset('chat.css')}">++${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}<link rel="stylesheet" href="${ovServe.url('items.css')}">${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}@@@ -168,6 +174,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join<script src="${ovServe.url('navbar.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script>${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}++${o.shell ? `<script src="${asset('chat-shell.js')}" defer></script>` : ''}${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}@@@ -178,11 +185,11 @@ ${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${<a class="oc-skip" href="#main">Skip to content</a><div id="navbar-mount"></div>${frame.noscriptNav({ name: siteName, home: '/', links: siteLinks.map(({ label, href }) => ({ label, href })) })}--<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.wide ? ' oc-main--wide' : ''}`}"${o.mainAttrs ? ` ${o.mainAttrs}` : ''}>++<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.shell ? ' oc-main--shell' : (o.wide ? ' oc-main--wide' : '')}`}"${o.mainAttrs ? ` ${o.mainAttrs}` : ''}><noscript><p class="oc-account">${who}</p></noscript>${o.body || ''}</main>--${o.app ? '' : frame.footer({ service: siteService, variant: footerVariant, updates: '/updates' })}++${o.app || footerVariant === 'none' ? '' : frame.footer({ service: siteService, variant: footerVariant, updates: '/updates' })}<script>window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config, site, siteLinks), footer: { service: siteService, variant: footerVariant, mount: '#ov-footer', brandName: siteName, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};document.addEventListener('DOMContentLoaded', function () {@@@ -30,6 +30,7 @@* test/live-down.test.js renders every page and uses the core APIs with Live refusing and hanging.*/const showcase = require('openvibe-shared/showcase');++const shell = require('./shell');const path = require('path');const express = require('express');const rateLimit = require('express-rate-limit');@@@ -125,9 +126,10 @@ function globalMessage(m) {const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;}--function dmMessage(m, me) {++function dmMessage(m, me, view = 'room') {const t = hhmm(m.created_at);const mine = me && m.sender_id === me.id;++ if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}">${!mine ? `<span class="oc-bubble-author">${esc(m.display_name || m.username)}</span>` : ''}<div class="oc-bubble">${linkify(m.message)}</div>${ui.linkCard(m.card)}<time class="oc-bubble-time" datetime="${t.iso}">${t.text}</time></li>`;return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;}// Vibe, OpenVibe's assistant, as a room's AI member (one click on the room's settings; a person's own chats with it are@@@ -348,9 +350,9 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', htmlconst dmApi = require('../chat/dm-routes');const { ICONS: IC } = require('./icons');/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */-- const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null }) => {++ const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null, avatarUrl = '' }) => {const t = ui.when(at);-- return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title, personPresence)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`++ return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title, personPresence, { avatarUrl })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`+ `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;};router.get('/messages', async (req, res) => {@@@ -365,8 +367,10 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', htmlconst others = participants[i].filter((p) => p.id !== me.id);const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';const personPresence = others.length === 1 ? others[0].presence : null;-- return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0, personPresence });++ return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0, personPresence, avatarUrl: others.length === 1 ? others[0].avatar_url : '' });});++ const recentPeople = [...new Map(participants.flat().filter((person) => person.id !== me.id).map((person) => [person.id, person])).values()].slice(0, 6);++ const recentPicker = recentPeople.map((person) => `<li><form method="post" action="/messages/new"><input type="hidden" name="username" value="${esc(person.username || '')}"><button type="submit">${ui.initials(person.display_name || person.username, person.presence, { avatarUrl: person.avatar_url })}<span>${esc(person.display_name || person.username)}${person.username ? ` <small>@${esc(person.username)}</small>` : ''}</span></button></form></li>`).join('');const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';const blocked = await dm.getBlockedUsers(me.id);@@@ -376,14 +380,32 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', htmlconst support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);const STATUS = { bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' };const supportList = support.length ? `<h2 class="oc-list-head">Support</h2><ul class="oc-list">${support.map((c) => convRow({ href: `/support/${c.id}`, title: c.inbox.name, preview: c.preview || '', at: c.last_message_at, badge: ` <span class="oc-badge">${esc(STATUS[c.status] || c.status)}</span>` })).join('')}</ul>` : '';++ // The rail, the side column (Home) and the list: the app shell (the plan, 2026-11-09, stage V2).++ const mineServers = (await require('../rooms/servers').list(me)).mine;++ const contacts = await require('../chat/contacts').of(me.id);++ const dms = convs.map((c, i) => {++ const others = participants[i].filter((p) => p.id !== me.id);++ const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';++ return {++ id: c.id, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet',++ unread: Number(c.unread_count) || 0, presence: others.length === 1 ? others[0].presence : null,++ avatarUrl: others.length === 1 ? others[0].avatar_url : '',++ };++ });await page(req, res, 200, {-- actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',++ actor, shell: true, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',page: { view: 'inbox', unread, me: me.id },-- body: `${ui.head({ title: 'Messages', sub: `<span id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</span>` })}${error}${done}--<form class="oc-find" method="post" action="/messages/new" role="search">${IC.pencil}<label for="oc-to" class="oc-sr">New message: their username</label><input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="New message: their username" autocomplete="off" enterkeyhint="go"><button type="submit" class="oc-send" aria-label="Start the conversation" title="Start">${IC.send}</button></form>++ body: shell.shell({++ rail: shell.rail({ home: true, dmUnread: unread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),++ sideLabel: 'Home',++ main: `${ui.head({ title: 'Messages', sub: `<span id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</span>`, actions: [`<details class="oc-new-picker"><summary class="oc-icon-btn" aria-label="New message" title="New message">${IC.pencil}<span>New</span></summary><div class="oc-new-pop"><label for="oc-people-search">Find someone</label><input id="oc-people-search" type="search" minlength="2" autocomplete="off" placeholder="Search people"><ul id="oc-people-results" aria-live="polite">${recentPicker || '<li class="oc-muted">Search by name or username</li>'}</ul></div></details>`] })}${error}${done}<ul class="oc-list" id="oc-convs">${rows.join('\n') || '<li class="oc-row-empty oc-muted">No conversations yet. Write a username above to start one.</li>'}</ul>${supportList}${blockedList}`,++ mainLabel: 'Messages',++ page: true,++ }),});});@@@ -395,6 +417,31 @@ ${blockedList}`,res.redirect(303, back);});++ // ── Friends and requests (the side column on Home) ──++ router.post('/messages/contacts', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const name = String((req.body && req.body.username) || '').trim();++ const back = '/messages';++ if (!/^[A-Za-z0-9_]{3,24}$/.test(name)) return res.redirect(303, `${back}?error=${encodeURIComponent('That is not a username')}`);++ const target = await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);++ if (!target) return res.redirect(303, `${back}?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);++ if (Number(target.id) === Number(actor.user.id)) return res.redirect(303, back);++ const status = await require('../chat/contacts').add(actor.user.id, target.id);++ res.redirect(303, status === 'friend' ? `${back}?added=1` : `${back}?asked=1`);++ });++++ router.post('/messages/contacts/:userId/accept', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ await require('../chat/contacts').accept(actor.user.id, Number(req.params.userId));++ res.redirect(303, '/messages');++ });++++ router.post('/messages/contacts/:userId/remove', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ await require('../chat/contacts').remove(actor.user.id, Number(req.params.userId));++ res.redirect(303, '/messages');++ });++router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {const actor = await needUser(req, res); if (!actor) return;const name = String((req.body && req.body.username) || '').trim();@@@ -412,6 +459,7 @@ ${blockedList}`,router.get('/messages/:id', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const me = actor.user;++ const preferences = await prefsOf(actor);const id = parseInt(req.params.id, 10);if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return await page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);@@@ -419,6 +467,10 @@ ${blockedList}`,const conv = await dm.getConversation(id);const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);++ const savedView = await db.get('SELECT view FROM dm_view_preferences WHERE user_id = ? AND conversation_id = ?', [me.id, id]).catch(() => null);++ const viewKind = others.length <= 1 ? 'one_to_one' : 'group';++ const viewDefaults = await require('../chat/view-defaults').of(me.id);++ const view = savedView?.view || viewDefaults[viewKind] || (viewKind === 'one_to_one' ? 'bubbles' : 'room');try { await dm.markRead(id, me.id); } catch { /* best effort */ }const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';// A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.@@@ -431,21 +483,43 @@ ${blockedList}`,const otherPresence = other ? other.presence : null;const liveHref = otherPresence && (otherPresence.channel_url || otherPresence.url);const subLine = other ? (ui.presenceLine(otherPresence, { liveHref }) || `@${esc(other.username || '')}`) : `${others.length + 1} people`;-- const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar' }) : '';++ const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar', avatarUrl: other.avatar_url }) : '';let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;else if (closed) composer = notice('error', 'You cannot message this person.');// Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.-- const tools = other && !iBlocked ? `<details class="oc-more"><summary class="oc-icon-btn" aria-label="More" title="More">${IC.more}</summary><div class="oc-pop"><form method="post" action="/messages/${id}/block"><button type="submit" class="oc-pop-item oc-danger">${IC.ban}<span>Block ${otherName}</span></button></form></div></details>` : '';++ const tools = !closed ? `<details class="oc-more"><summary class="oc-icon-btn" aria-label="More" title="More">${IC.more}</summary><div class="oc-pop"><button type="button" class="oc-pop-item" id="oc-view-toggle" aria-label="Show as ${view === 'bubbles' ? 'a chat room' : 'bubbles'}" data-view="${view}">${IC.view}<span>${view === 'bubbles' ? 'Show as a chat room' : 'Show as bubbles'}</span></button>${other && !iBlocked ? `<form method="post" action="/messages/${id}/block"><button type="submit" class="oc-pop-item oc-danger">${IC.ban}<span>Block ${otherName}</span></button></form>` : ''}</div></details>` : '';// A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).const callForm = other && !closed && require('../config').calls.enabled-- ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}</button></form>` : '';++ ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}<span class="oc-call-label">Call</span></button></form>` : '';++ // The DMs down the side, so the conversation can be swapped without going back (the app shell).++ const convs = await dm.getConversations(me.id);++ const parts = await Promise.all(convs.map((c) => dm.getParticipants(c.id)));++ await presence.attachTo(me.subject_id, parts.flat());++ const dms = convs.map((c, i) => {++ const others = parts[i].filter((p) => p.id !== me.id);++ const name = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';++ return {++ id: c.id, title: name, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet',++ unread: Number(c.unread_count) || 0, presence: others.length === 1 ? others[0].presence : null,++ avatarUrl: others.length === 1 ? others[0].avatar_url : '', current: Number(c.id) === Number(id),++ };++ });++ const contacts = await require('../chat/contacts').of(me.id);++ const mineServers = (await require('../rooms/servers').list(me)).mine;++ const dmUnread = dms.reduce((n, d) => n + (d.current ? 0 : d.unread), 0);await page(req, res, 200, {-- actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',-- page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },-- body: `${ui.head({ title: esc(title), sub: `<span id="oc-dm-status">${subLine}</span>`, avatar, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}--<ol class="oc-feed oc-room-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>++ actor, shell: true, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',++ page: { view: 'dm', conversation: id, messageView: view, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },++ body: shell.shell({++ rail: shell.rail({ home: true, dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),++ sideLabel: 'Home',++ main: `${ui.head({ title: esc(title), sub: `<span id="oc-dm-status">${subLine}</span>`, avatar, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}++<ol class="oc-feed oc-room-feed${view === 'bubbles' ? ' oc-bubbles' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me, view)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>${composer}`,++ mainLabel: title,++ }),});});@@@ -532,7 +606,7 @@ ${composer}`,router.get('/settings', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const sid = actor.user.subject_id;-- const [p, tts, dmSet, pres] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid)]);++ const [p, tts, dmSet, pres, viewDefaults] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid), require('../chat/view-defaults').of(actor.user.id)]);const src = tts.sources || {};// A settings row: what it does on the left (with one muted line when it needs saying), the switch on the right.const box = (name, label, on, hint = '') => `<label class="oc-set-row"><span class="oc-set-text">${label}${hint ? `<small>${hint}</small>` : ''}</span><input type="checkbox" class="oc-switch" name="${name}" value="1"${on ? ' checked' : ''}></label>`;@@@ -551,6 +625,8 @@ ${box('compact', 'Compact messages', p.compact === true)}${box('show_badges', 'Staff and moderator badges', p.show_badges !== false)}${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}<div class="oc-set-row"><span class="oc-set-text" id="oc-size-l">Text size</span><span class="oc-seg" role="radiogroup" aria-labelledby="oc-size-l">${sizes}</span></div>++<h3>Conversation view</h3>++${[['one_to_one','One-to-one chats','bubbles'],['ai','Chats with Vibe','bubbles'],['group','Group conversations','room'],['channel','Channels and stream chat','room']].map(([key,label,fallback]) => `<label class="oc-set-row"><span class="oc-set-text">${label}</span><select name="view_${key}"><option value="bubbles"${((viewDefaults && viewDefaults[key]) || fallback) === 'bubbles' ? ' selected' : ''}>Bubbles</option><option value="room"${((viewDefaults && viewDefaults[key]) || fallback) === 'room' ? ' selected' : ''}>Chat room</option></select></label>`).join('')}</section><section class="oc-set" aria-labelledby="oc-set-dm"><h2 id="oc-set-dm">Direct messages</h2><label class="oc-set-row"><span class="oc-set-text">Who can start one with you</span><select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody</option></select></label>@@@ -649,6 +725,14 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/aconst sid = actor.user.subject_id;try {await prefs.update(sid, patch);++ // The view defaults are Chat's own (server/chat/view-defaults.js): the Network module has no field for++ // them yet, and a refused patch would lose the whole save.++ await require('../chat/view-defaults').save(actor.user.id, {++ one_to_one: b.view_one_to_one === 'room' ? 'room' : 'bubbles',++ ai: b.view_ai === 'room' ? 'room' : 'bubbles',++ group: b.view_group === 'bubbles' ? 'bubbles' : 'room',++ channel: b.view_channel === 'bubbles' ? 'bubbles' : 'room',++ });await prefStores.dm.update(sid, { new_conversations: b.new_conversations === 'nobody' ? 'nobody' : null, group_invites: on('group_invites') ? null : false, previews: on('previews') ? null : false });await prefStores.presence.update(sid, { show_in_user_list: on('show_in_user_list') ? null : false });await prefStores.tts.update(sid, {@@@ -17,6 +17,7 @@ const { esc } = require('./layout');const ui = require('./ui');const { ICONS: IC } = require('./icons');const rooms = require('../rooms/rooms');++const shell = require('./shell');const servers = require('../rooms/servers');const ctx = require('../live-context');@@@ -49,37 +50,117 @@ function mountServerPages(router, h) {const serverRow = (s) => `<li class="oc-row${s.unread ? ' oc-unread' : ''}"><a href="/s/${esc(s.slug)}">${ui.serverMark(s)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(s.name)}</strong>${s.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${s.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`+ `<span class="oc-row-sub">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}${s.unread ? `<span class="oc-dot" aria-label="${s.unread} new">${s.unread > 9 ? '9+' : s.unread}</span>` : ''}</span></span></a></li>`;-- // ── The directory ──++ // ── Explore: the servers on OpenVibe, and the sheet that starts one ──router.get('/servers', async (req, res) => {const actor = await viewerOf(req);const me = userOf(actor);const { public: pub, mine } = await servers.list(me);-- // Your servers are listed once, in their own list.++ const templates = require('../rooms/server-templates');++ const q = String(req.query.q || '').trim().toLowerCase();++ const cat = String(req.query.category || '');++ // Every server the person may see: the ones they are in, then the public ones (the official server is++ // always first). "No other public server yet." is never shown: the official server and Start a server++ // are always there.const yours = new Set(mine.map((s) => s.slug));-- const others = pub.filter((s) => !yours.has(s.slug));-- const create = me ? `<form class="oc-form oc-grid-form oc-create" id="oc-new-server" method="post" action="/servers/new">++ const all = [...mine, ...pub.filter((s) => !yours.has(s.slug))];++ const listed = all.filter((s) => (!q || `${s.name} ${s.description || ''}`.toLowerCase().includes(q))++ && (!cat || s.category === cat));++ // How many of each server's people are here now (Network's presence, one read, cached 30 s).++ const online = {};++ if (me && listed.length) {++ const ids = listed.map((s) => Number(s.id)).filter((n) => Number.isInteger(n));++ const rows = ids.length ? await require('../db/database').all(`SELECT s.slug AS slug, u.subject_id AS subject FROM chat_servers s++ JOIN chat_server_members m ON m.server_id = s.id AND m.role != 'blocked'++ JOIN ctx_users u ON u.id = m.user_id WHERE s.id IN (${ids.map(() => '?').join(',')})`, ids) : [];++ const people = rows.map((r) => ({ slug: r.slug, subject_id: r.subject }));++ await require('../presence').attachTo(me.subject_id, people);++ for (const p of people) if (p.presence) online[p.slug] = (online[p.slug] || 0) + 1;++ }++ const chip = (name, href) => `<a class="oc-chip${name === cat ? ' is-active' : ''}" href="${esc(href)}">${esc(name || 'All')}</a>`;++ // Chat never writes ?q= back into a page (test/security-private.test.js): the chips do not carry it.++ const chips = `<nav class="oc-chips" aria-label="Categories">${chip('', '/servers')}${templates.CATEGORIES.map((c) => chip(c, `/servers?category=${encodeURIComponent(c)}`)).join('')}</nav>`;++ // One server as a card: its banner, its icon, its name, one line about it, its people, and a way in.++ const card = (sv) => {++ const isMember = yours.has(sv.slug);++ const join = isMember++ ? `<a class="oc-button oc-button--quiet" href="/s/${esc(sv.slug)}">Open</a>`++ : (me ? `<form method="post" action="/s/${esc(sv.slug)}/join"><button type="submit">Join</button></form>`++ : `<a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/s/${sv.slug}`)}">Sign in to join</a>`);++ return `<article class="oc-card oc-server-card">++<div class="oc-card-banner" style="--oc-hue:${hueOf(sv.slug)}" aria-hidden="true"></div>++<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(sv.slug)}" aria-hidden="true">${esc(initials(sv.name))}</span>++<h3>${esc(sv.name)}${sv.official ? ' <span class="oc-badge oc-badge-official">official</span>' : ''}${sv.category ? ` <span class="oc-badge">${esc(sv.category)}</span>` : ''}</h3>++<p class="oc-card-line">${esc(sv.description || 'A server on OpenVibe.Chat.')}</p>++<p class="oc-muted oc-card-meta">${Number(sv.members) || 0} member${Number(sv.members) === 1 ? '' : 's'}${online[sv.slug] ? ` · ${online[sv.slug]} online` : ''}</p>++${join}</article>`;++ };++ const startCard = `<article class="oc-card oc-server-card oc-server-card--new"><h3>Start a server</h3><p class="oc-card-line">A template, a name and a picture: under ten seconds.</p><a class="oc-button" href="#oc-new-server">Start a server</a></article>`;++ // The sheet: a template first, then a name and a picture, then public or private.++ const sheet = me ? `<details class="oc-sheet" id="oc-new-server"><summary class="oc-button">Start a server</summary>++<form class="oc-form oc-sheet-form" method="post" action="/servers/new" enctype="multipart/form-data"><h2>Start a server</h2>--<p class="oc-muted oc-small">Text channels to write in and voice channels to talk in, with one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>--<label>Name <input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>--<label>Description <input name="description" maxlength="300" placeholder="What it is for (optional)"></label>--<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people with an invite can find, read or join it</label>++<fieldset class="oc-sheet-step"><legend>1. What is it for?</legend>++<div class="oc-templates">${templates.TEMPLATES.map((t, i) => `<label class="oc-template"><input type="radio" name="template" value="${esc(t.id)}"${i === 0 ? ' checked' : ''}><span class="oc-template-body"><strong>${esc(t.name)}</strong><span class="oc-muted">${esc(t.description)}</span><span class="oc-template-channels">${t.channels.map((c) => esc(c.kind === 'voice' ? '🔊 ' : '# ') + esc(c.name)).join(' · ')}</span></span></label>`).join('')}</div>++</fieldset>++<fieldset class="oc-sheet-step"><legend>2. Its name and picture</legend>++<div class="oc-sheet-fields">++<label class="oc-field"><span>Name</span><input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>++<label class="oc-field"><span>Category</span><select name="category"><option value="">No category</option>${templates.CATEGORIES.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select></label>++<label class="oc-field oc-field--wide"><span>Description</span><input name="description" maxlength="300" placeholder="What it is for (optional)"></label>++<label class="oc-field oc-field--wide"><span>Picture</span><input type="file" name="picture" accept="image/png,image/jpeg,image/webp"><small class="oc-muted">PNG, JPEG or WebP. Without one, its initials on its colour.</small></label>++</div>++</fieldset>++<fieldset class="oc-sheet-step"><legend>3. Who may find it?</legend>++<div class="oc-seg" role="radiogroup"><label><input type="radio" name="visibility" value="public" checked><span>Public</span></label><label><input type="radio" name="visibility" value="private"><span>Private</span></label></div>++<p class="oc-muted oc-small">A public server is listed in Explore and anyone may join it. A private one is joined through an invite.</p>++</fieldset><p><button type="submit">Create server</button></p>--</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;++</form></details>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;++ const side = `<div class="oc-side-top"><a class="oc-side-find" href="#oc-new-server">${IC.plus}<span>Start a server</span></a></div>++<section class="oc-side-sec"><h2 class="oc-side-group">Your servers</h2>++${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"><a href="/s/${esc(sv.slug)}">${ui.serverMark(sv)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(sv.name)}</strong></span><span class="oc-row-sub">${Number(sv.members) || 0} member${Number(sv.members) === 1 ? '' : 's'}</span></span></a></li>`).join('')}</ul>` : `<p class="oc-side-empty oc-muted">You are in no servers yet.</p>`}++</section>`;await page(req, res, 200, {-- actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'compact',++ actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none',description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',-- body: `${ui.head({ title: 'Servers', sub: 'Groups with text and voice channels, roles and invites', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-server" aria-label="Start a server" title="Start a server">${IC.plus}</a>`] : [] })}${errorOf(req)}--${mine.length ? `<h2 class="oc-list-head">Your servers</h2><ul class="oc-list">${mine.map(serverRow).join('')}</ul>` : ''}--<h2 class="oc-list-head">${mine.length ? 'More public servers' : 'Public servers'}</h2>${others.length ? `<ul class="oc-list">${others.map(serverRow).join('')}</ul>` : `<p class="oc-rooms-empty oc-muted">${mine.length ? 'No other public server yet.' : 'No public server yet. Start one: it can be public, or private to the people you invite.'}</p>`}--${create}`,++ body: shell.shell({++ rail: shell.rail({ explore: true, dmUnread: me ? await require('../chat/dm').getTotalUnread(me.id) : 0, servers: mine.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ side, sideLabel: 'Your servers',++ main: `${ui.head({ title: 'Explore servers', sub: `${listed.length} server${listed.length === 1 ? '' : 's'}` })}++<form class="oc-find" method="get" action="/servers" role="search">${IC.search}<label for="oc-server-q" class="oc-sr">Find a server</label><input id="oc-server-q" name="q" value="" placeholder="Find a server" autocomplete="off"><button type="submit" class="oc-send" aria-label="Find" title="Find">${IC.search}</button></form>++${chips}++<div class="oc-cards">${listed.map(card).join('')}${startCard}</div>++${sheet}`,++ mainLabel: 'Explore servers',++ page: true,++ }),});});-- router.post('/servers/new', formLimit, sameSite, orError(async (req, res) => {++ // The Start sheet's picture (multipart; the other fields are ordinary form fields).++ const serverPicture = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 12 } }).single('picture');++++ router.post('/servers/new', serverPicture, formLimit, sameSite, orError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const b = req.body || {};-- const s = await servers.create(actor.user, { name: b.name, description: b.description, visibility: b.private === '1' ? 'private' : 'public' });-- res.redirect(303, `/s/${s.slug}`);++ // A picture, when one was sent: OpenVibe.Pics, as the person (server/rooms/pictures.js).++ let picture = null;++ try {++ const pics = require('../rooms/pictures');++ if (pics.enabled() && req.file && req.file.buffer) {++ const up = await pics.upload({ token: req.cookies && req.cookies.ov_token, buffer: req.file.buffer, contentType: req.file.mimetype, title: `Server: ${String(b.name || '').slice(0, 40)}`, ip: req.ip });++ if (up && up.url) picture = up.url;++ }++ } catch { /* no picture: the monogram tile is the icon */ }++ const s = await servers.create(actor.user, {++ name: b.name, description: b.description,++ // The sheet sends visibility; the older form sent private=1.++ visibility: (b.visibility === 'private' || b.private === '1') ? 'private' : 'public',++ category: b.category, picture_url: picture, template: b.template,++ });++ // The invite link shows at the end, with Copy and Share (the sheet's last step).++ const invite = await servers.createInvite(s, actor.user, { max_uses: null, expires_hours: null }).catch(() => null);++ res.redirect(303, `/s/${s.slug}${invite && invite.code ? `?invite=${encodeURIComponent(invite.code)}` : ''}`);}));/** The server behind :slug for this viewer (404 page when they may not read it). */@@@ -107,18 +188,24 @@ ${create}`,const msgs = await rooms.history(room, { limit: 60 });if (me && rcan.role) await rooms.markRead(room, me);const mine = me ? (await servers.list(me)).mine : [];++ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;const people = await servers.members(server, { limit: 300 });++ // The people's presence (Network, cached 30 s) and their mark.++ await require('../presence').attachTo(me ? me.subject_id : null, people.map((p) => ({ ...p, id: p.user_id })));const isVoice = room.kind === 'call';const callReady = isVoice && !!require('../config').calls.enabled;const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };-- const chanLink = (c) => {-- const here = c.slug === pick.slug;-- const unread = !here && c.unread ? `<span class="oc-chan-dot" aria-label="${c.unread} unread">${c.unread > 99 ? '99+' : c.unread}</span>` : '';-- const inCall = c.kind === 'voice' ? (callOf(c) || {}).participants : 0;-- return `<li><a class="oc-chan${c.kind === 'voice' ? ' oc-chan-voice' : ''}${here ? ' is-active' : ''}${unread ? ' is-unread' : ''}" href="/s/${esc(server.slug)}/${esc(c.slug)}"${here ? ' aria-current="page"' : ''}>${c.kind === 'voice' ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(c.name)}</span>${inCall ? `<span class="oc-muted oc-chan-in">${inCall} in</span>` : ''}${unread}</a></li>`;-- };-- const texts = chans.filter((c) => c.kind === 'text');-- const voices = chans.filter((c) => c.kind === 'voice');++ // The people in each voice channel (the plan: "voice channels with who is in them").++ const occupants = {};++ for (const c of chans) if (c.kind === 'voice') occupants[c.slug] = ((callOf(c) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name }));++ // The channel categories: a channel's own category, or the kind's default.++ const categories = [];++ const byCat = new Map();++ for (const c of chans) {++ const name = c.category || (c.kind === 'voice' ? 'Voice channels' : 'Text channels');++ if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }++ byCat.get(name).push(c);++ }let composer;const back = `/s/${server.slug}/${pick.slug}`;if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;@@@ -127,21 +214,34 @@ ${create}`,else composer = notice('error', 'You cannot write here right now.');const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(require('../rooms/ai-members').modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';++ // The people, grouped by their role (the members column).const byRank = {};for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);-- const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;-- const peopleHtml = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => `<h3 class="oc-srv-group">${RANK_TITLE[r]} <span class="oc-muted">${byRank[r].length}</span></h3><ul class="oc-srv-people-list">${byRank[r].map(person).join('')}</ul>`).join('');-- const tools = [-- can.moderate ? `<a href="/s/${esc(server.slug)}/settings">${can.manage ? 'Server settings' : 'Members and invites'}</a>` : '',-- can.member && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-link">Leave server</button></form>` : '',-- ].filter(Boolean).join(' · ');++ const roleGroups = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => ({++ role: r, title: RANK_TITLE[r],++ people: byRank[r].map((p) => ({++ id: p.user_id, username: p.username || '', display_name: p.display_name || p.username || '',++ avatar_url: p.avatar_url || null, presence: p.presence || null,++ color: /^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? p.profile_color : '',++ href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,++ })),++ }));const canInvite = can.manage || (can.moderate && server.visibility === 'public');++ // The sheet's last step: the invite link, with Copy and Share (a fresh server comes with one).++ const inviteCode = String(req.query.invite || '').toLowerCase();++ const invite = inviteCode && /^[a-z0-9]{6,16}$/.test(inviteCode) ? await servers.inviteByCode(inviteCode).catch(() => null) : null;++ const invitePanel = invite && invite.invite ? `<section class="oc-invite-new" aria-label="Your invite link">++<h2>Your invite link</h2><p class="oc-muted">Anyone with this link can join ${esc(server.name)}.</p>++<code class="oc-invite-url" id="oc-invite-url">${esc(`${site}/join/${invite.invite.code}`)}</code>++<p class="oc-invite-tools"><button type="button" class="oc-button" id="oc-invite-copy" data-oc-copy="${esc(`${site}/join/${invite.invite.code}`)}">Copy</button>++<a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">More invites</a></p>++</section>` : '';// The canonical URL is the one served and listed in the sitemap: /s/<server> without a channel in the// request, and the channel's own URL when the request named it. `back` stays the channel URL the composer// and the sign-in link post to (POST /s/:slug/:channel).const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;await page(req, res, 200, {-- actor, wide: true, footer: 'compact', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers',++ actor, shell: true, footer: 'none', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers',robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,page: {@@@ -149,31 +249,35 @@ ${create}`,me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),},-- body: `<div class="oc-srv">--<nav class="oc-srv-rail" aria-label="Your servers">${mine.map((s) => icon(s, s.slug === server.slug)).join('')}${mine.some((s) => s.slug === server.slug) ? '' : icon(servers.publicServer(server), true)}--<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>--<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>--<aside class="oc-srv-side" aria-label="${esc(server.name)}">--<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.slug === servers.OFFICIAL_SLUG ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}--<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>--${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>--<details class="oc-srv-channels" open><summary>Channels</summary>--<h2 class="oc-srv-group">Text channels</h2><ul class="oc-srv-list">${texts.map(chanLink).join('')}</ul>--${voices.length ? `<h2 class="oc-srv-group">Voice channels</h2><ul class="oc-srv-list">${voices.map(chanLink).join('')}</ul>` : ''}--</details>--${!can.member && me ? `<form class="oc-srv-join" method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>` : ''}--${canInvite ? `<p class="oc-srv-invite"><a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">Invite people</a></p>` : ''}--${tools ? `<p class="oc-srv-tools">${tools}</p>` : ''}--</aside>--<section class="oc-srv-main" aria-labelledby="oc-chan-h">--<header class="oc-chan-head"><h2 id="oc-chan-h">${isVoice ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}${esc(room.name)}</h2>${room.topic ? `<span class="oc-muted oc-chan-topic">${esc(room.topic)}</span>` : ''}<span class="oc-live" id="oc-live" hidden>● live</span></header>++ body: shell.shell({++ rail: shell.rail({++ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })),++ dmUnread,++ }),++ side: shell.sideServer({++ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can: { ...can, invite: canInvite }, me: !!me,++ categories: categories.map((c) => ({++ name: c.name,++ channels: c.channels.map((ch) => ({++ kind: ch.kind, name: ch.name, slug: ch.slug, unread: ch.unread,++ href: `/s/${server.slug}/${ch.slug}`, active: ch.slug === pick.slug,++ })),++ })),++ occupants,++ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},++ }),++ sideLabel: server.name,++ main: `${shell.channelHeader({ name: room.name, kind: isVoice ? 'voice' : 'text', topic: room.topic, members: true })}++${invitePanel}++<span class="oc-live" id="oc-live" hidden>● live</span>${aiHere}${errorOf(req)}${isVoice ? callPanel(room, rcan, me) : ''}<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>--${composer}--</section>--<aside class="oc-srv-people" aria-label="People in ${esc(server.name)}"><details open><summary>People <span class="oc-muted">${Number(server.member_count) || 0}</span></summary>${peopleHtml}${people.length >= 300 ? '<p class="oc-muted">And more.</p>' : ''}</details></aside>--</div>`,++${composer}`,++ mainLabel: `${isVoice ? '' : '#'}${room.name}`,++ members: shell.membersColumn({ groups: roleGroups }),++ membersLabel: `People in ${server.name}`,++ }),});}router.get('/s/:slug', serverPage);nnew file mode 100644@@@ -0,0 +1,188 @@++'use strict';++/**++ * The app shell on every page of openvibe.chat (the plan, 2026-11-09, stage V2: "the rail, the side column (Home's++ * DMs and Friends; a server's channels with categories and voice occupants; the you-bar), the members column, the++ * phone panels with swipe and the Shared appbar as the bottom bar; the channel header; the user popout everywhere;++ * buttons as the plan says").++ *++ * Discord's four columns, our data:++ *++ * the rail (72 px) Home (the DMs) and Vibe at the top, then your servers as round icons (their picture, or a++ * generated monogram tile in the server's colour), each with its unread dot, then Add a++ * server and Explore++ * the side column Home: "Find or start a conversation", then Friends, Requests and Vibe, then the DMs++ * A server: its banner and name with a menu (Invite, Settings, Notifications, Leave),++ * Events, the channel categories (collapsible) with the people in each voice channel, and++ * the you-bar at the foot++ * the middle the conversation, or the channel's messages++ * the members column the people, grouped by role, with their presence++ *++ * On a phone the three columns are panels you swipe between (public/web/chat-shell.js) under Shared's app bar.++ * Everything here is server-rendered: the page reads and works without JavaScript, and the script only adds the++ * gestures, the members toggle and the shared user popout.++ */++const { esc } = require('./layout');++const ui = require('./ui');++const { ICONS: IC } = require('./icons');++++/** The five places of the phone's bottom bar: Shared's app bar draws them (appbar.js). */++const APP_BAR = [++ { label: 'Home', href: '/messages', icon: 'fa-comment-dots', page: 'messages' },++ { label: 'Servers', href: '/servers', icon: 'fa-globe', page: 'servers' },++ { label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles', page: 'vibe' },++ { label: 'Activity', href: '/notifications', icon: 'fa-bell', page: 'notifications' },++ { label: 'You', href: '/settings', icon: 'fa-user', page: 'settings' },++];++++/** One icon button: 40 px on a computer, 44 px on a phone (chat-shell.css), never a bare glyph at a random size. */++const iconBtn = (href, label, svg, extra = '') => `<a class="oc-icon-btn" href="${esc(href)}" aria-label="${esc(label)}" title="${esc(label)}"${extra}>${svg}</a>`;++++/**++ * The rail: Home and Vibe, your servers, then Add a server and Explore.++ * o: { servers: [{ slug, name, unread, active }], dmUnread, home, vibe, mine }++ */++function rail(o = {}) {++ const servers = o.servers || [];++ const server = (s) => `<a class="oc-rail-icon${s.active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" title="${esc(s.name)}" style="--oc-hue:${ui.hueOf(s.slug)}"${s.active ? ' aria-current="page"' : ''}><span aria-hidden="true">${esc(ui.serverLetters(s.name))}</span><span class="oc-sr">${esc(s.name)}</span>${s.unread ? `<span class="oc-rail-dot" aria-label="${s.unread} unread">${s.unread > 99 ? '99+' : s.unread}</span>` : ''}</a>`;++ return `<nav class="oc-rail" aria-label="Chat destinations">++<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-label="Open the side column" title="Open the side column">${IC.menu}</button>++<a class="oc-rail-icon oc-rail-home${o.home ? ' is-active' : ''}" href="/messages" title="Home"${o.home ? ' aria-current="page"' : ''}>${IC.chat}<span class="oc-sr">Home</span>${o.dmUnread ? `<span class="oc-rail-dot" aria-label="${o.dmUnread} unread">${o.dmUnread > 99 ? '99+' : o.dmUnread}</span>` : ''}</a>++<a class="oc-rail-icon oc-rail-vibe${o.vibe ? ' is-active' : ''}" href="/vibe" title="Vibe"${o.vibe ? ' aria-current="page"' : ''}>${IC.spark}<span class="oc-sr">Vibe</span></a>++<span class="oc-rail-divider" role="separator" aria-hidden="true"></span>++${servers.map(server).join('')}++<a class="oc-rail-icon oc-rail-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>++<a class="oc-rail-icon oc-rail-explore${o.explore ? ' is-active' : ''}" href="/servers" title="Explore servers"${o.explore ? ' aria-current="page"' : ''}>${IC.search}<span class="oc-sr">Explore servers</span></a>++</nav>`;++}++++/** One person in a list: their mark, their name and a muted line under it. */++function personRow(person, { href = '', extra = '', title = '', sub = '' } = {}) {++ const name = person.display_name || person.username || '?';++ const mark = ui.initials(name, person.presence, { avatarUrl: person.avatar_url });++ const body = `<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(name)}</strong>${title}</span>${sub ? `<span class="oc-row-sub">${sub}</span>` : ''}</span>`;++ const inner = href++ ? `<a href="${esc(href)}"${person.id ? ` data-oc-user="${Number(person.id)}"` : ''}>${mark}${body}</a>`++ : `<span>${mark}${body}</span>`;++ return `<li class="oc-row${person.presence ? ' is-online' : ''}">${inner}${extra}</li>`;++}++++/**++ * The side column on Home: find or start a conversation, then Friends, Requests and Vibe, then the DMs.++ * o: { dms: [{ id, title, preview, at, unread, presence, avatarUrl }], friends, requests, current }++ */++function sideHome(o = {}) {++ const dms = o.dms || [];++ const dmRow = (d) => `<li class="oc-row${d.unread ? ' oc-unread' : ''}"><a href="/messages/${d.id}"${d.current ? ' aria-current="page"' : ''}>${ui.initials(d.title, d.presence, { avatarUrl: d.avatarUrl })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(d.title)}</strong>${d.unread ? `<span class="oc-dot" aria-label="${d.unread} new">${d.unread > 9 ? '9+' : d.unread}</span>` : ''}</span><span class="oc-row-sub">${esc(d.preview || '')}</span></span></a></li>`;++ const friends = o.friends || [];++ const requests = o.requests || [];++ return `<div class="oc-side-top">++<a class="oc-side-find" href="/messages#oc-new">${IC.pencil}<span>Find or start a conversation</span></a>++</div>++<section class="oc-side-sec" aria-labelledby="oc-side-friends"><h2 class="oc-side-group" id="oc-side-friends">Friends <span class="oc-muted">${friends.length}</span></h2>++${friends.length ? `<ul class="oc-side-list">${friends.map((f) => `<li class="oc-row"><form method="post" action="/messages/new"><input type="hidden" name="username" value="${esc(f.username || '')}"><button type="submit" class="oc-side-person">${ui.initials(f.display_name || f.username || '?', f.presence, { avatarUrl: f.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(f.display_name || f.username || '?')}</strong></span><span class="oc-row-sub">@${esc(f.username || '')}</span></span></button></form></li>`).join('')}</ul>`++ : `<p class="oc-side-empty oc-muted">No friends yet. Start a conversation and add the people you talk to.</p>`}++<form class="oc-side-add" method="post" action="/messages/contacts"><label class="oc-sr" for="oc-add-friend">Add a friend by username</label><input id="oc-add-friend" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add a friend by username" autocomplete="off"><button type="submit">Add</button></form>++</section>++<section class="oc-side-sec" aria-labelledby="oc-side-requests"><h2 class="oc-side-group" id="oc-side-requests">Requests <span class="oc-muted">${requests.length}</span></h2>++${requests.length ? `<ul class="oc-side-list">${requests.map((r) => personRow(r, { extra: `<form class="oc-inline" method="post" action="/messages/contacts/${Number(r.id)}/accept"><button type="submit" class="oc-link">Accept</button></form>` })).join('')}</ul>`++ : `<p class="oc-side-empty oc-muted">No requests.</p>`}++</section>++<section class="oc-side-sec" aria-labelledby="oc-side-vibe"><h2 class="oc-side-group" id="oc-side-vibe">Vibe</h2>++<ul class="oc-side-list"><li class="oc-row"><a href="/vibe">${IC.spark}<span class="oc-row-main"><span class="oc-row-top"><strong>Vibe</strong></span><span class="oc-row-sub">OpenVibe's assistant</span></span></a></li></ul>++</section>++<section class="oc-side-sec" aria-labelledby="oc-side-dms"><h2 class="oc-side-group" id="oc-side-dms">Direct messages</h2>++${dms.length ? `<ul class="oc-side-list">${dms.map(dmRow).join('')}</ul>` : `<p class="oc-side-empty oc-muted">No conversations yet.</p>`}++</section>++${youBar(o.you || {})}`;++}++++/**++ * A voice channel with the people in it (the plan: "voice channels with who is in them").++ * o: { name, href, occupants: [{ display_name, username }], active }++ */++function voiceRow(o) {++ const occupants = (o.occupants || []).slice(0, 4);++ const names = occupants.map((p) => esc(p.display_name || p.username || '?'));++ return `<li><a class="oc-chan oc-chan-voice${o.active ? ' is-active' : ''}" href="${esc(o.href)}"${o.active ? ' aria-current="page"' : ''}>${IC.voice}<span class="oc-chan-name">${esc(o.name)}</span></a>++${names.length ? `<ul class="oc-voice-people">${names.map((n) => `<li>${n}</li>`).join('')}</ul>` : ''}</li>`;++}++++/**++ * The side column in a server: its banner and name with a menu, Events, the channel categories with the people in++ * each voice channel, and the you-bar.++ * o: { server, can, me, categories: [{ name, channels: [{ kind, name, href, slug, active, unread }] }], occupants:++ * { slug: [{ display_name }] }, events, you }++ */++function sideServer(o = {}) {++ const server = o.server || {};++ const can = o.can || {};++ const me = !!o.me;++ const categories = o.categories || [];++ const menu = [];++ if (can.invite) menu.push(`<a class="oc-pop-item" href="/s/${esc(server.slug)}/settings#oc-invites">${IC.members}<span>Invite people</span></a>`);++ if (can.manage || can.moderate) menu.push(`<a class="oc-pop-item" href="/s/${esc(server.slug)}/settings">${IC.gear}<span>Server settings</span></a>`);++ if (can.member && can.role !== 'owner') menu.push(`<form method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-pop-item">${IC.leave}<span>Leave server</span></button></form>`);++ const cat = (c) => `<section class="oc-side-sec oc-side-cat"><details open><summary class="oc-side-group">${esc(c.name)}</summary>++<ul class="oc-side-list">${c.channels.map((ch) => (ch.kind === 'voice'++ ? voiceRow({ name: ch.name, href: ch.href, active: ch.active, occupants: (o.occupants || {})[ch.slug] || [] })++ : `<li><a class="oc-chan${ch.active ? ' is-active' : ''}${ch.unread ? ' is-unread' : ''}" href="${esc(ch.href)}"${ch.active ? ' aria-current="page"' : ''}><span class="oc-chan-hash" aria-hidden="true">#</span><span class="oc-chan-name">${esc(ch.name)}</span>${ch.unread ? `<span class="oc-chan-dot" aria-label="${ch.unread} unread">${ch.unread > 99 ? '99+' : ch.unread}</span>` : ''}</a></li>`)).join('')}</ul></details></section>`;++ return `<header class="oc-side-head">++<h1 class="oc-side-name">${esc(server.name || '')}</h1>${server.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${server.visibility === 'private' ? `<span class="oc-badge">${IC.lock}<span class="oc-sr">private</span></span>` : ''}++<p class="oc-muted oc-side-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${can.role ? ` · you are ${esc(can.role)}` : ''}</p>++${server.description ? `<p class="oc-side-desc">${esc(server.description)}</p>` : ''}++${menu.length ? `<details class="oc-more oc-side-more"><summary class="oc-icon-btn" aria-label="Server menu" title="Server menu">${IC.more}</summary><div class="oc-pop">${menu.join('')}</div></details>` : ''}++</header>++<a class="oc-side-event" href="/s/${esc(server.slug || '')}/events">${IC.clock}<span>Events</span></a>++${can.member ? '' : me ? `<form class="oc-side-join" method="post" action="/s/${esc(server.slug || '')}/join"><button type="submit">Join ${esc(server.name || 'this server')}</button></form>` : `<p class="oc-side-join"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/s/${server.slug || ''}`)}">Sign in to join</a></p>`}++${categories.map(cat).join('')}++${youBar(o.you || {})}`;++}++++/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */++function youBar(o = {}) {++ if (!o.name) return '';++ return `<div class="oc-you"><a class="oc-you-id" href="/settings">${ui.initials(o.name, o.presence, { avatarUrl: o.avatarUrl })}<span class="oc-you-names"><strong>${esc(o.name)}</strong><span class="oc-muted">${esc(o.status || 'Online')}</span></span></a>++<span class="oc-you-tools"><a class="oc-icon-btn" href="/settings#oc-sound" aria-label="Mute" title="Mute">${IC.alert}</a><a class="oc-icon-btn" href="/settings" aria-label="Settings" title="Settings">${IC.gear}</a></span></div>`;++}++++/**++ * The members column: the people, grouped by role, with their presence.++ * o: { groups: [{ role, title, people: [] }], label }++ */++function membersColumn(o = {}) {++ const groups = o.groups || [];++ return `<div class="oc-members-head"><h2 class="oc-side-group">People</h2><button type="button" class="oc-icon-btn oc-members-close" data-oc-panel="main" aria-label="Hide the people" title="Hide the people">${IC.back}</button></div>++${groups.map((g) => `<section class="oc-members-sec"><h3 class="oc-side-group">${esc(g.title)} <span class="oc-muted">${g.people.length}</span></h3>++<ul class="oc-side-list">${g.people.map((p) => `<li class="oc-row oc-member-row${p.presence ? ' is-online' : ''}"><a href="${p.href || '#'}"${p.id ? ` data-oc-user="${Number(p.id)}"` : ''}>${ui.initials(p.display_name || p.username || '?', p.presence, { avatarUrl: p.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong${p.color ? ` style="--nc:${esc(p.color)}"` : ''}>${esc(p.display_name || p.username || '?')}</strong></span><span class="oc-row-sub">${esc(p.username ? `@${p.username}` : '')}</span></span></a></li>`).join('')}</ul></section>`).join('')}`;++}++++/**++ * The channel header: the channel's name and topic, and the buttons that work (the people, the server's menu).++ * o: { name, kind, topic, href, members, active }++ */++function channelHeader(o = {}) {++ const kind = o.kind === 'voice' ? IC.voice : '<span class="oc-chan-hash" aria-hidden="true">#</span>';++ return `<header class="oc-chan-head">++<h2 id="oc-chan-h" class="oc-chan-title">${kind}${esc(o.name || '')}</h2>++${o.topic ? `<span class="oc-muted oc-chan-topic">${esc(o.topic)}</span>` : ''}++<span class="oc-chan-tools">++<button type="button" class="oc-icon-btn oc-members-toggle" data-oc-panel="members" aria-label="Show the people" title="People" aria-pressed="false">${IC.members}</button>++${o.back ? iconBtn(o.back.href, o.back.label, IC.back) : ''}++</span></header>`;++}++++/**++ * The shell itself: the rail, the side column, the middle and the members column. On a phone the columns are++ * panels (public/web/chat-shell.js sets data-oc-panel).++ * o: { rail, side, sideLabel, main, members, membersLabel }++ */++function shell(o = {}) {++ return `<div class="oc-app" data-oc-app data-oc-panel="main">++${o.rail || ''}++${o.side ? `<aside class="oc-side" aria-label="${esc(o.sideLabel || 'Chat')}">${o.side}</aside>` : ''}++<section class="oc-col oc-col-main" aria-label="${esc(o.mainLabel || 'Conversation')}">${o.page ? `<div class="oc-page">${o.main || ''}</div>` : (o.main || '')}</section>++${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersLabel || 'People')}">${o.members}</aside>` : ''}++</div>`;++}++++module.exports = { APP_BAR, rail, sideHome, sideServer, membersColumn, channelHeader, youBar, shell, personRow };@@@ -76,7 +76,7 @@ function presenceLine(presence, { liveHref = null } = {}) {return `${shown}${presence.status_text ? ` · ${esc(String(presence.status_text).slice(0, 80))}` : ''}`;}--function initials(name, presence = null, { large = false, id = '' } = {}) {++function initials(name, presence = null, { large = false, id = '', avatarUrl = '' } = {}) {const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();let h = 0;@@@ -84,8 +84,11 @@ function initials(name, presence = null, { large = false, id = '' } = {}) {const cls = `oc-av${large ? ' oc-av-lg' : ''}`;const idAttr = id ? ` id="${esc(id)}"` : '';const state = presenceState(presence);-- if (!state) return `<span class="${cls}" style="--av:${h}"${idAttr} aria-hidden="true">${esc(letters)}</span>`;-- return `<span class="${cls}" style="--av:${h}"${idAttr} role="img" aria-label="${esc(`${String(name || '?')}, ${PRESENCE[state].a11y}`)}">${esc(letters)}${presenceDot(presence)}</span>`;++ let photo = '';++ try { const u = new URL(String(avatarUrl || '')); if (u.protocol === 'https:' && u.hostname.toLowerCase() === 'openvibe.media') photo = `<img src="${esc(u.href)}" alt="" loading="lazy" referrerpolicy="no-referrer">`; } catch { /* initials are the fallback */ }++ const content = photo || esc(letters);++ if (!state) return `<span class="${cls}" style="--av:${h}"${idAttr} aria-hidden="true">${content}</span>`;++ return `<span class="${cls}" style="--av:${h}"${idAttr} role="img" aria-label="${esc(`${String(name || '?')}, ${PRESENCE[state].a11y}`)}">${content}${presenceDot(presence)}</span>`;}/** An https address a card may link to; anything else (javascript:, http:, a relative path) is no link. */@@@ -34,7 +34,7 @@ t('export, then erase and confirm', async () => {{const missing = await uncovered(d, {taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',-- 'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions',++ 'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions','inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions','user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],exempt: {@@@ -30,7 +30,11 @@ const navLabel = (page, key) => {t('boot', async () => {h = await boot({ env: { CHAT_WEB_URL: SITE } });alice = h.addUser('alice', { subject: ids.newId('user') });++ alice.avatar_url = 'https://openvibe.media/fixture/alice.png';++ h.live.users.get(alice.id).avatar_url = alice.avatar_url;bob = h.addUser('bob', { subject: ids.newId('user') });++ bob.avatar_url = 'https://openvibe.media/fixture/bob.png';++ h.live.users.get(bob.id).avatar_url = bob.avatar_url;carol = h.addUser('carol', { subject: ids.newId('user') });for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }});@@@ -46,6 +50,7 @@ t('the inbox: your conversations only, unread counts in the list and the navigatassert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');++ assert.ok(inbox.text.includes('src="https://openvibe.media/fixture/alice.png"'), 'DM list rows use the person picture from Network');const carolInbox = await req('GET', '/messages', { headers: cookie(carol) });assert.ok(!carolInbox.text.includes(`/messages/${conv}`), 'not in anyone else\'s inbox');assert.strictEqual(navLabel(carolInbox.text, '/messages'), 'Messages');@@@ -57,6 +62,11 @@ t('the inbox: your conversations only, unread counts in the list and the navigatassert.ok(!/\d+ new<\/span>/.test(inbox.text));assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages');assert.strictEqual((await dmApi('GET', '/unread', bob)).body.unread, 0);++ const defaultView = await dmApi('GET', `/conversations/${conv}/view`, bob);++ assert.strictEqual(defaultView.body.view, 'bubbles', 'one-to-one conversations default to bubbles');++ assert.strictEqual((await dmApi('PUT', `/conversations/${conv}/view`, bob, { view: 'room' })).body.view, 'room');++ assert.match((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).text, /oc-feed oc-room-feed" id="oc-feed"/);++ assert.strictEqual((await dmApi('GET', `/conversations/${conv}/view`, bob)).body.view, 'room', 'view choice is saved per person and conversation');// Rooms count too.await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Book Club' } });@@@ -30,6 +30,7 @@ const TABLES = ['chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases','chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',++ 'dm_contacts', 'dm_view_defaults', 'dm_view_preferences','chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates','agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties','room_sync_clocks', 'room_control_leases',@@@ -40,6 +41,7 @@ const INDEXES = ['idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup','idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent','idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',++ 'idx_dm_contacts_user','chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',];const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',@@@ -171,10 +171,15 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<chaassert.doesNotMatch(r.text, /Ann and Bob/, 'private servers are not listed');r = await page('/s/saturday-raiders', dee);assert.strictEqual(r.status, 200);-- assert.match(r.text, /class="oc-srv"/);++ assert.match(r.text, /class="oc-app"/, 'the app shell');++ assert.match(r.text, /aria-label="Chat destinations"/);++ assert.match(r.text, /title="Home"/);++ assert.match(r.text, /title="Vibe"/);++ assert.match(r.text, /class="oc-col oc-col-members"/, 'the members column');assert.match(r.text, /aria-current="page"[^>]*>.*general/s);assert.match(r.text, /Voice channels/);-- assert.match(r.text, /<h3 class="oc-srv-group">Owner/);++ assert.match(r.text, /<h3 class="oc-side-group">Owner/);++ assert.match(r.text, /class="oc-you"/, 'the you-bar');assert.match(r.text, /id="oc-compose"/, 'a member writes');assert.doesNotMatch(r.text, /Server settings/, 'a member sees no settings');r = await page('/s/saturday-raiders/lounge', null);@@@ -194,7 +199,10 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<chaassert.strictEqual(r.status, 303);assert.match((await page('/s/saturday-raiders/general', dee)).text, /from the form/);r = await page('/servers/new', cat, { method: 'POST', form: { name: 'Book Club', private: '1' } });-- assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);++ assert.strictEqual(r.status, 303);++ assert.match(r.location, /^\/s\/book-club\?invite=[a-z0-9]+$/, 'a new server ends with its invite link');++ const newCode = new URL(r.location, 'http://x').searchParams.get('invite');++ assert.match((await page(`/s/book-club?invite=${newCode}`, cat)).text, /Your invite link/);const code = (await api('POST', '/book-club/invites', cat, {})).body.invite.code;r = await page(`/join/${code}`, dee);assert.match(r.text, /You are invited to/);@@@ -230,9 +238,14 @@ t('the official server: only chat staff take /s/openvibe; it is marked officialassert.strictEqual(list[0].slug, 'openvibe', 'listed first, whatever its size');assert.ok(list.slice(1).every((x) => x.official === false), 'no other server is official');assert.strictEqual((await api('POST', '/', staff, { name: 'Explore', slug: 'explore' })).status, 422, 'the other reserved words stay reserved, even for staff');-- assert.match((await page('/servers', null)).text, /<strong>OpenVibe<\/strong><span class="oc-badge oc-badge-official">official<\/span>/);++ const explore = (await page('/servers', null)).text;++ assert.match(explore, /<h3>OpenVibe <span class="oc-badge oc-badge-official">official<\/span>/, 'Explore shows the official server as a card');++ assert.match(explore, /class="oc-chips"/, 'the categories');++ assert.match(explore, /Gaming/, 'a category chip');++ assert.match(explore, /Start a server/, 'the card that starts one');++ assert.doesNotMatch(explore, /No other public server yet\./, 'that line is never shown');const official = (await page('/s/openvibe', null)).text;-- assert.match(official, /oc-srv-name">OpenVibe<\/h1><span class="oc-badge oc-badge-official">official<\/span>/);++ assert.match(official, /oc-side-name">OpenVibe<\/h1><span class="oc-badge oc-badge-official">official<\/span>/);const canonical = (/<link rel="canonical" href="([^"]*)"/.exec(official) || [])[1] || '';assert.match(canonical, /\/s\/openvibe$/, 'canonical is the URL served and listed in the sitemap, not the default channel');assert.ok(!canonical.includes('/s/openvibe/general'), 'no canonical to the channel the server happens to default to');nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * The app shell (the plan, 2026-11-09, stage V2) and Explore (stage V3).++ *++ * The shell: the rail (Home, Vibe, your servers, Add, Explore), the side column (Home's DMs and Friends; a++ * server's channels with their categories and the people in each voice channel, and the you-bar), the members++ * column grouped by role, the phone's bottom bar (Shared's app bar), and the shared user popout on every name.++ *++ * Explore: the category chips, the featured servers as cards, the Start sheet with its templates, and a server++ * that starts with the template's channels and ends with its invite link.++ *++ * Friends and requests: the people you talk to, on the side column and over the API.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('shell');++let h, dee, bob, cat;++const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });++const dmApi = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });++const page = async (path, u) => {++ const headers = u ? { cookie: `ov_token=${u.token}` } : {};++ const r = await fetch(`${h.base}${path}`, { headers });++ return { status: r.status, text: await r.text() };++};++++t('boot', async () => {++ h = await boot();++ dee = h.addUser('dee', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ for (const u of [dee, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('the shell: the rail, the side column, the members column and the bottom bar', async () => {++ const made = await api('POST', '/', dee, { name: 'Saturday Raiders', description: 'Raids on Saturdays' });++ const slug = made.body.server.slug;++ await api('POST', `/${slug}/channels`, dee, { name: 'planning' });++ await api('POST', `/${slug}/channels`, dee, { name: 'Lounge', kind: 'voice' });++ await api('POST', `/${slug}/join`, bob);++ const r = await page(`/s/${slug}`, dee);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /class="oc-app"/, 'the shell');++ assert.match(r.text, /<nav class="oc-rail" aria-label="Chat destinations">/, 'the rail');++ assert.match(r.text, /title="Home"/);++ assert.match(r.text, /title="Vibe"/);++ assert.match(r.text, /class="oc-rail-icon oc-rail-add" href="\/servers#oc-new-server"/, 'Add a server');++ assert.match(r.text, /class="oc-rail-icon oc-rail-explore"/, 'Explore');++ assert.match(r.text, /class="oc-side"/, 'the side column');++ assert.match(r.text, /Text channels/, 'the channel categories');++ assert.match(r.text, /Voice channels/);++ assert.match(r.text, /class="oc-you"/, 'the you-bar');++ assert.match(r.text, /class="oc-col oc-col-members"/, 'the members column');++ assert.match(r.text, /<h3 class="oc-side-group">Owner/, 'the people, grouped by role');++ assert.match(r.text, /data-oc-user="\d+"/, 'every name opens the shared user popout');++ // The phone's bottom bar is Shared's app bar (five places).++ assert.match(r.text, /"label":"Home","href":"\/messages"/);++ assert.match(r.text, /"label":"Servers","href":"\/servers"/);++ assert.match(r.text, /"label":"Vibe","href":"\/vibe"/);++ assert.match(r.text, /"label":"Activity","href":"\/notifications"/);++ assert.match(r.text, /"label":"You","href":"\/settings"/);++});++++t('Home: the DMs and the people you talk to down the side', async () => {++ const conv = await dmApi('POST', '/conversations', dee, { user_ids: [bob.id] });++ const id = (conv.body.conversation && conv.body.conversation.id) || conv.body.id;++ await dmApi('POST', `/conversations/${id}/messages`, dee, { message: 'Saturday?' });++ const r = await page('/messages', dee);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /id="oc-side-dms"/, 'the DMs in the side column');++ assert.match(r.text, /id="oc-side-friends"/, 'Friends');++ assert.match(r.text, /id="oc-side-requests"/, 'Requests');++ assert.match(r.text, /Find or start a conversation/);++ assert.match(r.text, /Saturday\\?/, 'the last line of the conversation');++});++++t('friends and requests: add, list, accept and remove', async () => {++ const added = await dmApi('POST', '/contacts', dee, { username: 'bob' });++ assert.strictEqual(added.status, 200);++ assert.strictEqual(added.body.status, 'outgoing');++ const list = await dmApi('GET', '/contacts', dee);++ assert.strictEqual(list.status, 200);++ assert.strictEqual(list.body.requests.length, 0, 'the request waits for them');++ assert.strictEqual(list.body.asked.length, 1, 'and you see that you asked');++ const theirs = await dmApi('GET', '/contacts', bob);++ assert.strictEqual(theirs.body.requests.length, 1, 'and they see the request');++ assert.strictEqual((await dmApi('POST', `/contacts/${dee.id}/accept`, bob)).status, 200);++ const friends = await dmApi('GET', '/contacts', dee);++ assert.strictEqual(friends.body.friends.length, 1, 'both are friends now');++ assert.match((await page('/messages', dee)).text, /id="oc-side-friends"/);++ assert.strictEqual((await dmApi('DELETE', `/contacts/${bob.id}`, dee)).status, 200);++ assert.strictEqual((await dmApi('GET', '/contacts', dee)).body.friends.length, 0);++ // A blocked person is never a friend.++ await dmApi('POST', `/blocks/${bob.id}`, dee);++ assert.strictEqual((await dmApi('POST', '/contacts', dee, { username: 'bob' })).status, 403);++});++++t('Explore: the chips, the cards and the Start sheet', async () => {++ const r = await page('/servers', dee);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /class="oc-chips"/);++ for (const c of ['Gaming', 'Streams', 'Dev', 'Art', 'Music', 'Study', 'AI']) assert.match(r.text, new RegExp(`>${c}</a>`), `the ${c} chip`);++ assert.match(r.text, /oc-server-card/, 'the servers as cards');++ assert.match(r.text, /id="oc-new-server"/, 'the Start sheet');++ for (const [id, name] of [['friends', 'Friends'], ['gaming', 'Gaming group'], ['streams', 'Stream community'], ['study', 'Study group'], ['creators', 'Creators'], ['blank', 'Blank']]) {++ assert.match(r.text, new RegExp(`value="${id}"`), `the ${name} template`);++ }++ assert.match(r.text, /name="visibility" value="public"/);++ assert.match(r.text, /name="visibility" value="private"/);++ assert.doesNotMatch(r.text, /No other public server yet\\./);++ // A search and a category filter.++ assert.match(r.text, /name="q"/);++ assert.match((await page('/servers?category=Gaming', dee)).text, /class="oc-chip is-active"[^>]*>Gaming</);++});++++t('a server starts from a template and ends with its invite link', async () => {++ const form = new URLSearchParams({ name: 'Book Club', template: 'gaming', category: 'Study', visibility: 'public' });++ const res = await fetch(`${h.base}/servers/new`, { method: 'POST', headers: { cookie: `ov_token=${dee.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: form.toString(), redirect: 'manual' });++ assert.strictEqual(res.status, 303);++ const location = res.headers.get('location');++ assert.match(location, /^\/s\/book-club\?invite=[a-z0-9]+$/, 'the invite link at the end');++ const slug = location.split('/')[2].split('?')[0];++ const server = await api('GET', `/${slug}`, dee);++ const names = server.body.channels.map((c) => c.name).sort();++ assert.deepStrictEqual(names, ['clips', 'general', 'looking-for-group', 'voice'], 'the template\'s channels');++ assert.strictEqual(server.body.server.category, 'Study');++ assert.strictEqual(server.body.server.template, 'gaming');++ const shown = await page(location, dee);++ assert.match(shown.text, /Your invite link/, 'the invite is shown');++ assert.match(shown.text, /\/join\/[a-z0-9]+/);++});++++t.run(async () => { if (h && h.close) await h.close(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- A person's saved presentation choice for each DM conversation.
+CREATE TABLE IF NOT EXISTS dm_view_preferences (
+ user_id bigint NOT NULL,
+ conversation_id bigint NOT NULL REFERENCES dm_conversations(id) ON DELETE CASCADE,
+ view text COLLATE "C" NOT NULL CHECK (view IN ('bubbles', 'room')),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (user_id, conversation_id)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- The app shell (the plan, 2026-11-09, stage V2): a channel's category in the side column, and the people you
+-- talk to (Friends and Requests on Home).
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS category text COLLATE "C";
+
+CREATE TABLE IF NOT EXISTS dm_contacts (
+ user_id bigint NOT NULL,
+ contact_id bigint NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'friend' CHECK (status IN ('friend', 'outgoing', 'incoming')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (user_id, contact_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_dm_contacts_user ON dm_contacts(user_id);
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- The default view for each kind of conversation (bubbles or a chat room): the plan, 2026-11-09, stage V1.
+CREATE TABLE IF NOT EXISTS dm_view_defaults (
+ user_id bigint NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('one_to_one', 'ai', 'group', 'channel')),
+ view text COLLATE "C" NOT NULL CHECK (view IN ('bubbles', 'room')),
+ PRIMARY KEY (user_id, kind)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Explore (the plan, 2026-11-09, stage V3): a server's category, its picture, and the template it started from.
+ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS category text COLLATE "C";
+ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS picture_url text;
+ALTER TABLE chat_servers ADD COLUMN IF NOT EXISTS template text COLLATE "C";
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
+/* openvibe.chat — the app shell (the plan, 2026-11-09, stage V2): the rail, the side column, the members column and
+ the phone panels. Discord's four columns, our data; the look is Opus's (openvibe.design), this is the structure.
+ One icon button size: 40 px on a computer, 44 px on a phone. One primary style. Labels beside icons where there
+ is room. */
+
+.oc-app { display: grid; grid-template-columns: 72px 240px minmax(0, 1fr) 0; height: calc(100dvh - var(--ovnav-h, 56px)); min-height: 420px; background: var(--oc-panel); overflow: hidden; }
+.oc-app.is-members { grid-template-columns: 72px 240px minmax(0, 1fr) 240px; }
+.oc-app > .oc-col-members { overflow: hidden; border-left: 0; }
+.oc-app.is-members > .oc-col-members { overflow-y: auto; border-left: 1px solid var(--oc-line); }
+
+/* ── The rail (72 px): Home, Vibe, your servers, then Add and Explore ── */
+.oc-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }
+.oc-rail::-webkit-scrollbar { display: none; }
+.oc-rail-icon { position: relative; display: grid; place-items: center; width: 48px; height: 48px; flex: 0 0 auto; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 800; font-size: .95rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }
+.oc-rail-icon:hover, .oc-rail-icon:focus-visible { border-radius: 16px; }
+.oc-rail-icon.is-active { border-radius: 16px; background: var(--oc-panel-2); }
+.oc-rail .oc-rail-icon.is-active::before { content: ''; position: absolute; left: -12px; top: 10px; bottom: 10px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }
+.oc-rail-home, .oc-rail-vibe { background: var(--oc-panel-2); }
+.oc-rail-home .oc-i, .oc-rail-vibe .oc-i { width: 22px; height: 22px; }
+.oc-rail-divider { width: 32px; height: 2px; flex: 0 0 2px; border-radius: 2px; background: var(--oc-line); }
+.oc-rail-dot { position: absolute; right: -6px; bottom: -4px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; }
+.oc-rail-add { background: var(--oc-panel-2); color: var(--oc-ok); font-size: 1.5rem; font-weight: 500; }
+.oc-rail-explore { background: var(--oc-panel-2); color: var(--oc-accent); }
+.oc-rail-add:hover, .oc-rail-explore:hover { background: var(--oc-panel); }
+.oc-rail-explore .oc-i { width: 20px; height: 20px; }
+
+/* On a phone the side column opens from the rail: this button. */
+.oc-rail-menu { display: none; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border: 0; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); cursor: pointer; }
+.oc-rail-menu .oc-i { width: 20px; height: 20px; }
+
+/* ── The side column (240 px) ── */
+.oc-side { display: flex; flex-direction: column; min-width: 0; padding: 12px 8px 0; overflow-y: auto; border-right: 1px solid var(--oc-line); background: var(--oc-bg); }
+.oc-side-top { padding: 0 0 10px; }
+.oc-side-find { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 8px 10px; border-radius: 10px; background: var(--oc-panel-2); color: var(--oc-text); text-decoration: none; font-size: .92rem; }
+.oc-side-find:hover { background: var(--oc-panel); }
+.oc-side-find .oc-i { width: 18px; height: 18px; color: var(--oc-muted); }
+.oc-side-sec { margin-bottom: 12px; }
+.oc-side-group { display: flex; align-items: center; gap: 6px; margin: 0; padding: 8px 8px 4px; font-size: .72rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--oc-muted); }
+.oc-side-group > summary { cursor: pointer; list-style: none; }
+.oc-side-group > summary::-webkit-details-marker { display: none; }
+.oc-side-cat details > summary::after { content: '▾'; margin-left: auto; font-size: .8rem; }
+.oc-side-cat details:not([open]) > summary::after { content: '▸'; }
+.oc-side-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }
+.oc-side-list .oc-row > a, .oc-side-list .oc-row > span { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 8px; color: var(--oc-text); text-decoration: none; }
+.oc-side-list .oc-row > a:hover { background: var(--oc-panel-2); }
+.oc-side-list .oc-row-main { min-width: 0; }
+.oc-side-list .oc-row-top { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.oc-side-list .oc-row-top strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-side-list .oc-row-sub { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .8rem; }
+.oc-side-empty { margin: 0; padding: 2px 8px 6px; font-size: .84rem; }
+.oc-side-head { padding: 0 6px 8px; }
+.oc-side-name { font-size: 1.05rem; line-height: 1.3; margin: 0 0 2px; overflow-wrap: anywhere; }
+.oc-side-head .oc-badge { margin-left: 0; }
+.oc-side-meta { margin: 2px 0 0; font-size: .82rem; }
+.oc-side-desc { margin: 6px 0 0; font-size: .86rem; color: var(--oc-muted); }
+.oc-side-more { position: absolute; right: 8px; top: 8px; }
+.oc-side-event { display: flex; align-items: center; gap: 8px; min-height: 40px; margin: 0 0 6px; padding: 6px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; font-size: .9rem; }
+.oc-side-event:hover { background: var(--oc-panel-2); color: var(--oc-text); }
+.oc-side-event .oc-i { width: 18px; height: 18px; }
+
+/* Channels: text (#) and voice (a speaker), with the people in each voice channel. */
+.oc-chan { display: flex; align-items: center; gap: 7px; min-height: 36px; padding: 4px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; }
+.oc-chan:hover, .oc-chan:focus-visible { background: var(--oc-panel-2); color: var(--oc-text); }
+.oc-chan.is-active { background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-text); }
+.oc-chan.is-unread { color: var(--oc-text); font-weight: 700; }
+.oc-chan-hash { width: 16px; text-align: center; font-weight: 700; opacity: .75; }
+.oc-chan-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-chan .oc-i { width: 16px; height: 16px; }
+.oc-voice-people { list-style: none; margin: 0 0 4px; padding: 0 8px 0 32px; }
+.oc-voice-people li { font-size: .8rem; color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* The you-bar at the foot of the side column. */
+.oc-you { position: sticky; bottom: 0; display: flex; align-items: center; gap: 8px; margin-top: auto; padding: 8px; border-top: 1px solid var(--oc-line); background: var(--oc-bg); }
+.oc-you-id { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--oc-text); text-decoration: none; }
+.oc-you-names { display: grid; min-width: 0; }
+.oc-you-names strong { font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-you-names .oc-muted { font-size: .74rem; }
+.oc-you-tools { display: flex; gap: 2px; margin-left: auto; }
+
+/* ── The members column ── */
+.oc-members-head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 0; }
+.oc-members-sec { margin-bottom: 10px; }
+.oc-member-row strong { color: color-mix(in oklab, var(--nc, var(--oc-text)) 50%, var(--oc-text)); }
+
+/* The shell fills the page: no gutter, no card. */
+.oc-main--shell { max-width: none; padding: 0; margin: 0; }
+
+/* ── The middle column ── */
+.oc-col-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: color-mix(in srgb, var(--oc-panel-2) 60%, var(--oc-panel)); }
+.oc-chan-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 10px 16px; border-bottom: 1px solid var(--oc-line); }
+.oc-chan-title { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 1.04rem; }
+.oc-chan-tools { display: flex; align-items: center; gap: 4px; margin-left: auto; }
+.oc-col-main > .oc-notice, .oc-col-main > .oc-ai-here { margin: 10px 18px 0; }
+.oc-col-main > .oc-call { margin: 12px 18px 0; }
+.oc-col-main > .oc-compose, .oc-col-main > .oc-signin, .oc-col-main > form, .oc-col-main > .oc-dock { margin: 0; padding: 12px 18px 16px; border-top: 1px solid var(--oc-line); }
+/* A conversation's head (a DM) sits like a channel's: inside the gutter, a line under it. */
+.oc-col-main > .oc-page-head { margin: 0; padding: 10px 18px; border-bottom: 1px solid var(--oc-line); }
+.oc-side-join { margin: 0 0 8px; }
+.oc-side-join button, .oc-side-join .oc-button { width: 100%; text-align: center; }
+
+/* One icon button size: 40 px on a computer, 44 px on a phone. */
+.oc-icon-btn { display: inline-grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: transparent; color: var(--oc-muted); text-decoration: none; cursor: pointer; border: 0; }
+.oc-icon-btn:hover { background: var(--oc-panel-2); color: var(--oc-text); }
+.oc-icon-btn .oc-i { width: 20px; height: 20px; }
+
+/* A page in the middle column (Explore, the Messages list): it scrolls on its own, inside the page gutter. */
+.oc-page { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 18px 24px 28px; }
+.oc-page > .oc-page-head:first-child { margin-top: 0; }
+.oc-page > .oc-list { margin-left: -8px; margin-right: -8px; }
+
+/* ── The phone: the columns are panels you swipe between ── */
+@media (max-width: 760px) {
+ /* The shell takes what the navbar and the app bar leave, so a channel fills the screen and its composer sits at the
+ bottom, above the app bar (the body keeps --ovnav-appbar-h of padding for it). */
+ .oc-app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
+ height: calc(100dvh - var(--ovnav-h, 56px) - var(--ovnav-appbar-h, 0px)); min-height: 360px; }
+ .oc-page { padding: 14px 16px 20px; }
+ .oc-app.is-members { grid-template-columns: minmax(0, 1fr); }
+ .oc-rail-menu { display: grid; }
+ .oc-app > .oc-rail { flex-direction: row; justify-content: flex-start; gap: 10px; padding: 8px 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); min-width: 0; }
+ .oc-app > .oc-side, .oc-app > .oc-col-main, .oc-app > .oc-col-members { min-width: 0; }
+ .oc-app .oc-rail .oc-rail-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -8px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }
+ .oc-app > .oc-side, .oc-app > .oc-col-members { display: none; }
+ .oc-app[data-oc-panel="side"] > .oc-side { display: flex; border-right: 0; }
+ .oc-app[data-oc-panel="members"] > .oc-col-members { display: block; border-left: 0; }
+ .oc-app[data-oc-panel="side"] > .oc-col-main, .oc-app[data-oc-panel="members"] > .oc-col-main { display: none; }
+ .oc-icon-btn { width: 44px; height: 44px; }
+ .oc-col-main > .oc-page-head, .oc-col-main > .oc-chan-head { padding-left: 12px; padding-right: 12px; }
+ .oc-col-main > .oc-compose, .oc-col-main > .oc-dock, .oc-col-main > .oc-signin { padding-left: 12px; padding-right: 12px; }
+ .oc-chan { min-height: 44px; }
+ .oc-side-list .oc-row > a, .oc-side-list .oc-row > span { min-height: 44px; }
+}
+
+/* A friend row is a button that opens the conversation; the form posts the username. */
+.oc-side-list .oc-row form { width: 100%; }
+.oc-side-person { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px; padding: 4px 8px; border: 0; border-radius: 8px; background: transparent; color: var(--oc-text); text-align: left; cursor: pointer; font: inherit; }
+.oc-side-person:hover { background: var(--oc-panel-2); }
+.oc-side-add { display: flex; gap: 6px; margin: 6px 0 0; }
+.oc-side-add input { flex: 1; min-width: 0; min-height: 40px; }
+.oc-side-add button { min-height: 40px; }
+
+/* ── Explore: the cards, the chips and the Start sheet ── */
+.oc-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 14px; }
+.oc-chip { display: inline-flex; align-items: center; min-height: 36px; padding: 6px 14px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); text-decoration: none; font-size: .9rem; }
+.oc-chip:hover { border-color: var(--oc-accent); color: var(--oc-text); }
+.oc-chip.is-active { background: var(--oc-accent); border-color: var(--oc-accent); color: var(--oc-on-accent); font-weight: 700; }
+.oc-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
+.oc-card { display: flex; flex-direction: column; gap: 6px; padding: 0 0 14px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); overflow: hidden; }
+.oc-card-banner { height: 64px; background: linear-gradient(120deg, hsl(var(--oc-hue, 215) 52% 34%), hsl(var(--oc-hue, 215) 52% 22%)); }
+.oc-card .oc-srv-icon { margin: -24px 0 0 14px; border: 3px solid var(--oc-panel); }
+.oc-card h3 { margin: 4px 14px 0; font-size: 1.02rem; }
+.oc-card-line { margin: 0 14px; font-size: .88rem; color: var(--oc-muted); }
+.oc-card-meta { margin: 0 14px; font-size: .8rem; }
+.oc-card form, .oc-card > .oc-button { margin: 6px 14px 0; }
+.oc-card form button { width: 100%; }
+.oc-card > .oc-button { text-align: center; }
+.oc-server-card--new { justify-content: center; border-style: dashed; }
+
+/* The sheet: a template, a name and a picture, public or private. */
+.oc-sheet { margin: 18px 0 0; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }
+.oc-sheet > summary { display: block; padding: 14px 18px; cursor: pointer; font-weight: 700; list-style: none; }
+.oc-sheet > summary::-webkit-details-marker { display: none; }
+.oc-sheet-form { padding: 0 18px 18px; }
+.oc-sheet-step { border: 0; padding: 0; margin: 0 0 18px; }
+.oc-sheet-step legend { padding: 0 0 8px; font-weight: 700; }
+.oc-templates { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
+.oc-template { display: block; }
+.oc-template input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+.oc-template-body { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--oc-line); border-radius: 12px; cursor: pointer; }
+.oc-template input:checked + .oc-template-body { border-color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 14%, transparent); }
+.oc-template input:focus-visible + .oc-template-body { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
+.oc-template-channels { font-size: .78rem; color: var(--oc-muted); }
+.oc-seg { display: inline-flex; border: 1px solid var(--oc-line); border-radius: 10px; overflow: hidden; }
+.oc-seg label { position: relative; }
+.oc-seg input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+.oc-seg span { display: block; min-width: 96px; min-height: 40px; padding: 8px 16px; text-align: center; cursor: pointer; color: var(--oc-muted); }
+.oc-seg input:checked + span { background: var(--oc-accent); color: var(--oc-on-accent); font-weight: 700; }
+.oc-seg input:focus-visible + span { outline: 2px solid var(--oc-accent); outline-offset: -2px; }
+
+/* The invite link at the end of the Start sheet. */
+.oc-invite-new { margin: 0 18px 14px; padding: 12px 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
+.oc-invite-new h2 { margin: 0 0 4px; font-size: 1rem; }
+.oc-invite-url { display: block; overflow-wrap: anywhere; padding: 8px 10px; border-radius: 8px; background: var(--oc-bg); }
+.oc-invite-tools { display: flex; gap: 8px; margin: 10px 0 0; }
+
+/* A server card is not a message attachment card (chat.css .oc-card): no 380 px cap, no feed margins; the banner bleeds. */
+.oc-server-card { max-width: none; margin: 0; background: var(--oc-panel); }
+.oc-server-card > .oc-card-banner { align-self: stretch; margin: 0; }
+
+/* With the shell script running, links open the Start sheet: its own bar is not needed while it is closed. */
+.oc-js .oc-sheet:not([open]) { display: none; }
+.oc-js .oc-sheet[open] > summary { display: none; }
+
+/* The Start sheet's fields: the label above, two columns on a computer, one on a phone. */
+.oc-sheet-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; max-width: 640px; }
+.oc-field { display: grid; gap: 6px; min-width: 0; }
+.oc-field > span { font-weight: 600; font-size: .9rem; }
+.oc-field--wide { grid-column: 1 / -1; }
+.oc-field input:not([type=file]), .oc-field select { width: 100%; min-height: 44px; box-sizing: border-box; }
+.oc-field small { font-size: .8rem; }
+@media (max-width: 760px) { .oc-sheet-fields { grid-template-columns: minmax(0, 1fr); } }
+
+/* Absolutely placed inputs (the template radios) stay inside the page that scrolls, not the document. */
+.oc-page, .oc-template { position: relative; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+/* openvibe.chat — the app shell on the page (the plan, 2026-11-09, stage V2): the phone's panels and the swipe
+ * between them, the members toggle, and the shared user popout on every name.
+ *
+ * The page reads and works without this file: the shell is server-rendered, and the panels are links to the same
+ * places. This adds the gestures and the popout. */
+(function () {
+ 'use strict';
+
+ var app = document.querySelector('[data-oc-app]');
+ if (!app) return;
+
+ // ── The Start sheet opens from any link to it (the rail's +, the side column, the Start card), so it needs no bar
+ // of its own while closed (chat-shell.css hides that bar once this runs; without this file the bar stays) ──
+ document.documentElement.classList.add('oc-js');
+ function openSheet() {
+ var sheet = document.getElementById('oc-new-server');
+ if (!sheet) return false;
+ sheet.open = true;
+ var page = sheet.closest('.oc-page');
+ if (page) page.scrollTo({ top: Math.max(0, sheet.offsetTop - page.offsetTop - 12) });
+ var first = sheet.querySelector('input:not([type=hidden]):not([type=radio]), input[type=radio]:checked, select, textarea');
+ if (first) first.focus({ preventScroll: true });
+ return true;
+ }
+ if (location.hash === '#oc-new-server') openSheet();
+ window.addEventListener('hashchange', function () { if (location.hash === '#oc-new-server') openSheet(); });
+ document.addEventListener('click', function (e) {
+ var a = e.target && e.target.closest && e.target.closest('a[href$="#oc-new-server"]');
+ if (!a || !document.getElementById('oc-new-server')) return;
+ e.preventDefault();
+ if (location.hash !== '#oc-new-server') history.replaceState(null, '', '#oc-new-server');
+ openSheet();
+ });
+
+ // ── The panels: the side column on the left, the people on the right, the conversation between them ──
+ function panel(name) {
+ app.setAttribute('data-oc-panel', name);
+ app.classList.toggle('is-members', name === 'members');
+ document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {
+ b.setAttribute('aria-pressed', b.getAttribute('data-oc-panel-toggle') === name ? 'true' : 'false');
+ });
+ }
+ document.addEventListener('click', function (e) {
+ var b = e.target.closest && e.target.closest('[data-oc-panel]');
+ if (!b || b === app) return;
+ var to = b.getAttribute('data-oc-panel');
+ if (!to) return;
+ e.preventDefault();
+ panel(app.getAttribute('data-oc-panel') === to ? 'main' : to);
+ });
+ document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {
+ b.addEventListener('click', function () { panel(b.getAttribute('data-oc-panel-toggle')); });
+ });
+
+ // ── The phone: swipe right from the conversation for the side column, left for the people ──
+ if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches && window.PointerEvent) {
+ var startX = 0, startY = 0, tracking = false;
+ app.addEventListener('pointerdown', function (e) {
+ if (e.pointerType !== 'touch') return;
+ startX = e.clientX; startY = e.clientY; tracking = true;
+ }, { passive: true });
+ app.addEventListener('pointerup', function (e) {
+ if (!tracking) return;
+ tracking = false;
+ var dx = e.clientX - startX, dy = e.clientY - startY;
+ if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx)) return;
+ var here = app.getAttribute('data-oc-panel');
+ if (dx > 0 && here === 'main') panel('side');
+ else if (dx > 0 && here === 'members') panel('main');
+ else if (dx < 0 && here === 'main') panel('members');
+ else if (dx < 0 && here === 'side') panel('main');
+ }, { passive: true });
+ }
+
+ // ── Copy a link (the invite at the end of the Start sheet) ──
+ document.addEventListener('click', function (e) {
+ var b = e.target.closest && e.target.closest('[data-oc-copy]');
+ if (!b) return;
+ e.preventDefault();
+ var text = b.getAttribute('data-oc-copy') || '';
+ var done = function () { var old = b.textContent; b.textContent = 'Copied'; setTimeout(function () { b.textContent = old; }, 1400); };
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { });
+ else {
+ var input = document.createElement('textarea');
+ input.value = text; document.body.appendChild(input); input.select();
+ try { document.execCommand('copy'); done(); } catch (err) { /* nothing to copy with */ }
+ input.remove();
+ }
+ });
+
+ // ── The shared user popout (openvibe-shared/user-card.js): every name and avatar opens it ──
+ function token() {
+ var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
+ return m ? decodeURIComponent(m[1]) : null;
+ }
+ var people = Array.prototype.slice.call(document.querySelectorAll('[data-oc-user]'));
+ // A message's author opens the card too: the message carries data-user, live messages included (one listener).
+ var hasMessages = !!document.querySelector('.oc-msg[data-user]');
+ if (!people.length && !hasMessages) return;
+ var tok = token();
+ if (!tok) return; // signed out: no card to open
+ document.addEventListener('click', function (e) {
+ var name = e.target && e.target.closest && e.target.closest('.oc-msg[data-user] .oc-name');
+ if (!name || !window.OpenVibeUserCard || e.ctrlKey || e.metaKey || e.shiftKey) return; // a modifier opens the profile page
+ var id = name.closest('.oc-msg').getAttribute('data-user');
+ if (!id || id === '0') return;
+ e.preventDefault();
+ var box = name.getBoundingClientRect();
+ window.OpenVibeUserCard.show(id, box.left + 8, box.bottom + 8);
+ });
+
+ function openCard(el) {
+ if (window.OpenVibeUserCard && window.OpenVibeUserCard.show) {
+ var box = el.getBoundingClientRect();
+ window.OpenVibeUserCard.show(el.getAttribute('data-oc-user'), box.left + 8, box.bottom + 8);
+ return;
+ }
+ if (window.OpenVibeUserMenu && window.OpenVibeUserMenu.show) {
+ var box2 = el.getBoundingClientRect();
+ window.OpenVibeUserMenu.show(el.getAttribute('data-oc-user'), box2.left + 8, box2.bottom + 8);
+ }
+ }
+ function wire() {
+ if (!window.OpenVibeUserCard) return;
+ window.OpenVibeUserCard.init({ apiBase: 'https://openvibe.network', token: tok });
+ var phone = window.matchMedia && window.matchMedia('(max-width: 760px)').matches;
+ people.forEach(function (el) {
+ var id = el.getAttribute('data-oc-user');
+ if (!id) return;
+ if (phone) {
+ el.addEventListener('click', function (e) { e.preventDefault(); openCard(el); });
+ } else {
+ window.OpenVibeUserCard.attach(el, id);
+ el.addEventListener('click', function (e) {
+ if (e.ctrlKey || e.metaKey) { e.preventDefault(); openCard(el); }
+ });
+ }
+ });
+ // Chat's own actions in the shared card: Message, Call, Add friend, Mute, Block, Report.
+ if (window.OpenVibeUserMenu && window.OpenVibeUserMenu.register) {
+ window.OpenVibeUserMenu.register({
+ id: 'chat-message', label: 'Message', icon: '💬',
+ onSelect: function (user) { window.location.href = '/messages/new?to=' + encodeURIComponent(user.username || ''); },
+ });
+ window.OpenVibeUserMenu.register({
+ id: 'chat-friend', label: 'Add friend', icon: '+',
+ onSelect: function (user) {
+ fetch('/api/dm/contacts', {
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: user.username }),
+ }).then(function (r) { if (r.ok) window.location.reload(); });
+ },
+ });
+ }
+ }
+ if (window.OpenVibeUserCard) wire();
+ else {
+ var s = document.createElement('script');
+ s.src = '/shared/user-card.js';
+ s.async = true;
+ s.onload = wire;
+ s.onerror = function () { /* the card is optional */ };
+ document.head.appendChild(s);
+ }
+})();
@@ -117,6 +117,37 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -117,6 +117,37 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-dm-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
.oc-dm-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
.oc-dm-head h1 { margin: 0; }
.oc-dm-head h1 { margin: 0; }
.oc-dm-call button { padding: 7px 14px; }
.oc-dm-call button { padding: 7px 14px; }
+.oc-page-tools #oc-view-toggle, .oc-page-tools .oc-dm-call .oc-icon-btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; width:auto; min-width:40px; padding:0 8px; white-space:nowrap; }
+.oc-view-label, .oc-call-label { font-size:.8rem; }
+.oc-av img { width:100%; height:100%; border-radius:inherit; object-fit:cover; display:block; }
+.oc-bubbles { display:flex; flex-direction:column; align-items:stretch; gap:5px; padding:16px clamp(12px,3vw,28px) 22px; }
+.oc-bubble-row { list-style:none; display:flex; flex-direction:column; align-items:flex-start; max-width:min(78%,680px); margin:5px 0; }
+.oc-bubble-row.oc-bubble-mine { align-self:flex-end; align-items:flex-end; }
+.oc-bubble-author { color:var(--oc-muted); font-size:.78rem; font-weight:650; margin:0 0 3px 10px; }
+.oc-bubble { background:var(--oc-panel-raised,var(--oc-panel)); border-radius:17px 17px 17px 5px; padding:9px 12px; overflow-wrap:anywhere; white-space:pre-wrap; }
+.oc-bubble-mine .oc-bubble { background:var(--accent,#5879f6); color:#fff; border-radius:17px 17px 5px 17px; }
+.oc-bubble-time { font-size:.7rem; opacity:.65; margin:3px 4px 0; }
+.oc-bubbles .oc-empty { align-self:center; }
+.oc-list#oc-convs { border:0; border-radius:0; background:transparent; overflow:visible; }
+.oc-list#oc-convs .oc-row + .oc-row { border-top:1px solid var(--oc-line); }
+.oc-list#oc-convs .oc-row > a { padding:12px 4px; }
+.oc-list#oc-convs .oc-av { width:48px; height:48px; }
+.oc-new-picker { position:relative; }
+.oc-new-picker > summary { list-style:none; display:inline-flex; align-items:center; justify-content:center; gap:7px; width:auto; min-width:44px; height:44px; padding:0 12px; cursor:pointer; border:1px solid var(--oc-line); background:var(--oc-panel); }
+.oc-new-picker > summary::-webkit-details-marker { display:none; }
+.oc-new-pop { position:absolute; z-index:8; right:0; top:48px; width:min(360px,calc(100vw - 28px)); padding:12px; border:1px solid var(--oc-line); border-radius:12px; background:var(--oc-panel); box-shadow:0 12px 36px #0005; }
+.oc-new-pop label { display:block; font-weight:650; margin-bottom:6px; }
+.oc-new-pop input { width:100%; }
+.oc-new-pop ul { list-style:none; margin:8px 0 0; padding:0; max-height:45vh; overflow:auto; }
+.oc-new-pop li + li { border-top:1px solid var(--oc-line); }
+.oc-new-pop li button { width:100%; text-align:left; background:transparent; color:var(--oc-text); border-radius:6px; padding:8px; }
+.oc-new-pop li button { display:flex; align-items:center; gap:10px; }
+.oc-new-pop li small { display:block; color:var(--oc-muted); }
+.oc-feed.oc-bubbles { max-height:none; flex:1; min-height:0; margin:0; padding:16px clamp(12px,3vw,28px) 22px; border:0; border-radius:0; background:transparent; }
+@media (max-width:560px) {
+ .oc-page-tools #oc-view-toggle, .oc-page-tools .oc-dm-call .oc-icon-btn, .oc-new-picker > summary { width:44px; min-width:44px; height:44px; padding:0; }
+ .oc-view-label, .oc-call-label, .oc-new-picker > summary span { display:none; }
+}
/* A call coming in (public/web/ring.js): above everything, bottom right; full width on phones. */
/* A call coming in (public/web/ring.js): above everything, bottom right; full width on phones. */
.oc-ring { position: fixed; right: 16px; bottom: 16px; z-index: 50; max-width: min(360px, calc(100vw - 32px)); margin: 0; padding: 14px 16px; background: var(--oc-panel); border: 1px solid var(--oc-accent); border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, .45); }
.oc-ring { position: fixed; right: 16px; bottom: 16px; z-index: 50; max-width: min(360px, calc(100vw - 32px)); margin: 0; padding: 14px 16px; background: var(--oc-panel); border: 1px solid var(--oc-accent); border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, .45); }
.oc-ring-text { margin: 0 0 10px; }
.oc-ring-text { margin: 0 0 10px; }
@@ -153,6 +184,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -153,6 +184,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-main--wide { max-width: 1440px; }
.oc-main--wide { max-width: 1440px; }
.oc-srv { display: grid; grid-template-columns: 64px 232px minmax(0, 1fr) 212px; height: min(80vh, 900px); min-height: 480px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; overflow: hidden; }
.oc-srv { display: grid; grid-template-columns: 64px 232px minmax(0, 1fr) 212px; height: min(80vh, 900px); min-height: 480px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; overflow: hidden; }
.oc-srv-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }
.oc-srv-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }
+.oc-srv-divider { width: 30px; height: 1px; flex: 0 0 1px; background: var(--oc-line); }
+.oc-srv-home, .oc-srv-vibe { background: var(--oc-panel-2); color: var(--oc-text); }
+.oc-srv-home .oc-i, .oc-srv-vibe .oc-i { width: 21px; height: 21px; }
+.oc-mobile-bar { display: none; }
.oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; font-weight: 800; font-size: .92rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }
.oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; font-weight: 800; font-size: .92rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }
.oc-srv-icon:hover, .oc-srv-icon:focus-visible, .oc-srv-icon.is-active { border-radius: 14px; }
.oc-srv-icon:hover, .oc-srv-icon:focus-visible, .oc-srv-icon.is-active { border-radius: 14px; }
.oc-srv-rail .oc-srv-icon.is-active::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }
.oc-srv-rail .oc-srv-icon.is-active::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }
@@ -220,7 +255,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -220,7 +255,7 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-srv-people-list { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.oc-srv-people-list { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
}
}
@media (max-width: 760px) {
@media (max-width: 760px) {
- .oc-srv { grid-template-columns: minmax(0, 1fr); border-radius: 12px; }
+ .oc-srv { grid-template-columns: minmax(0, 1fr); border: 0; border-radius: 0; overflow: visible; padding-bottom: 68px; }
.oc-srv-rail, .oc-srv-side, .oc-srv-main { height: auto; }
.oc-srv-rail, .oc-srv-side, .oc-srv-main { height: auto; }
.oc-srv-rail { flex-direction: row; justify-content: flex-start; padding: 10px 12px; gap: 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); }
.oc-srv-rail { flex-direction: row; justify-content: flex-start; padding: 10px 12px; gap: 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); }
.oc-srv-rail .oc-srv-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -10px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }
.oc-srv-rail .oc-srv-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -10px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }
@@ -230,6 +265,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -230,6 +265,10 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-srv-feed { max-height: 62vh; min-height: 220px; }
.oc-srv-feed { max-height: 62vh; min-height: 220px; }
.oc-chan-head, .oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { padding-left: 12px; padding-right: 12px; }
.oc-chan-head, .oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { padding-left: 12px; padding-right: 12px; }
.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }
.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }
+ .oc-mobile-bar { position: fixed; z-index: 20; inset: auto 0 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px max(8px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--oc-line); background: var(--oc-panel); }
+ .oc-mobile-bar a { min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--oc-muted); text-decoration: none; font-size: .68rem; }
+ .oc-mobile-bar a.is-active { color: var(--oc-accent); }
+ .oc-mobile-bar .oc-i { width: 20px; height: 20px; }
}
}
.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }
.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }
@@ -14,6 +14,40 @@
@@ -14,6 +14,40 @@
var input = document.getElementById('oc-input');
var input = document.getElementById('oc-input');
var latest = Number(cfg.latest) || 0;
var latest = Number(cfg.latest) || 0;
var LIVE = 'https://openvibe.live';
var LIVE = 'https://openvibe.live';
+ var viewToggle = document.getElementById('oc-view-toggle');
+ if (viewToggle && cfg.conversation) viewToggle.addEventListener('click', function () {
+ var next = viewToggle.dataset.view === 'bubbles' ? 'room' : 'bubbles';
+ viewToggle.disabled = true;
+ fetch('/api/dm/conversations/' + encodeURIComponent(cfg.conversation) + '/view', {
+ method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ view: next })
+ }).then(function (response) { if (!response.ok) throw new Error('Could not save view'); window.location.reload(); })
+ .catch(function () { viewToggle.disabled = false; });
+ });
+ var peopleSearch = document.getElementById('oc-people-search');
+ var peopleResults = document.getElementById('oc-people-results');
+ if (peopleSearch && peopleResults) {
+ var recentPeopleMarkup = peopleResults.innerHTML;
+ var peopleTimer = 0;
+ peopleSearch.addEventListener('input', function () {
+ window.clearTimeout(peopleTimer);
+ peopleTimer = window.setTimeout(function () {
+ var q = peopleSearch.value.trim();
+ peopleResults.replaceChildren();
+ if (q.length < 2) { peopleResults.innerHTML = recentPeopleMarkup; return; }
+ fetch('/api/dm/users/search?q=' + encodeURIComponent(q), { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : { users: [] }; }).then(function (data) {
+ if (peopleSearch.value.trim() !== q) return;
+ (data.users || []).forEach(function (person) {
+ var li = document.createElement('li'), form = document.createElement('form'), username = String(person.username || '');
+ form.method = 'post'; form.action = '/messages/new';
+ var field = document.createElement('input'); field.type = 'hidden'; field.name = 'username'; field.value = username;
+ var button = document.createElement('button'); button.type = 'submit'; button.textContent = String(person.display_name || username) + (username ? ' · @' + username : '');
+ form.appendChild(field); form.appendChild(button); li.appendChild(form); peopleResults.appendChild(li);
+ });
+ if (!peopleResults.children.length) { var empty = el('li', 'oc-muted', 'No people found'); peopleResults.appendChild(empty); }
+ }).catch(function () { peopleResults.replaceChildren(); });
+ }, 180);
+ });
+ }
// The session token (the ov_token cookie is JS-readable), presented in each socket's first message.
// The session token (the ov_token cookie is JS-readable), presented in each socket's first message.
function sessionToken() {
function sessionToken() {
@@ -74,6 +74,10 @@ const EXPORTS = [
@@ -74,6 +74,10 @@ const EXPORTS = [
// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['vibe_asks.json', `SELECT idempotency_key, room_id, state, answer, code, created_at FROM vibe_asks WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['vibe_asks.json', `SELECT idempotency_key, room_id, state, answer, code, created_at FROM vibe_asks WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
+ // Friends and conversation view preferences from the Chat app shell (migrations 0051, 0052, 0049).
+ ['dm_contacts.json', `SELECT user_id, contact_id, status, created_at FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
+ ['dm_view_defaults.json', `SELECT kind, view FROM dm_view_defaults WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
+ ['dm_view_preferences.json', `SELECT conversation_id, view, updated_at FROM dm_view_preferences WHERE user_id = ANY(@ids) ORDER BY updated_at DESC LIMIT ${LIMIT}`],
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inboxes_owned.json', `SELECT * FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_memberships.json', `SELECT * FROM inbox_members WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['inbox_replies.json', `SELECT * FROM inbox_messages WHERE author = 'agent' AND author_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
@@ -138,6 +142,10 @@ const ERASE_AFTER_ROOMS = [
@@ -138,6 +142,10 @@ const ERASE_AFTER_ROOMS = [
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
['vibe_deliveries', 'DELETE FROM vibe_deliveries WHERE user_id = ANY(@ids)'],
+ // Friends and conversation view preferences from the Chat app shell.
+ ['dm_contacts', 'DELETE FROM dm_contacts WHERE user_id = ANY(@ids) OR contact_id = ANY(@ids)'],
+ ['dm_view_defaults', 'DELETE FROM dm_view_defaults WHERE user_id = ANY(@ids)'],
+ ['dm_view_preferences', 'DELETE FROM dm_view_preferences WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// their replies stay for the visitor without their name, and their place on the team goes.
// their replies stay for the visitor without their name, and their place on the team goes.
[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
[null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * Friends and requests (the plan, 2026-11-09, stage V2: the side column on Home carries "Friends, Requests and
+ * Vibe, then DMs"). Migration 0051 keeps the contacts a person has chosen: a friend is a contact both ways, a
+ * request is one the other person has not accepted yet.
+ *
+ * A friend is only ever a person on OpenVibe: the id comes from Network's user directory (ctx_users), never from
+ * the request body. A blocked person is never a friend.
+ */
+const db = require('../db/database');
+
+/** The contacts of `userId`: their friends and the requests they sent or received. */
+async function of(userId) {
+ const rows = await db.all(`
+ SELECT c.contact_id, c.status, u.username, u.display_name, u.avatar_url, u.subject_id
+ FROM dm_contacts c LEFT JOIN ctx_users u ON u.id = c.contact_id
+ WHERE c.user_id = ? ORDER BY lower(COALESCE(u.display_name, u.username, ''))`, [userId]);
+ const friends = [];
+ const requests = [];
+ const asked = [];
+ for (const r of rows) {
+ const person = { id: Number(r.contact_id), username: r.username || '', display_name: r.display_name || r.username || '', avatar_url: r.avatar_url || null, subject_id: r.subject_id || null, status: r.status };
+ if (r.status === 'friend') friends.push(person);
+ else if (r.status === 'incoming') requests.push(person); // they asked: it waits for you
+ else if (r.status === 'outgoing') asked.push(person); // you asked: it waits for them
+ }
+ return { friends, requests, asked };
+}
+
+/** The people who asked `userId` to be their friend (they answer in the side column). */
+async function incoming(userId) {
+ const rows = await db.all(`
+ SELECT c.user_id, u.username, u.display_name, u.avatar_url, u.subject_id
+ FROM dm_contacts c LEFT JOIN ctx_users u ON u.id = c.user_id
+ WHERE c.contact_id = ? AND c.status = 'incoming'`, [userId]);
+ return rows.map((r) => ({ id: Number(r.user_id), username: r.username || '', display_name: r.display_name || r.username || '', avatar_url: r.avatar_url || null, subject_id: r.subject_id || null }));
+}
+
+/** Ask someone to be a friend (or accept them: both people then carry the other as a friend). */
+async function add(userId, contactId) {
+ const a = Number(userId); const b = Number(contactId);
+ if (!a || !b || a === b) return null;
+ // The other way round already: both are friends.
+ const mine = await db.get('SELECT status FROM dm_contacts WHERE user_id = ? AND contact_id = ?', [a, b]);
+ const theirs = await db.get('SELECT status FROM dm_contacts WHERE user_id = ? AND contact_id = ?', [b, a]);
+ if (mine && mine.status === 'friend') return 'friend';
+ if (theirs && theirs.status === 'incoming') {
+ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);
+ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);
+ return 'friend';
+ }
+ if (mine && mine.status === 'incoming') {
+ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);
+ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);
+ return 'friend';
+ }
+ await db.run("INSERT INTO dm_contacts (user_id, contact_id, status) VALUES (?, ?, 'outgoing') ON CONFLICT (user_id, contact_id) DO UPDATE SET status = 'outgoing'", [a, b]);
+ if (!theirs) await db.run("INSERT INTO dm_contacts (user_id, contact_id, status) VALUES (?, ?, 'incoming') ON CONFLICT (user_id, contact_id) DO NOTHING", [b, a]);
+ return 'outgoing';
+}
+
+/** Accept a request from someone (both ways become friends). */
+async function accept(userId, contactId) {
+ const a = Number(userId); const b = Number(contactId);
+ // The request waits for them: their own row says so ('incoming' means they asked).
+ const mine = await db.get('SELECT status FROM dm_contacts WHERE user_id = ? AND contact_id = ?', [a, b]);
+ if (!mine || mine.status !== 'incoming') return false;
+ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [a, b]);
+ await db.run("UPDATE dm_contacts SET status = 'friend' WHERE user_id = ? AND contact_id = ?", [b, a]);
+ return true;
+}
+
+/** Drop a friend or a request (both ways). */
+async function remove(userId, contactId) {
+ const a = Number(userId); const b = Number(contactId);
+ await db.run('DELETE FROM dm_contacts WHERE (user_id = ? AND contact_id = ?) OR (user_id = ? AND contact_id = ?)', [a, b, b, a]);
+ return true;
+}
+
+module.exports = { of, incoming, add, accept, remove };
@@ -18,8 +18,10 @@ const router = express.Router();
@@ -18,8 +18,10 @@ const router = express.Router();
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const dm = require('./dm');
const dm = require('./dm');
+const contacts = require('./contacts');
const ctx = require('../live-context');
const ctx = require('../live-context');
const prefStores = require('../prefs/stores');
const prefStores = require('../prefs/stores');
+const database = require('../db/database');
const presence = require('../presence');
const presence = require('../presence');
const { idParam } = require('./route-params');
const { idParam } = require('./route-params');
@@ -47,6 +49,25 @@ async function withPresence(viewerSubject, conversations) {
@@ -47,6 +49,25 @@ async function withPresence(viewerSubject, conversations) {
// All DM routes require authentication
// All DM routes require authentication
router.use(requireAuth);
router.use(requireAuth);
+// A view choice is private to the signed-in participant and this conversation.
+router.get('/conversations/:id/view', async (req, res) => {
+ const id = Number(req.params.id);
+ if (!Number.isSafeInteger(id) || !await dm.isParticipant(id, req.user.id)) return res.status(404).json({ error: 'Conversation not found' });
+ const saved = await database.get('SELECT view FROM dm_view_preferences WHERE user_id = ? AND conversation_id = ?', [req.user.id, id]);
+ const participants = await dm.getParticipants(id);
+ const defaults = await prefStores.settingsOf(prefStores.preferences, req.user.subject_id);
+ const kind = participants.length <= 2 ? 'one_to_one' : 'group';
+ const view = saved?.view || (defaults.view_defaults && defaults.view_defaults[kind]) || (kind === 'one_to_one' ? 'bubbles' : 'room');
+ res.set('Cache-Control', 'private, no-store').json({ conversation_id: id, view });
+});
+router.put('/conversations/:id/view', async (req, res) => {
+ const id = Number(req.params.id), view = req.body && req.body.view;
+ if (!Number.isSafeInteger(id) || !await dm.isParticipant(id, req.user.id)) return res.status(404).json({ error: 'Conversation not found' });
+ if (view !== 'bubbles' && view !== 'room') return res.status(400).json({ error: 'view must be bubbles or room' });
+ await database.run('INSERT INTO dm_view_preferences (user_id, conversation_id, view, updated_at) VALUES (?, ?, ?, ov_now()) ON CONFLICT (user_id, conversation_id) DO UPDATE SET view = EXCLUDED.view, updated_at = ov_now()', [req.user.id, id, view]);
+ res.set('Cache-Control', 'private, no-store').json({ conversation_id: id, view });
+});
+
// Ids are validated before the handler (and its queries) see them, one rule per param name.
// Ids are validated before the handler (and its queries) see them, one rule per param name.
router.param('id', idParam('invalid conversation id'));
router.param('id', idParam('invalid conversation id'));
router.param('userId', idParam('invalid user id'));
router.param('userId', idParam('invalid user id'));
@@ -419,6 +440,61 @@ router.get('/users/search', async (req, res) => {
@@ -419,6 +440,61 @@ router.get('/users/search', async (req, res) => {
}
}
});
});
+// ── Friends and requests (the side column on Home) ─────────────
+
+// The people you talk to: your friends and the requests that wait for you.
+router.get('/contacts', async (req, res) => {
+ try {
+ const list = await contacts.of(req.user.id);
+ res.set('Cache-Control', 'private, no-store').json(list);
+ } catch (err) {
+ console.error('[Contacts] List error:', err.message);
+ res.status(500).json({ error: 'Failed' });
+ }
+});
+
+// Ask someone to be a friend (or accept them, when they asked first).
+router.post('/contacts', manageLimit, async (req, res) => {
+ try {
+ const username = String((req.body && req.body.username) || '').trim();
+ if (!/^[A-Za-z0-9_]{3,24}$/.test(username)) return res.status(400).json({ error: 'That is not a username' });
+ const target = await ctx.getUserByUsername(username) || await ctx.ensureUserByUsername(username).catch(() => null);
+ if (!target) return res.status(404).json({ error: 'Nobody on OpenVibe goes by that name' });
+ if (Number(target.id) === Number(req.user.id)) return res.status(400).json({ error: 'That is you' });
+ if (await dm.hasBlocked(req.user.id, target.id) || await dm.hasBlocked(target.id, req.user.id)) {
+ return res.status(403).json({ error: 'You cannot add this person' });
+ }
+ const status = await contacts.add(req.user.id, target.id);
+ res.json({ ok: true, status });
+ } catch (err) {
+ console.error('[Contacts] Add error:', err.message);
+ res.status(500).json({ error: 'Failed' });
+ }
+});
+
+// Accept a request.
+router.post('/contacts/:userId/accept', manageLimit, async (req, res) => {
+ try {
+ const ok = await contacts.accept(req.user.id, Number(req.params.userId));
+ if (!ok) return res.status(404).json({ error: 'No request from that person' });
+ res.json({ ok: true });
+ } catch (err) {
+ console.error('[Contacts] Accept error:', err.message);
+ res.status(500).json({ error: 'Failed' });
+ }
+});
+
+// Drop a friend or a request.
+router.delete('/contacts/:userId', manageLimit, async (req, res) => {
+ try {
+ await contacts.remove(req.user.id, Number(req.params.userId));
+ res.json({ ok: true });
+ } catch (err) {
+ console.error('[Contacts] Remove error:', err.message);
+ res.status(500).json({ error: 'Failed' });
+ }
+});
+
// ── Block & message management ───────────────────────────────
// ── Block & message management ───────────────────────────────
// Block a user
// Block a user
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * The default view for each kind of conversation (bubbles or a chat room) — the plan, 2026-11-09, stage V1:
+ * "You can toggle per conversation, and set a default per kind in settings, synced through Network so the Dock
+ * follows it."
+ *
+ * The choice is kept in Chat (migration 0052) because the Network module `chat.preferences` has no field for it
+ * yet: adding one is a Contracts change (chat.preferences v3) that only lands with a release and a pin. Until
+ * then the choice is Chat's own, and `view_defaults` is written to the Network module only when that module
+ * knows the field (it answers with "unknown field" otherwise).
+ */
+const db = require('../db/database');
+
+const KINDS = ['one_to_one', 'ai', 'group', 'channel'];
+const FALLBACK = { one_to_one: 'bubbles', ai: 'bubbles', group: 'room', channel: 'room' };
+
+/** The default view for each kind: what the person chose, or the built-in default. */
+async function of(userId) {
+ const out = { ...FALLBACK };
+ try {
+ const rows = await db.all('SELECT kind, view FROM dm_view_defaults WHERE user_id = ?', [userId]);
+ for (const r of rows) if (KINDS.includes(r.kind) && (r.view === 'bubbles' || r.view === 'room')) out[r.kind] = r.view;
+ } catch { /* the table is not there yet: the defaults stand */ }
+ return out;
+}
+
+/** Save the defaults for the kinds that were chosen (null leaves a kind as it was). */
+async function save(userId, values = {}) {
+ for (const kind of KINDS) {
+ const view = values[kind];
+ if (view !== 'bubbles' && view !== 'room') continue;
+ await db.run(`INSERT INTO dm_view_defaults (user_id, kind, view) VALUES (?, ?, ?)
+ ON CONFLICT (user_id, kind) DO UPDATE SET view = EXCLUDED.view`, [userId, kind, view]);
+ }
+ return await of(userId);
+}
+
+module.exports = { of, save, KINDS, FALLBACK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * The templates a new server starts from, and the categories Explore lists them under (the plan, 2026-11-09,
+ * stage V3: "/servers becomes Explore … a template first (Friends, Gaming group, Stream community, Study group,
+ * Creators, Blank), each with its channels listed; then a name and a picture; then Public/Private as a segmented
+ * control. Done in under 10 seconds.").
+ *
+ * A template is a name, one line about it and the channels it starts with. Nothing here talks to the database:
+ * server/rooms/servers.js creates the channels.
+ */
+
+/** The categories Explore filters by. */
+const CATEGORIES = ['Gaming', 'Streams', 'Dev', 'Art', 'Music', 'Study', 'AI'];
+
+/** The templates: what a new server starts with. */
+const TEMPLATES = [
+ {
+ id: 'friends',
+ name: 'Friends',
+ category: 'Study',
+ description: 'A small server for a few friends.',
+ channels: [
+ { name: 'general', kind: 'text' },
+ { name: 'plans', kind: 'text' },
+ { name: 'lounge', kind: 'voice' },
+ ],
+ },
+ {
+ id: 'gaming',
+ name: 'Gaming group',
+ category: 'Gaming',
+ description: 'A team that plays together: a channel per game, and a voice channel to talk in.',
+ channels: [
+ { name: 'general', kind: 'text' },
+ { name: 'looking-for-group', kind: 'text' },
+ { name: 'clips', kind: 'text' },
+ { name: 'voice', kind: 'voice' },
+ ],
+ },
+ {
+ id: 'streams',
+ name: 'Stream community',
+ category: 'Streams',
+ description: 'For a streamer and their people: chat, clips, and a stage to talk from.',
+ channels: [
+ { name: 'chat', kind: 'text' },
+ { name: 'clips', kind: 'text' },
+ { name: 'announcements', kind: 'text' },
+ { name: 'stage', kind: 'voice' },
+ ],
+ },
+ {
+ id: 'study',
+ name: 'Study group',
+ category: 'Study',
+ description: 'A quiet place to learn together: a room per subject and a silent study room.',
+ channels: [
+ { name: 'general', kind: 'text' },
+ { name: 'homework', kind: 'text' },
+ { name: 'study-room', kind: 'voice' },
+ ],
+ },
+ {
+ id: 'creators',
+ name: 'Creators',
+ category: 'Art',
+ description: 'For people who make things: work in progress, feedback, and a room to show off.',
+ channels: [
+ { name: 'general', kind: 'text' },
+ { name: 'work-in-progress', kind: 'text' },
+ { name: 'feedback', kind: 'text' },
+ { name: 'lounge', kind: 'voice' },
+ ],
+ },
+ {
+ id: 'blank',
+ name: 'Blank',
+ category: 'Dev',
+ description: 'Start empty: one #general, and you add the rest.',
+ channels: [{ name: 'general', kind: 'text' }],
+ },
+];
+
+/** The template with this id, or null. */
+const byId = (id) => TEMPLATES.find((t) => t.id === String(id || '')) || null;
+
+/** The category a server may be listed under, or null. */
+const category = (name) => (CATEGORIES.includes(String(name || '')) ? String(name) : null);
+
+module.exports = { TEMPLATES, CATEGORIES, byId, category };
@@ -104,7 +104,8 @@ function publicServer(s, extra = {}) {
@@ -104,7 +104,8 @@ function publicServer(s, extra = {}) {
if (!s) return null;
if (!s) return null;
return {
return {
id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,
id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,
- official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,
+ official: s.slug === OFFICIAL_SLUG, members: Number(s.member_count) || 0, created_at: s.created_at,
+ category: s.category || null, picture_url: s.picture_url || null, template: s.template || null, ...extra,
};
};
}
}
@@ -113,6 +114,7 @@ function publicChannel(r, extra = {}) {
@@ -113,6 +114,7 @@ function publicChannel(r, extra = {}) {
return {
return {
slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',
slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
+ category: r.category || null,
...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,
...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,
};
};
}
}
@@ -158,7 +160,7 @@ async function insertChannel(server, user, { name, kind, topic = null, position
@@ -158,7 +160,7 @@ async function insertChannel(server, user, { name, kind, topic = null, position
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
}
}
-async function create(user, { name, slug, description, visibility = 'public' } = {}) {
+async function create(user, { name, slug, description, visibility = 'public', category = null, picture_url = null, template = null } = {}) {
if (!user) fail(401, 'servers.sign_in', 'Sign in to create a server');
if (!user) fail(401, 'servers.sign_in', 'Sign in to create a server');
if (rooms.siteBanned(user)) fail(403, 'servers.banned', 'Your account is banned from chat');
if (rooms.siteBanned(user)) fail(403, 'servers.banned', 'Your account is banned from chat');
const n = cleanName(name, 2, 40);
const n = cleanName(name, 2, 40);
@@ -172,13 +174,18 @@ async function create(user, { name, slug, description, visibility = 'public' } =
@@ -172,13 +174,18 @@ async function create(user, { name, slug, description, visibility = 'public' } =
const owned = Number((await db.get('SELECT COUNT(*) AS n FROM chat_servers WHERE owner_id = ? AND archived_at IS NULL', [user.id]) || {}).n) || 0;
const owned = Number((await db.get('SELECT COUNT(*) AS n FROM chat_servers WHERE owner_id = ? AND archived_at IS NULL', [user.id]) || {}).n) || 0;
if (owned >= MAX_SERVERS_PER_OWNER && !isStaff(user)) fail(429, 'servers.too_many', `You can own up to ${MAX_SERVERS_PER_OWNER} servers`);
if (owned >= MAX_SERVERS_PER_OWNER && !isStaff(user)) fail(429, 'servers.too_many', `You can own up to ${MAX_SERVERS_PER_OWNER} servers`);
if (await db.get('SELECT 1 FROM chat_servers WHERE slug = ?', [s])) fail(409, 'servers.taken', 'That server address is taken');
if (await db.get('SELECT 1 FROM chat_servers WHERE slug = ?', [s])) fail(409, 'servers.taken', 'That server address is taken');
+ // The template decides the channels a new server starts with (server/rooms/server-templates.js); a blank
+ // server keeps #general and the Lounge.
+ const tpl = require('./server-templates').byId(template);
+ const cat = require('./server-templates').category(category);
+ const pic = picture_url ? String(picture_url).slice(0, 500) : null;
const id = await db.tx(async () => {
const id = await db.tx(async () => {
- const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count) VALUES (?, ?, ?, ?, ?, ?, 1)',
- [s, n, d, visibility, user.id, user.subject_id || null]);
+ const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count, category, picture_url, template) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)',
+ [s, n, d, visibility, user.id, user.subject_id || null, cat, pic, tpl ? tpl.id : null]);
const server = await db.get('SELECT * FROM chat_servers WHERE id = ?', [r.lastInsertRowid]);
const server = await db.get('SELECT * FROM chat_servers WHERE id = ?', [r.lastInsertRowid]);
await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [server.id, user.id]);
await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [server.id, user.id]);
- await insertChannel(server, user, { name: 'general', kind: 'text', topic: 'Say hello', position: 0 });
- await insertChannel(server, user, { name: 'Lounge', kind: 'voice', position: 0 });
+ const channels = (tpl ? tpl.channels : [{ name: 'general', kind: 'text', topic: 'Say hello' }, { name: 'Lounge', kind: 'voice' }]);
+ for (const [i, c] of channels.entries()) await insertChannel(server, user, { name: c.name, kind: c.kind, topic: c.topic || (i === 0 ? 'Say hello' : null), position: i });
await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) SELECT id, ?, 'member', 0, ov_now() FROM rooms WHERE server_id = ?", [user.id, server.id]);
await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) SELECT id, ?, 'member', 0, ov_now() FROM rooms WHERE server_id = ?", [user.id, server.id]);
return Number(server.id);
return Number(server.id);
});
});
@@ -303,7 +310,7 @@ async function setRole(server, actor, targetUserId, role) {
@@ -303,7 +310,7 @@ async function setRole(server, actor, targetUserId, role) {
/** The server's people by rank (blocked last, only for moderators). */
/** The server's people by rank (blocked last, only for moderators). */
async function members(server, { blocked = false, limit = 1000, subjects = false } = {}) {
async function members(server, { blocked = false, limit = 1000, subjects = false } = {}) {
const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
- return await db.all(`SELECT m.user_id${subjects ? ', u.subject_id' : ''}, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m
+ return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color, u.subject_id, u.avatar_url FROM chat_server_members m
LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
}
}
@@ -37,6 +37,9 @@ const ICONS = {
@@ -37,6 +37,9 @@ const ICONS = {
key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),
key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),
help: svg('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>'),
help: svg('<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>'),
more: svg('<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>'),
more: svg('<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>'),
+ view: svg('<path d="M4 5h7a3 3 0 0 1 3 3v3H7a3 3 0 0 1-3-3zM20 19h-7a3 3 0 0 1-3-3v-3h7a3 3 0 0 1 3 3z"/>'),
+ menu: svg('<path d="M4 7h16M4 12h16M4 17h16"/>'),
+ voice: svg('<path d="M3 9v6h4l5 4V5L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/>'),
download: svg('<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>'),
download: svg('<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>'),
chevron: svg('<path d="m9 6 6 6-6 6"/>'),
chevron: svg('<path d="m9 6 6 6-6 6"/>'),
hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
@@ -63,6 +63,9 @@ function navConfig(o, config, site = {}, siteLinks = null) {
@@ -63,6 +63,9 @@ function navConfig(o, config, site = {}, siteLinks = null) {
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: (siteLinks || site.links || links(o)).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),
links: (siteLinks || site.links || links(o)).map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: o.active === l.key })),
history: { type: 'page', title: o.title || site.name || SITE_NAME },
history: { type: 'page', title: o.title || site.name || SITE_NAME },
+ // The phone's bottom bar (Shared's app bar, appbar.js): Home, Servers, Vibe, Activity and You — one
+ // bar for every OpenVibe app (the plan, 2026-11-09, stage V2).
+ appBar: require('./shell').APP_BAR.map((l) => ({ ...l, active: o.active === l.page })),
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
@@ -145,7 +148,9 @@ function renderPage(o) {
@@ -145,7 +148,9 @@ function renderPage(o) {
const p = o.prefs || {};
const p = o.prefs || {};
const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
- const footerVariant = o.footer === 'compact' ? 'compact' : 'full';
+ // 'none': the app shell's own pages (the plan, 2026-11-09, stage V2) have no footer: the conversation
+ // fills the screen, and the phone's bottom bar is Shared's app bar.
+ const footerVariant = o.footer === 'compact' ? 'compact' : (o.footer === 'none' ? 'none' : 'full');
const html = `<!DOCTYPE html>
const html = `<!DOCTYPE html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -155,6 +160,7 @@ ${head}
@@ -155,6 +160,7 @@ ${head}
${appIcon.headTags({ site: 'network' })}
${appIcon.headTags({ site: 'network' })}
${INSTALL_LINKS}
${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${asset('chat.css')}">
+${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
@@ -168,6 +174,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
@@ -168,6 +174,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
+${o.shell ? `<script src="${asset('chat-shell.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
@@ -178,11 +185,11 @@ ${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${
@@ -178,11 +185,11 @@ ${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${
<a class="oc-skip" href="#main">Skip to content</a>
<a class="oc-skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
${frame.noscriptNav({ name: siteName, home: '/', links: siteLinks.map(({ label, href }) => ({ label, href })) })}
${frame.noscriptNav({ name: siteName, home: '/', links: siteLinks.map(({ label, href }) => ({ label, href })) })}
-<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.wide ? ' oc-main--wide' : ''}`}"${o.mainAttrs ? ` ${o.mainAttrs}` : ''}>
+<main id="main" class="${o.app ? 'vb-wrap' : `oc-main${o.shell ? ' oc-main--shell' : (o.wide ? ' oc-main--wide' : '')}`}"${o.mainAttrs ? ` ${o.mainAttrs}` : ''}>
<noscript><p class="oc-account">${who}</p></noscript>
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
${o.body || ''}
</main>
</main>
-${o.app ? '' : frame.footer({ service: siteService, variant: footerVariant, updates: '/updates' })}
+${o.app || footerVariant === 'none' ? '' : frame.footer({ service: siteService, variant: footerVariant, updates: '/updates' })}
<script>
<script>
window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config, site, siteLinks), footer: { service: siteService, variant: footerVariant, mount: '#ov-footer', brandName: siteName, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config, site, siteLinks), footer: { service: siteService, variant: footerVariant, mount: '#ov-footer', brandName: siteName, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
document.addEventListener('DOMContentLoaded', function () {
document.addEventListener('DOMContentLoaded', function () {
@@ -30,6 +30,7 @@
@@ -30,6 +30,7 @@
* test/live-down.test.js renders every page and uses the core APIs with Live refusing and hanging.
* test/live-down.test.js renders every page and uses the core APIs with Live refusing and hanging.
*/
*/
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const shell = require('./shell');
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
@@ -125,9 +126,10 @@ function globalMessage(m) {
@@ -125,9 +126,10 @@ function globalMessage(m) {
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
}
}
-function dmMessage(m, me) {
+function dmMessage(m, me, view = 'room') {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const mine = me && m.sender_id === me.id;
const mine = me && m.sender_id === me.id;
+ if (view === 'bubbles') return `<li class="oc-bubble-row${mine ? ' oc-bubble-mine' : ''}" data-id="${Number(m.id) || 0}">${!mine ? `<span class="oc-bubble-author">${esc(m.display_name || m.username)}</span>` : ''}<div class="oc-bubble">${linkify(m.message)}</div>${ui.linkCard(m.card)}<time class="oc-bubble-time" datetime="${t.iso}">${t.text}</time></li>`;
return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span>${ui.linkCard(m.card)}</li>`;
}
}
// Vibe, OpenVibe's assistant, as a room's AI member (one click on the room's settings; a person's own chats with it are
// Vibe, OpenVibe's assistant, as a room's AI member (one click on the room's settings; a person's own chats with it are
@@ -348,9 +350,9 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
@@ -348,9 +350,9 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
const dmApi = require('../chat/dm-routes');
const dmApi = require('../chat/dm-routes');
const { ICONS: IC } = require('./icons');
const { ICONS: IC } = require('./icons');
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
- const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null }) => {
+ const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null, avatarUrl = '' }) => {
const t = ui.when(at);
const t = ui.when(at);
- return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title, personPresence)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`
+ return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title, personPresence, { avatarUrl })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`
+ `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;
+ `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;
};
};
router.get('/messages', async (req, res) => {
router.get('/messages', async (req, res) => {
@@ -365,8 +367,10 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
@@ -365,8 +367,10 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
const others = participants[i].filter((p) => p.id !== me.id);
const others = participants[i].filter((p) => p.id !== me.id);
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
const personPresence = others.length === 1 ? others[0].presence : null;
const personPresence = others.length === 1 ? others[0].presence : null;
- return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0, personPresence });
+ return convRow({ href: `/messages/${c.id}`, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet', at: c.last_message_at, unread: Number(c.unread_count) || 0, personPresence, avatarUrl: others.length === 1 ? others[0].avatar_url : '' });
});
});
+ const recentPeople = [...new Map(participants.flat().filter((person) => person.id !== me.id).map((person) => [person.id, person])).values()].slice(0, 6);
+ const recentPicker = recentPeople.map((person) => `<li><form method="post" action="/messages/new"><input type="hidden" name="username" value="${esc(person.username || '')}"><button type="submit">${ui.initials(person.display_name || person.username, person.presence, { avatarUrl: person.avatar_url })}<span>${esc(person.display_name || person.username)}${person.username ? ` <small>@${esc(person.username)}</small>` : ''}</span></button></form></li>`).join('');
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
const done = req.query.unblocked ? notice('ok', 'Unblocked. They can message you again, and you them.') : '';
const blocked = await dm.getBlockedUsers(me.id);
const blocked = await dm.getBlockedUsers(me.id);
@@ -376,14 +380,32 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
@@ -376,14 +380,32 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);
const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);
const STATUS = { bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' };
const STATUS = { bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' };
const supportList = support.length ? `<h2 class="oc-list-head">Support</h2><ul class="oc-list">${support.map((c) => convRow({ href: `/support/${c.id}`, title: c.inbox.name, preview: c.preview || '', at: c.last_message_at, badge: ` <span class="oc-badge">${esc(STATUS[c.status] || c.status)}</span>` })).join('')}</ul>` : '';
const supportList = support.length ? `<h2 class="oc-list-head">Support</h2><ul class="oc-list">${support.map((c) => convRow({ href: `/support/${c.id}`, title: c.inbox.name, preview: c.preview || '', at: c.last_message_at, badge: ` <span class="oc-badge">${esc(STATUS[c.status] || c.status)}</span>` })).join('')}</ul>` : '';
+ // The rail, the side column (Home) and the list: the app shell (the plan, 2026-11-09, stage V2).
+ const mineServers = (await require('../rooms/servers').list(me)).mine;
+ const contacts = await require('../chat/contacts').of(me.id);
+ const dms = convs.map((c, i) => {
+ const others = participants[i].filter((p) => p.id !== me.id);
+ const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
+ return {
+ id: c.id, title, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet',
+ unread: Number(c.unread_count) || 0, presence: others.length === 1 ? others[0].presence : null,
+ avatarUrl: others.length === 1 ? others[0].avatar_url : '',
+ };
+ });
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
+ actor, shell: true, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
page: { view: 'inbox', unread, me: me.id },
page: { view: 'inbox', unread, me: me.id },
- body: `${ui.head({ title: 'Messages', sub: `<span id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</span>` })}${error}${done}
-<form class="oc-find" method="post" action="/messages/new" role="search">${IC.pencil}<label for="oc-to" class="oc-sr">New message: their username</label><input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="New message: their username" autocomplete="off" enterkeyhint="go"><button type="submit" class="oc-send" aria-label="Start the conversation" title="Start">${IC.send}</button></form>
+ body: shell.shell({
+ rail: shell.rail({ home: true, dmUnread: unread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
+ sideLabel: 'Home',
+ main: `${ui.head({ title: 'Messages', sub: `<span id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</span>`, actions: [`<details class="oc-new-picker"><summary class="oc-icon-btn" aria-label="New message" title="New message">${IC.pencil}<span>New</span></summary><div class="oc-new-pop"><label for="oc-people-search">Find someone</label><input id="oc-people-search" type="search" minlength="2" autocomplete="off" placeholder="Search people"><ul id="oc-people-results" aria-live="polite">${recentPicker || '<li class="oc-muted">Search by name or username</li>'}</ul></div></details>`] })}${error}${done}
<ul class="oc-list" id="oc-convs">${rows.join('\n') || '<li class="oc-row-empty oc-muted">No conversations yet. Write a username above to start one.</li>'}</ul>
<ul class="oc-list" id="oc-convs">${rows.join('\n') || '<li class="oc-row-empty oc-muted">No conversations yet. Write a username above to start one.</li>'}</ul>
${supportList}
${supportList}
${blockedList}`,
${blockedList}`,
+ mainLabel: 'Messages',
+ page: true,
+ }),
});
});
});
});
@@ -395,6 +417,31 @@ ${blockedList}`,
@@ -395,6 +417,31 @@ ${blockedList}`,
res.redirect(303, back);
res.redirect(303, back);
});
});
+ // ── Friends and requests (the side column on Home) ──
+ router.post('/messages/contacts', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const name = String((req.body && req.body.username) || '').trim();
+ const back = '/messages';
+ if (!/^[A-Za-z0-9_]{3,24}$/.test(name)) return res.redirect(303, `${back}?error=${encodeURIComponent('That is not a username')}`);
+ const target = await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);
+ if (!target) return res.redirect(303, `${back}?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
+ if (Number(target.id) === Number(actor.user.id)) return res.redirect(303, back);
+ const status = await require('../chat/contacts').add(actor.user.id, target.id);
+ res.redirect(303, status === 'friend' ? `${back}?added=1` : `${back}?asked=1`);
+ });
+
+ router.post('/messages/contacts/:userId/accept', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ await require('../chat/contacts').accept(actor.user.id, Number(req.params.userId));
+ res.redirect(303, '/messages');
+ });
+
+ router.post('/messages/contacts/:userId/remove', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ await require('../chat/contacts').remove(actor.user.id, Number(req.params.userId));
+ res.redirect(303, '/messages');
+ });
+
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const name = String((req.body && req.body.username) || '').trim();
const name = String((req.body && req.body.username) || '').trim();
@@ -412,6 +459,7 @@ ${blockedList}`,
@@ -412,6 +459,7 @@ ${blockedList}`,
router.get('/messages/:id', async (req, res) => {
router.get('/messages/:id', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
+ const preferences = await prefsOf(actor);
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return await page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
if (!Number.isInteger(id) || !await dm.isParticipant(id, me.id)) return await page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);
const others = (await dm.getParticipants(id)).filter((p) => p.id !== me.id);
@@ -419,6 +467,10 @@ ${blockedList}`,
@@ -419,6 +467,10 @@ ${blockedList}`,
const conv = await dm.getConversation(id);
const conv = await dm.getConversation(id);
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);
const msgs = (await dm.getMessages(id, 60)).slice().sort((a, b) => a.id - b.id);
+ const savedView = await db.get('SELECT view FROM dm_view_preferences WHERE user_id = ? AND conversation_id = ?', [me.id, id]).catch(() => null);
+ const viewKind = others.length <= 1 ? 'one_to_one' : 'group';
+ const viewDefaults = await require('../chat/view-defaults').of(me.id);
+ const view = savedView?.view || viewDefaults[viewKind] || (viewKind === 'one_to_one' ? 'bubbles' : 'room');
try { await dm.markRead(id, me.id); } catch { /* best effort */ }
try { await dm.markRead(id, me.id); } catch { /* best effort */ }
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
// A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.
// A 1:1 conversation: block or unblock the other person here; a block (either way) closes the composer.
@@ -431,21 +483,43 @@ ${blockedList}`,
@@ -431,21 +483,43 @@ ${blockedList}`,
const otherPresence = other ? other.presence : null;
const otherPresence = other ? other.presence : null;
const liveHref = otherPresence && (otherPresence.channel_url || otherPresence.url);
const liveHref = otherPresence && (otherPresence.channel_url || otherPresence.url);
const subLine = other ? (ui.presenceLine(otherPresence, { liveHref }) || `@${esc(other.username || '')}`) : `${others.length + 1} people`;
const subLine = other ? (ui.presenceLine(otherPresence, { liveHref }) || `@${esc(other.username || '')}`) : `${others.length + 1} people`;
- const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar' }) : '';
+ const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar', avatarUrl: other.avatar_url }) : '';
let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;
let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
else if (closed) composer = notice('error', 'You cannot message this person.');
else if (closed) composer = notice('error', 'You cannot message this person.');
// Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.
// Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.
- const tools = other && !iBlocked ? `<details class="oc-more"><summary class="oc-icon-btn" aria-label="More" title="More">${IC.more}</summary><div class="oc-pop"><form method="post" action="/messages/${id}/block"><button type="submit" class="oc-pop-item oc-danger">${IC.ban}<span>Block ${otherName}</span></button></form></div></details>` : '';
+ const tools = !closed ? `<details class="oc-more"><summary class="oc-icon-btn" aria-label="More" title="More">${IC.more}</summary><div class="oc-pop"><button type="button" class="oc-pop-item" id="oc-view-toggle" aria-label="Show as ${view === 'bubbles' ? 'a chat room' : 'bubbles'}" data-view="${view}">${IC.view}<span>${view === 'bubbles' ? 'Show as a chat room' : 'Show as bubbles'}</span></button>${other && !iBlocked ? `<form method="post" action="/messages/${id}/block"><button type="submit" class="oc-pop-item oc-danger">${IC.ban}<span>Block ${otherName}</span></button></form>` : ''}</div></details>` : '';
// A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).
// A 1:1 conversation nobody blocked can become a call (POST /messages/:id/call rings the other person).
const callForm = other && !closed && require('../config').calls.enabled
const callForm = other && !closed && require('../config').calls.enabled
- ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}</button></form>` : '';
+ ? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-icon-btn" aria-label="Call ${otherName}" title="Call ${otherName}">${IC.phone}<span class="oc-call-label">Call</span></button></form>` : '';
+ // The DMs down the side, so the conversation can be swapped without going back (the app shell).
+ const convs = await dm.getConversations(me.id);
+ const parts = await Promise.all(convs.map((c) => dm.getParticipants(c.id)));
+ await presence.attachTo(me.subject_id, parts.flat());
+ const dms = convs.map((c, i) => {
+ const others = parts[i].filter((p) => p.id !== me.id);
+ const name = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
+ return {
+ id: c.id, title: name, preview: c.last_message ? String(c.last_message).slice(0, 120) : 'No messages yet',
+ unread: Number(c.unread_count) || 0, presence: others.length === 1 ? others[0].presence : null,
+ avatarUrl: others.length === 1 ? others[0].avatar_url : '', current: Number(c.id) === Number(id),
+ };
+ });
+ const contacts = await require('../chat/contacts').of(me.id);
+ const mineServers = (await require('../rooms/servers').list(me)).mine;
+ const dmUnread = dms.reduce((n, d) => n + (d.current ? 0 : d.unread), 0);
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
- page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },
- body: `${ui.head({ title: esc(title), sub: `<span id="oc-dm-status">${subLine}</span>`, avatar, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}
-<ol class="oc-feed oc-room-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
+ actor, shell: true, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
+ page: { view: 'dm', conversation: id, messageView: view, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id, other: other ? { id: other.id, username: other.username || '' } : null },
+ body: shell.shell({
+ rail: shell.rail({ home: true, dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
+ sideLabel: 'Home',
+ main: `${ui.head({ title: esc(title), sub: `<span id="oc-dm-status">${subLine}</span>`, avatar, back: { href: '/messages', label: 'All messages' }, actions: [callForm, tools].filter(Boolean) })}${error}
+<ol class="oc-feed oc-room-feed${view === 'bubbles' ? ' oc-bubbles' : ''}" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me, view)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
${composer}`,
${composer}`,
+ mainLabel: title,
+ }),
});
});
});
});
@@ -532,7 +606,7 @@ ${composer}`,
@@ -532,7 +606,7 @@ ${composer}`,
router.get('/settings', async (req, res) => {
router.get('/settings', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const sid = actor.user.subject_id;
const sid = actor.user.subject_id;
- const [p, tts, dmSet, pres] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid)]);
+ const [p, tts, dmSet, pres, viewDefaults] = await Promise.all([prefsOf(actor), prefStores.settingsOf(prefStores.tts, sid), prefStores.settingsOf(prefStores.dm, sid), prefStores.settingsOf(prefStores.presence, sid), require('../chat/view-defaults').of(actor.user.id)]);
const src = tts.sources || {};
const src = tts.sources || {};
// A settings row: what it does on the left (with one muted line when it needs saying), the switch on the right.
// A settings row: what it does on the left (with one muted line when it needs saying), the switch on the right.
const box = (name, label, on, hint = '') => `<label class="oc-set-row"><span class="oc-set-text">${label}${hint ? `<small>${hint}</small>` : ''}</span><input type="checkbox" class="oc-switch" name="${name}" value="1"${on ? ' checked' : ''}></label>`;
const box = (name, label, on, hint = '') => `<label class="oc-set-row"><span class="oc-set-text">${label}${hint ? `<small>${hint}</small>` : ''}</span><input type="checkbox" class="oc-switch" name="${name}" value="1"${on ? ' checked' : ''}></label>`;
@@ -551,6 +625,8 @@ ${box('compact', 'Compact messages', p.compact === true)}
@@ -551,6 +625,8 @@ ${box('compact', 'Compact messages', p.compact === true)}
${box('show_badges', 'Staff and moderator badges', p.show_badges !== false)}
${box('show_badges', 'Staff and moderator badges', p.show_badges !== false)}
${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
<div class="oc-set-row"><span class="oc-set-text" id="oc-size-l">Text size</span><span class="oc-seg" role="radiogroup" aria-labelledby="oc-size-l">${sizes}</span></div>
<div class="oc-set-row"><span class="oc-set-text" id="oc-size-l">Text size</span><span class="oc-seg" role="radiogroup" aria-labelledby="oc-size-l">${sizes}</span></div>
+<h3>Conversation view</h3>
+${[['one_to_one','One-to-one chats','bubbles'],['ai','Chats with Vibe','bubbles'],['group','Group conversations','room'],['channel','Channels and stream chat','room']].map(([key,label,fallback]) => `<label class="oc-set-row"><span class="oc-set-text">${label}</span><select name="view_${key}"><option value="bubbles"${((viewDefaults && viewDefaults[key]) || fallback) === 'bubbles' ? ' selected' : ''}>Bubbles</option><option value="room"${((viewDefaults && viewDefaults[key]) || fallback) === 'room' ? ' selected' : ''}>Chat room</option></select></label>`).join('')}
</section>
</section>
<section class="oc-set" aria-labelledby="oc-set-dm"><h2 id="oc-set-dm">Direct messages</h2>
<section class="oc-set" aria-labelledby="oc-set-dm"><h2 id="oc-set-dm">Direct messages</h2>
<label class="oc-set-row"><span class="oc-set-text">Who can start one with you</span><select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody</option></select></label>
<label class="oc-set-row"><span class="oc-set-text">Who can start one with you</span><select name="new_conversations"><option value="everyone"${dmSet.new_conversations !== 'nobody' ? ' selected' : ''}>Everyone</option><option value="nobody"${dmSet.new_conversations === 'nobody' ? ' selected' : ''}>Nobody</option></select></label>
@@ -649,6 +725,14 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -649,6 +725,14 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
const sid = actor.user.subject_id;
const sid = actor.user.subject_id;
try {
try {
await prefs.update(sid, patch);
await prefs.update(sid, patch);
+ // The view defaults are Chat's own (server/chat/view-defaults.js): the Network module has no field for
+ // them yet, and a refused patch would lose the whole save.
+ await require('../chat/view-defaults').save(actor.user.id, {
+ one_to_one: b.view_one_to_one === 'room' ? 'room' : 'bubbles',
+ ai: b.view_ai === 'room' ? 'room' : 'bubbles',
+ group: b.view_group === 'bubbles' ? 'bubbles' : 'room',
+ channel: b.view_channel === 'bubbles' ? 'bubbles' : 'room',
+ });
await prefStores.dm.update(sid, { new_conversations: b.new_conversations === 'nobody' ? 'nobody' : null, group_invites: on('group_invites') ? null : false, previews: on('previews') ? null : false });
await prefStores.dm.update(sid, { new_conversations: b.new_conversations === 'nobody' ? 'nobody' : null, group_invites: on('group_invites') ? null : false, previews: on('previews') ? null : false });
await prefStores.presence.update(sid, { show_in_user_list: on('show_in_user_list') ? null : false });
await prefStores.presence.update(sid, { show_in_user_list: on('show_in_user_list') ? null : false });
await prefStores.tts.update(sid, {
await prefStores.tts.update(sid, {
@@ -17,6 +17,7 @@ const { esc } = require('./layout');
@@ -17,6 +17,7 @@ const { esc } = require('./layout');
const ui = require('./ui');
const ui = require('./ui');
const { ICONS: IC } = require('./icons');
const { ICONS: IC } = require('./icons');
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
+const shell = require('./shell');
const servers = require('../rooms/servers');
const servers = require('../rooms/servers');
const ctx = require('../live-context');
const ctx = require('../live-context');
@@ -49,37 +50,117 @@ function mountServerPages(router, h) {
@@ -49,37 +50,117 @@ function mountServerPages(router, h) {
const serverRow = (s) => `<li class="oc-row${s.unread ? ' oc-unread' : ''}"><a href="/s/${esc(s.slug)}">${ui.serverMark(s)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(s.name)}</strong>${s.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${s.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
const serverRow = (s) => `<li class="oc-row${s.unread ? ' oc-unread' : ''}"><a href="/s/${esc(s.slug)}">${ui.serverMark(s)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(s.name)}</strong>${s.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${s.visibility === 'private' ? `<span class="oc-row-lock" title="Private">${IC.lock}<span class="oc-sr">private</span></span>` : ''}</span>`
+ `<span class="oc-row-sub">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}${s.unread ? `<span class="oc-dot" aria-label="${s.unread} new">${s.unread > 9 ? '9+' : s.unread}</span>` : ''}</span></span></a></li>`;
+ `<span class="oc-row-sub">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}${s.unread ? `<span class="oc-dot" aria-label="${s.unread} new">${s.unread > 9 ? '9+' : s.unread}</span>` : ''}</span></span></a></li>`;
- // ── The directory ──
+ // ── Explore: the servers on OpenVibe, and the sheet that starts one ──
router.get('/servers', async (req, res) => {
router.get('/servers', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const me = userOf(actor);
const me = userOf(actor);
const { public: pub, mine } = await servers.list(me);
const { public: pub, mine } = await servers.list(me);
- // Your servers are listed once, in their own list.
+ const templates = require('../rooms/server-templates');
+ const q = String(req.query.q || '').trim().toLowerCase();
+ const cat = String(req.query.category || '');
+ // Every server the person may see: the ones they are in, then the public ones (the official server is
+ // always first). "No other public server yet." is never shown: the official server and Start a server
+ // are always there.
const yours = new Set(mine.map((s) => s.slug));
const yours = new Set(mine.map((s) => s.slug));
- const others = pub.filter((s) => !yours.has(s.slug));
- const create = me ? `<form class="oc-form oc-grid-form oc-create" id="oc-new-server" method="post" action="/servers/new">
+ const all = [...mine, ...pub.filter((s) => !yours.has(s.slug))];
+ const listed = all.filter((s) => (!q || `${s.name} ${s.description || ''}`.toLowerCase().includes(q))
+ && (!cat || s.category === cat));
+ // How many of each server's people are here now (Network's presence, one read, cached 30 s).
+ const online = {};
+ if (me && listed.length) {
+ const ids = listed.map((s) => Number(s.id)).filter((n) => Number.isInteger(n));
+ const rows = ids.length ? await require('../db/database').all(`SELECT s.slug AS slug, u.subject_id AS subject FROM chat_servers s
+ JOIN chat_server_members m ON m.server_id = s.id AND m.role != 'blocked'
+ JOIN ctx_users u ON u.id = m.user_id WHERE s.id IN (${ids.map(() => '?').join(',')})`, ids) : [];
+ const people = rows.map((r) => ({ slug: r.slug, subject_id: r.subject }));
+ await require('../presence').attachTo(me.subject_id, people);
+ for (const p of people) if (p.presence) online[p.slug] = (online[p.slug] || 0) + 1;
+ }
+ const chip = (name, href) => `<a class="oc-chip${name === cat ? ' is-active' : ''}" href="${esc(href)}">${esc(name || 'All')}</a>`;
+ // Chat never writes ?q= back into a page (test/security-private.test.js): the chips do not carry it.
+ const chips = `<nav class="oc-chips" aria-label="Categories">${chip('', '/servers')}${templates.CATEGORIES.map((c) => chip(c, `/servers?category=${encodeURIComponent(c)}`)).join('')}</nav>`;
+ // One server as a card: its banner, its icon, its name, one line about it, its people, and a way in.
+ const card = (sv) => {
+ const isMember = yours.has(sv.slug);
+ const join = isMember
+ ? `<a class="oc-button oc-button--quiet" href="/s/${esc(sv.slug)}">Open</a>`
+ : (me ? `<form method="post" action="/s/${esc(sv.slug)}/join"><button type="submit">Join</button></form>`
+ : `<a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/s/${sv.slug}`)}">Sign in to join</a>`);
+ return `<article class="oc-card oc-server-card">
+<div class="oc-card-banner" style="--oc-hue:${hueOf(sv.slug)}" aria-hidden="true"></div>
+<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(sv.slug)}" aria-hidden="true">${esc(initials(sv.name))}</span>
+<h3>${esc(sv.name)}${sv.official ? ' <span class="oc-badge oc-badge-official">official</span>' : ''}${sv.category ? ` <span class="oc-badge">${esc(sv.category)}</span>` : ''}</h3>
+<p class="oc-card-line">${esc(sv.description || 'A server on OpenVibe.Chat.')}</p>
+<p class="oc-muted oc-card-meta">${Number(sv.members) || 0} member${Number(sv.members) === 1 ? '' : 's'}${online[sv.slug] ? ` · ${online[sv.slug]} online` : ''}</p>
+${join}</article>`;
+ };
+ const startCard = `<article class="oc-card oc-server-card oc-server-card--new"><h3>Start a server</h3><p class="oc-card-line">A template, a name and a picture: under ten seconds.</p><a class="oc-button" href="#oc-new-server">Start a server</a></article>`;
+ // The sheet: a template first, then a name and a picture, then public or private.
+ const sheet = me ? `<details class="oc-sheet" id="oc-new-server"><summary class="oc-button">Start a server</summary>
+<form class="oc-form oc-sheet-form" method="post" action="/servers/new" enctype="multipart/form-data">
<h2>Start a server</h2>
<h2>Start a server</h2>
-<p class="oc-muted oc-small">Text channels to write in and voice channels to talk in, with one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>
-<label>Name <input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>
-<label>Description <input name="description" maxlength="300" placeholder="What it is for (optional)"></label>
-<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people with an invite can find, read or join it</label>
+<fieldset class="oc-sheet-step"><legend>1. What is it for?</legend>
+<div class="oc-templates">${templates.TEMPLATES.map((t, i) => `<label class="oc-template"><input type="radio" name="template" value="${esc(t.id)}"${i === 0 ? ' checked' : ''}><span class="oc-template-body"><strong>${esc(t.name)}</strong><span class="oc-muted">${esc(t.description)}</span><span class="oc-template-channels">${t.channels.map((c) => esc(c.kind === 'voice' ? '🔊 ' : '# ') + esc(c.name)).join(' · ')}</span></span></label>`).join('')}</div>
+</fieldset>
+<fieldset class="oc-sheet-step"><legend>2. Its name and picture</legend>
+<div class="oc-sheet-fields">
+<label class="oc-field"><span>Name</span><input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>
+<label class="oc-field"><span>Category</span><select name="category"><option value="">No category</option>${templates.CATEGORIES.map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select></label>
+<label class="oc-field oc-field--wide"><span>Description</span><input name="description" maxlength="300" placeholder="What it is for (optional)"></label>
+<label class="oc-field oc-field--wide"><span>Picture</span><input type="file" name="picture" accept="image/png,image/jpeg,image/webp"><small class="oc-muted">PNG, JPEG or WebP. Without one, its initials on its colour.</small></label>
+</div>
+</fieldset>
+<fieldset class="oc-sheet-step"><legend>3. Who may find it?</legend>
+<div class="oc-seg" role="radiogroup"><label><input type="radio" name="visibility" value="public" checked><span>Public</span></label><label><input type="radio" name="visibility" value="private"><span>Private</span></label></div>
+<p class="oc-muted oc-small">A public server is listed in Explore and anyone may join it. A private one is joined through an invite.</p>
+</fieldset>
<p><button type="submit">Create server</button></p>
<p><button type="submit">Create server</button></p>
-</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;
+</form></details>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;
+ const side = `<div class="oc-side-top"><a class="oc-side-find" href="#oc-new-server">${IC.plus}<span>Start a server</span></a></div>
+<section class="oc-side-sec"><h2 class="oc-side-group">Your servers</h2>
+${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"><a href="/s/${esc(sv.slug)}">${ui.serverMark(sv)}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(sv.name)}</strong></span><span class="oc-row-sub">${Number(sv.members) || 0} member${Number(sv.members) === 1 ? '' : 's'}</span></span></a></li>`).join('')}</ul>` : `<p class="oc-side-empty oc-muted">You are in no servers yet.</p>`}
+</section>`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'compact',
+ actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none',
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
- body: `${ui.head({ title: 'Servers', sub: 'Groups with text and voice channels, roles and invites', actions: me ? [`<a class="oc-icon-btn" href="#oc-new-server" aria-label="Start a server" title="Start a server">${IC.plus}</a>`] : [] })}${errorOf(req)}
-${mine.length ? `<h2 class="oc-list-head">Your servers</h2><ul class="oc-list">${mine.map(serverRow).join('')}</ul>` : ''}
-<h2 class="oc-list-head">${mine.length ? 'More public servers' : 'Public servers'}</h2>${others.length ? `<ul class="oc-list">${others.map(serverRow).join('')}</ul>` : `<p class="oc-rooms-empty oc-muted">${mine.length ? 'No other public server yet.' : 'No public server yet. Start one: it can be public, or private to the people you invite.'}</p>`}
-${create}`,
+ body: shell.shell({
+ rail: shell.rail({ explore: true, dmUnread: me ? await require('../chat/dm').getTotalUnread(me.id) : 0, servers: mine.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ side, sideLabel: 'Your servers',
+ main: `${ui.head({ title: 'Explore servers', sub: `${listed.length} server${listed.length === 1 ? '' : 's'}` })}
+<form class="oc-find" method="get" action="/servers" role="search">${IC.search}<label for="oc-server-q" class="oc-sr">Find a server</label><input id="oc-server-q" name="q" value="" placeholder="Find a server" autocomplete="off"><button type="submit" class="oc-send" aria-label="Find" title="Find">${IC.search}</button></form>
+${chips}
+<div class="oc-cards">${listed.map(card).join('')}${startCard}</div>
+${sheet}`,
+ mainLabel: 'Explore servers',
+ page: true,
+ }),
});
});
});
});
- router.post('/servers/new', formLimit, sameSite, orError(async (req, res) => {
+ // The Start sheet's picture (multipart; the other fields are ordinary form fields).
+ const serverPicture = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: 1, fields: 12 } }).single('picture');
+
+ router.post('/servers/new', serverPicture, formLimit, sameSite, orError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
const b = req.body || {};
- const s = await servers.create(actor.user, { name: b.name, description: b.description, visibility: b.private === '1' ? 'private' : 'public' });
- res.redirect(303, `/s/${s.slug}`);
+ // A picture, when one was sent: OpenVibe.Pics, as the person (server/rooms/pictures.js).
+ let picture = null;
+ try {
+ const pics = require('../rooms/pictures');
+ if (pics.enabled() && req.file && req.file.buffer) {
+ const up = await pics.upload({ token: req.cookies && req.cookies.ov_token, buffer: req.file.buffer, contentType: req.file.mimetype, title: `Server: ${String(b.name || '').slice(0, 40)}`, ip: req.ip });
+ if (up && up.url) picture = up.url;
+ }
+ } catch { /* no picture: the monogram tile is the icon */ }
+ const s = await servers.create(actor.user, {
+ name: b.name, description: b.description,
+ // The sheet sends visibility; the older form sent private=1.
+ visibility: (b.visibility === 'private' || b.private === '1') ? 'private' : 'public',
+ category: b.category, picture_url: picture, template: b.template,
+ });
+ // The invite link shows at the end, with Copy and Share (the sheet's last step).
+ const invite = await servers.createInvite(s, actor.user, { max_uses: null, expires_hours: null }).catch(() => null);
+ res.redirect(303, `/s/${s.slug}${invite && invite.code ? `?invite=${encodeURIComponent(invite.code)}` : ''}`);
}));
}));
/** The server behind :slug for this viewer (404 page when they may not read it). */
/** The server behind :slug for this viewer (404 page when they may not read it). */
@@ -107,18 +188,24 @@ ${create}`,
@@ -107,18 +188,24 @@ ${create}`,
const msgs = await rooms.history(room, { limit: 60 });
const msgs = await rooms.history(room, { limit: 60 });
if (me && rcan.role) await rooms.markRead(room, me);
if (me && rcan.role) await rooms.markRead(room, me);
const mine = me ? (await servers.list(me)).mine : [];
const mine = me ? (await servers.list(me)).mine : [];
+ const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
const people = await servers.members(server, { limit: 300 });
const people = await servers.members(server, { limit: 300 });
+ // The people's presence (Network, cached 30 s) and their mark.
+ await require('../presence').attachTo(me ? me.subject_id : null, people.map((p) => ({ ...p, id: p.user_id })));
const isVoice = room.kind === 'call';
const isVoice = room.kind === 'call';
const callReady = isVoice && !!require('../config').calls.enabled;
const callReady = isVoice && !!require('../config').calls.enabled;
const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };
const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };
- const chanLink = (c) => {
- const here = c.slug === pick.slug;
- const unread = !here && c.unread ? `<span class="oc-chan-dot" aria-label="${c.unread} unread">${c.unread > 99 ? '99+' : c.unread}</span>` : '';
- const inCall = c.kind === 'voice' ? (callOf(c) || {}).participants : 0;
- return `<li><a class="oc-chan${c.kind === 'voice' ? ' oc-chan-voice' : ''}${here ? ' is-active' : ''}${unread ? ' is-unread' : ''}" href="/s/${esc(server.slug)}/${esc(c.slug)}"${here ? ' aria-current="page"' : ''}>${c.kind === 'voice' ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(c.name)}</span>${inCall ? `<span class="oc-muted oc-chan-in">${inCall} in</span>` : ''}${unread}</a></li>`;
- };
- const texts = chans.filter((c) => c.kind === 'text');
- const voices = chans.filter((c) => c.kind === 'voice');
+ // The people in each voice channel (the plan: "voice channels with who is in them").
+ const occupants = {};
+ for (const c of chans) if (c.kind === 'voice') occupants[c.slug] = ((callOf(c) || {}).people || []).map((p) => ({ username: p.username, display_name: p.display_name }));
+ // The channel categories: a channel's own category, or the kind's default.
+ const categories = [];
+ const byCat = new Map();
+ for (const c of chans) {
+ const name = c.category || (c.kind === 'voice' ? 'Voice channels' : 'Text channels');
+ if (!byCat.has(name)) { byCat.set(name, []); categories.push({ name, channels: byCat.get(name) }); }
+ byCat.get(name).push(c);
+ }
let composer;
let composer;
const back = `/s/${server.slug}/${pick.slug}`;
const back = `/s/${server.slug}/${pick.slug}`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
@@ -127,21 +214,34 @@ ${create}`,
@@ -127,21 +214,34 @@ ${create}`,
else composer = notice('error', 'You cannot write here right now.');
else composer = notice('error', 'You cannot write here right now.');
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(require('../rooms/ai-members').modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(require('../rooms/ai-members').modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
+ // The people, grouped by their role (the members column).
const byRank = {};
const byRank = {};
for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);
for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);
- const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;
- const peopleHtml = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => `<h3 class="oc-srv-group">${RANK_TITLE[r]} <span class="oc-muted">${byRank[r].length}</span></h3><ul class="oc-srv-people-list">${byRank[r].map(person).join('')}</ul>`).join('');
- const tools = [
- can.moderate ? `<a href="/s/${esc(server.slug)}/settings">${can.manage ? 'Server settings' : 'Members and invites'}</a>` : '',
- can.member && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-link">Leave server</button></form>` : '',
- ].filter(Boolean).join(' · ');
+ const roleGroups = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => ({
+ role: r, title: RANK_TITLE[r],
+ people: byRank[r].map((p) => ({
+ id: p.user_id, username: p.username || '', display_name: p.display_name || p.username || '',
+ avatar_url: p.avatar_url || null, presence: p.presence || null,
+ color: /^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? p.profile_color : '',
+ href: `${LIVE}/@${encodeURIComponent(p.username || '')}`,
+ })),
+ }));
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
+ // The sheet's last step: the invite link, with Copy and Share (a fresh server comes with one).
+ const inviteCode = String(req.query.invite || '').toLowerCase();
+ const invite = inviteCode && /^[a-z0-9]{6,16}$/.test(inviteCode) ? await servers.inviteByCode(inviteCode).catch(() => null) : null;
+ const invitePanel = invite && invite.invite ? `<section class="oc-invite-new" aria-label="Your invite link">
+<h2>Your invite link</h2><p class="oc-muted">Anyone with this link can join ${esc(server.name)}.</p>
+<code class="oc-invite-url" id="oc-invite-url">${esc(`${site}/join/${invite.invite.code}`)}</code>
+<p class="oc-invite-tools"><button type="button" class="oc-button" id="oc-invite-copy" data-oc-copy="${esc(`${site}/join/${invite.invite.code}`)}">Copy</button>
+<a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">More invites</a></p>
+</section>` : '';
// The canonical URL is the one served and listed in the sitemap: /s/<server> without a channel in the
// The canonical URL is the one served and listed in the sitemap: /s/<server> without a channel in the
// request, and the channel's own URL when the request named it. `back` stays the channel URL the composer
// request, and the channel's own URL when the request named it. `back` stays the channel URL the composer
// and the sign-in link post to (POST /s/:slug/:channel).
// and the sign-in link post to (POST /s/:slug/:channel).
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
const canonicalPath = req.params.channel ? back : `/s/${server.slug}`;
await page(req, res, 200, {
await page(req, res, 200, {
- actor, wide: true, footer: 'compact', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers',
+ actor, shell: true, footer: 'none', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: canonicalPath, active: 'servers',
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
page: {
@@ -149,31 +249,35 @@ ${create}`,
@@ -149,31 +249,35 @@ ${create}`,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
},
},
- body: `<div class="oc-srv">
-<nav class="oc-srv-rail" aria-label="Your servers">${mine.map((s) => icon(s, s.slug === server.slug)).join('')}${mine.some((s) => s.slug === server.slug) ? '' : icon(servers.publicServer(server), true)}
-<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>
-<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>
-<aside class="oc-srv-side" aria-label="${esc(server.name)}">
-<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.slug === servers.OFFICIAL_SLUG ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}
-<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>
-${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>
-<details class="oc-srv-channels" open><summary>Channels</summary>
-<h2 class="oc-srv-group">Text channels</h2><ul class="oc-srv-list">${texts.map(chanLink).join('')}</ul>
-${voices.length ? `<h2 class="oc-srv-group">Voice channels</h2><ul class="oc-srv-list">${voices.map(chanLink).join('')}</ul>` : ''}
-</details>
-${!can.member && me ? `<form class="oc-srv-join" method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>` : ''}
-${canInvite ? `<p class="oc-srv-invite"><a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">Invite people</a></p>` : ''}
-${tools ? `<p class="oc-srv-tools">${tools}</p>` : ''}
-</aside>
-<section class="oc-srv-main" aria-labelledby="oc-chan-h">
-<header class="oc-chan-head"><h2 id="oc-chan-h">${isVoice ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}${esc(room.name)}</h2>${room.topic ? `<span class="oc-muted oc-chan-topic">${esc(room.topic)}</span>` : ''}<span class="oc-live" id="oc-live" hidden>● live</span></header>
+ body: shell.shell({
+ rail: shell.rail({
+ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })),
+ dmUnread,
+ }),
+ side: shell.sideServer({
+ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can: { ...can, invite: canInvite }, me: !!me,
+ categories: categories.map((c) => ({
+ name: c.name,
+ channels: c.channels.map((ch) => ({
+ kind: ch.kind, name: ch.name, slug: ch.slug, unread: ch.unread,
+ href: `/s/${server.slug}/${ch.slug}`, active: ch.slug === pick.slug,
+ })),
+ })),
+ occupants,
+ you: me ? { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } : {},
+ }),
+ sideLabel: server.name,
+ main: `${shell.channelHeader({ name: room.name, kind: isVoice ? 'voice' : 'text', topic: room.topic, members: true })}
+${invitePanel}
+<span class="oc-live" id="oc-live" hidden>● live</span>
${aiHere}${errorOf(req)}
${aiHere}${errorOf(req)}
${isVoice ? callPanel(room, rcan, me) : ''}
${isVoice ? callPanel(room, rcan, me) : ''}
<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
-${composer}
-</section>
-<aside class="oc-srv-people" aria-label="People in ${esc(server.name)}"><details open><summary>People <span class="oc-muted">${Number(server.member_count) || 0}</span></summary>${peopleHtml}${people.length >= 300 ? '<p class="oc-muted">And more.</p>' : ''}</details></aside>
-</div>`,
+${composer}`,
+ mainLabel: `${isVoice ? '' : '#'}${room.name}`,
+ members: shell.membersColumn({ groups: roleGroups }),
+ membersLabel: `People in ${server.name}`,
+ }),
});
});
}
}
router.get('/s/:slug', serverPage);
router.get('/s/:slug', serverPage);
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+'use strict';
+/**
+ * The app shell on every page of openvibe.chat (the plan, 2026-11-09, stage V2: "the rail, the side column (Home's
+ * DMs and Friends; a server's channels with categories and voice occupants; the you-bar), the members column, the
+ * phone panels with swipe and the Shared appbar as the bottom bar; the channel header; the user popout everywhere;
+ * buttons as the plan says").
+ *
+ * Discord's four columns, our data:
+ *
+ * the rail (72 px) Home (the DMs) and Vibe at the top, then your servers as round icons (their picture, or a
+ * generated monogram tile in the server's colour), each with its unread dot, then Add a
+ * server and Explore
+ * the side column Home: "Find or start a conversation", then Friends, Requests and Vibe, then the DMs
+ * A server: its banner and name with a menu (Invite, Settings, Notifications, Leave),
+ * Events, the channel categories (collapsible) with the people in each voice channel, and
+ * the you-bar at the foot
+ * the middle the conversation, or the channel's messages
+ * the members column the people, grouped by role, with their presence
+ *
+ * On a phone the three columns are panels you swipe between (public/web/chat-shell.js) under Shared's app bar.
+ * Everything here is server-rendered: the page reads and works without JavaScript, and the script only adds the
+ * gestures, the members toggle and the shared user popout.
+ */
+const { esc } = require('./layout');
+const ui = require('./ui');
+const { ICONS: IC } = require('./icons');
+
+/** The five places of the phone's bottom bar: Shared's app bar draws them (appbar.js). */
+const APP_BAR = [
+ { label: 'Home', href: '/messages', icon: 'fa-comment-dots', page: 'messages' },
+ { label: 'Servers', href: '/servers', icon: 'fa-globe', page: 'servers' },
+ { label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles', page: 'vibe' },
+ { label: 'Activity', href: '/notifications', icon: 'fa-bell', page: 'notifications' },
+ { label: 'You', href: '/settings', icon: 'fa-user', page: 'settings' },
+];
+
+/** One icon button: 40 px on a computer, 44 px on a phone (chat-shell.css), never a bare glyph at a random size. */
+const iconBtn = (href, label, svg, extra = '') => `<a class="oc-icon-btn" href="${esc(href)}" aria-label="${esc(label)}" title="${esc(label)}"${extra}>${svg}</a>`;
+
+/**
+ * The rail: Home and Vibe, your servers, then Add a server and Explore.
+ * o: { servers: [{ slug, name, unread, active }], dmUnread, home, vibe, mine }
+ */
+function rail(o = {}) {
+ const servers = o.servers || [];
+ const server = (s) => `<a class="oc-rail-icon${s.active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" title="${esc(s.name)}" style="--oc-hue:${ui.hueOf(s.slug)}"${s.active ? ' aria-current="page"' : ''}><span aria-hidden="true">${esc(ui.serverLetters(s.name))}</span><span class="oc-sr">${esc(s.name)}</span>${s.unread ? `<span class="oc-rail-dot" aria-label="${s.unread} unread">${s.unread > 99 ? '99+' : s.unread}</span>` : ''}</a>`;
+ return `<nav class="oc-rail" aria-label="Chat destinations">
+<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-label="Open the side column" title="Open the side column">${IC.menu}</button>
+<a class="oc-rail-icon oc-rail-home${o.home ? ' is-active' : ''}" href="/messages" title="Home"${o.home ? ' aria-current="page"' : ''}>${IC.chat}<span class="oc-sr">Home</span>${o.dmUnread ? `<span class="oc-rail-dot" aria-label="${o.dmUnread} unread">${o.dmUnread > 99 ? '99+' : o.dmUnread}</span>` : ''}</a>
+<a class="oc-rail-icon oc-rail-vibe${o.vibe ? ' is-active' : ''}" href="/vibe" title="Vibe"${o.vibe ? ' aria-current="page"' : ''}>${IC.spark}<span class="oc-sr">Vibe</span></a>
+<span class="oc-rail-divider" role="separator" aria-hidden="true"></span>
+${servers.map(server).join('')}
+<a class="oc-rail-icon oc-rail-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>
+<a class="oc-rail-icon oc-rail-explore${o.explore ? ' is-active' : ''}" href="/servers" title="Explore servers"${o.explore ? ' aria-current="page"' : ''}>${IC.search}<span class="oc-sr">Explore servers</span></a>
+</nav>`;
+}
+
+/** One person in a list: their mark, their name and a muted line under it. */
+function personRow(person, { href = '', extra = '', title = '', sub = '' } = {}) {
+ const name = person.display_name || person.username || '?';
+ const mark = ui.initials(name, person.presence, { avatarUrl: person.avatar_url });
+ const body = `<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(name)}</strong>${title}</span>${sub ? `<span class="oc-row-sub">${sub}</span>` : ''}</span>`;
+ const inner = href
+ ? `<a href="${esc(href)}"${person.id ? ` data-oc-user="${Number(person.id)}"` : ''}>${mark}${body}</a>`
+ : `<span>${mark}${body}</span>`;
+ return `<li class="oc-row${person.presence ? ' is-online' : ''}">${inner}${extra}</li>`;
+}
+
+/**
+ * The side column on Home: find or start a conversation, then Friends, Requests and Vibe, then the DMs.
+ * o: { dms: [{ id, title, preview, at, unread, presence, avatarUrl }], friends, requests, current }
+ */
+function sideHome(o = {}) {
+ const dms = o.dms || [];
+ const dmRow = (d) => `<li class="oc-row${d.unread ? ' oc-unread' : ''}"><a href="/messages/${d.id}"${d.current ? ' aria-current="page"' : ''}>${ui.initials(d.title, d.presence, { avatarUrl: d.avatarUrl })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(d.title)}</strong>${d.unread ? `<span class="oc-dot" aria-label="${d.unread} new">${d.unread > 9 ? '9+' : d.unread}</span>` : ''}</span><span class="oc-row-sub">${esc(d.preview || '')}</span></span></a></li>`;
+ const friends = o.friends || [];
+ const requests = o.requests || [];
+ return `<div class="oc-side-top">
+<a class="oc-side-find" href="/messages#oc-new">${IC.pencil}<span>Find or start a conversation</span></a>
+</div>
+<section class="oc-side-sec" aria-labelledby="oc-side-friends"><h2 class="oc-side-group" id="oc-side-friends">Friends <span class="oc-muted">${friends.length}</span></h2>
+${friends.length ? `<ul class="oc-side-list">${friends.map((f) => `<li class="oc-row"><form method="post" action="/messages/new"><input type="hidden" name="username" value="${esc(f.username || '')}"><button type="submit" class="oc-side-person">${ui.initials(f.display_name || f.username || '?', f.presence, { avatarUrl: f.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(f.display_name || f.username || '?')}</strong></span><span class="oc-row-sub">@${esc(f.username || '')}</span></span></button></form></li>`).join('')}</ul>`
+ : `<p class="oc-side-empty oc-muted">No friends yet. Start a conversation and add the people you talk to.</p>`}
+<form class="oc-side-add" method="post" action="/messages/contacts"><label class="oc-sr" for="oc-add-friend">Add a friend by username</label><input id="oc-add-friend" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="Add a friend by username" autocomplete="off"><button type="submit">Add</button></form>
+</section>
+<section class="oc-side-sec" aria-labelledby="oc-side-requests"><h2 class="oc-side-group" id="oc-side-requests">Requests <span class="oc-muted">${requests.length}</span></h2>
+${requests.length ? `<ul class="oc-side-list">${requests.map((r) => personRow(r, { extra: `<form class="oc-inline" method="post" action="/messages/contacts/${Number(r.id)}/accept"><button type="submit" class="oc-link">Accept</button></form>` })).join('')}</ul>`
+ : `<p class="oc-side-empty oc-muted">No requests.</p>`}
+</section>
+<section class="oc-side-sec" aria-labelledby="oc-side-vibe"><h2 class="oc-side-group" id="oc-side-vibe">Vibe</h2>
+<ul class="oc-side-list"><li class="oc-row"><a href="/vibe">${IC.spark}<span class="oc-row-main"><span class="oc-row-top"><strong>Vibe</strong></span><span class="oc-row-sub">OpenVibe's assistant</span></span></a></li></ul>
+</section>
+<section class="oc-side-sec" aria-labelledby="oc-side-dms"><h2 class="oc-side-group" id="oc-side-dms">Direct messages</h2>
+${dms.length ? `<ul class="oc-side-list">${dms.map(dmRow).join('')}</ul>` : `<p class="oc-side-empty oc-muted">No conversations yet.</p>`}
+</section>
+${youBar(o.you || {})}`;
+}
+
+/**
+ * A voice channel with the people in it (the plan: "voice channels with who is in them").
+ * o: { name, href, occupants: [{ display_name, username }], active }
+ */
+function voiceRow(o) {
+ const occupants = (o.occupants || []).slice(0, 4);
+ const names = occupants.map((p) => esc(p.display_name || p.username || '?'));
+ return `<li><a class="oc-chan oc-chan-voice${o.active ? ' is-active' : ''}" href="${esc(o.href)}"${o.active ? ' aria-current="page"' : ''}>${IC.voice}<span class="oc-chan-name">${esc(o.name)}</span></a>
+${names.length ? `<ul class="oc-voice-people">${names.map((n) => `<li>${n}</li>`).join('')}</ul>` : ''}</li>`;
+}
+
+/**
+ * The side column in a server: its banner and name with a menu, Events, the channel categories with the people in
+ * each voice channel, and the you-bar.
+ * o: { server, can, me, categories: [{ name, channels: [{ kind, name, href, slug, active, unread }] }], occupants:
+ * { slug: [{ display_name }] }, events, you }
+ */
+function sideServer(o = {}) {
+ const server = o.server || {};
+ const can = o.can || {};
+ const me = !!o.me;
+ const categories = o.categories || [];
+ const menu = [];
+ if (can.invite) menu.push(`<a class="oc-pop-item" href="/s/${esc(server.slug)}/settings#oc-invites">${IC.members}<span>Invite people</span></a>`);
+ if (can.manage || can.moderate) menu.push(`<a class="oc-pop-item" href="/s/${esc(server.slug)}/settings">${IC.gear}<span>Server settings</span></a>`);
+ if (can.member && can.role !== 'owner') menu.push(`<form method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-pop-item">${IC.leave}<span>Leave server</span></button></form>`);
+ const cat = (c) => `<section class="oc-side-sec oc-side-cat"><details open><summary class="oc-side-group">${esc(c.name)}</summary>
+<ul class="oc-side-list">${c.channels.map((ch) => (ch.kind === 'voice'
+ ? voiceRow({ name: ch.name, href: ch.href, active: ch.active, occupants: (o.occupants || {})[ch.slug] || [] })
+ : `<li><a class="oc-chan${ch.active ? ' is-active' : ''}${ch.unread ? ' is-unread' : ''}" href="${esc(ch.href)}"${ch.active ? ' aria-current="page"' : ''}><span class="oc-chan-hash" aria-hidden="true">#</span><span class="oc-chan-name">${esc(ch.name)}</span>${ch.unread ? `<span class="oc-chan-dot" aria-label="${ch.unread} unread">${ch.unread > 99 ? '99+' : ch.unread}</span>` : ''}</a></li>`)).join('')}</ul></details></section>`;
+ return `<header class="oc-side-head">
+<h1 class="oc-side-name">${esc(server.name || '')}</h1>${server.official ? '<span class="oc-badge oc-badge-official">official</span>' : ''}${server.visibility === 'private' ? `<span class="oc-badge">${IC.lock}<span class="oc-sr">private</span></span>` : ''}
+<p class="oc-muted oc-side-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${can.role ? ` · you are ${esc(can.role)}` : ''}</p>
+${server.description ? `<p class="oc-side-desc">${esc(server.description)}</p>` : ''}
+${menu.length ? `<details class="oc-more oc-side-more"><summary class="oc-icon-btn" aria-label="Server menu" title="Server menu">${IC.more}</summary><div class="oc-pop">${menu.join('')}</div></details>` : ''}
+</header>
+<a class="oc-side-event" href="/s/${esc(server.slug || '')}/events">${IC.clock}<span>Events</span></a>
+${can.member ? '' : me ? `<form class="oc-side-join" method="post" action="/s/${esc(server.slug || '')}/join"><button type="submit">Join ${esc(server.name || 'this server')}</button></form>` : `<p class="oc-side-join"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/s/${server.slug || ''}`)}">Sign in to join</a></p>`}
+${categories.map(cat).join('')}
+${youBar(o.you || {})}`;
+}
+
+/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */
+function youBar(o = {}) {
+ if (!o.name) return '';
+ return `<div class="oc-you"><a class="oc-you-id" href="/settings">${ui.initials(o.name, o.presence, { avatarUrl: o.avatarUrl })}<span class="oc-you-names"><strong>${esc(o.name)}</strong><span class="oc-muted">${esc(o.status || 'Online')}</span></span></a>
+<span class="oc-you-tools"><a class="oc-icon-btn" href="/settings#oc-sound" aria-label="Mute" title="Mute">${IC.alert}</a><a class="oc-icon-btn" href="/settings" aria-label="Settings" title="Settings">${IC.gear}</a></span></div>`;
+}
+
+/**
+ * The members column: the people, grouped by role, with their presence.
+ * o: { groups: [{ role, title, people: [] }], label }
+ */
+function membersColumn(o = {}) {
+ const groups = o.groups || [];
+ return `<div class="oc-members-head"><h2 class="oc-side-group">People</h2><button type="button" class="oc-icon-btn oc-members-close" data-oc-panel="main" aria-label="Hide the people" title="Hide the people">${IC.back}</button></div>
+${groups.map((g) => `<section class="oc-members-sec"><h3 class="oc-side-group">${esc(g.title)} <span class="oc-muted">${g.people.length}</span></h3>
+<ul class="oc-side-list">${g.people.map((p) => `<li class="oc-row oc-member-row${p.presence ? ' is-online' : ''}"><a href="${p.href || '#'}"${p.id ? ` data-oc-user="${Number(p.id)}"` : ''}>${ui.initials(p.display_name || p.username || '?', p.presence, { avatarUrl: p.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong${p.color ? ` style="--nc:${esc(p.color)}"` : ''}>${esc(p.display_name || p.username || '?')}</strong></span><span class="oc-row-sub">${esc(p.username ? `@${p.username}` : '')}</span></span></a></li>`).join('')}</ul></section>`).join('')}`;
+}
+
+/**
+ * The channel header: the channel's name and topic, and the buttons that work (the people, the server's menu).
+ * o: { name, kind, topic, href, members, active }
+ */
+function channelHeader(o = {}) {
+ const kind = o.kind === 'voice' ? IC.voice : '<span class="oc-chan-hash" aria-hidden="true">#</span>';
+ return `<header class="oc-chan-head">
+<h2 id="oc-chan-h" class="oc-chan-title">${kind}${esc(o.name || '')}</h2>
+${o.topic ? `<span class="oc-muted oc-chan-topic">${esc(o.topic)}</span>` : ''}
+<span class="oc-chan-tools">
+<button type="button" class="oc-icon-btn oc-members-toggle" data-oc-panel="members" aria-label="Show the people" title="People" aria-pressed="false">${IC.members}</button>
+${o.back ? iconBtn(o.back.href, o.back.label, IC.back) : ''}
+</span></header>`;
+}
+
+/**
+ * The shell itself: the rail, the side column, the middle and the members column. On a phone the columns are
+ * panels (public/web/chat-shell.js sets data-oc-panel).
+ * o: { rail, side, sideLabel, main, members, membersLabel }
+ */
+function shell(o = {}) {
+ return `<div class="oc-app" data-oc-app data-oc-panel="main">
+${o.rail || ''}
+${o.side ? `<aside class="oc-side" aria-label="${esc(o.sideLabel || 'Chat')}">${o.side}</aside>` : ''}
+<section class="oc-col oc-col-main" aria-label="${esc(o.mainLabel || 'Conversation')}">${o.page ? `<div class="oc-page">${o.main || ''}</div>` : (o.main || '')}</section>
+${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersLabel || 'People')}">${o.members}</aside>` : ''}
+</div>`;
+}
+
+module.exports = { APP_BAR, rail, sideHome, sideServer, membersColumn, channelHeader, youBar, shell, personRow };
@@ -76,7 +76,7 @@ function presenceLine(presence, { liveHref = null } = {}) {
@@ -76,7 +76,7 @@ function presenceLine(presence, { liveHref = null } = {}) {
return `${shown}${presence.status_text ? ` · ${esc(String(presence.status_text).slice(0, 80))}` : ''}`;
return `${shown}${presence.status_text ? ` · ${esc(String(presence.status_text).slice(0, 80))}` : ''}`;
}
}
-function initials(name, presence = null, { large = false, id = '' } = {}) {
+function initials(name, presence = null, { large = false, id = '', avatarUrl = '' } = {}) {
const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
const letters = (words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '?').slice(0, 2)).toUpperCase();
let h = 0;
let h = 0;
@@ -84,8 +84,11 @@ function initials(name, presence = null, { large = false, id = '' } = {}) {
@@ -84,8 +84,11 @@ function initials(name, presence = null, { large = false, id = '' } = {}) {
const cls = `oc-av${large ? ' oc-av-lg' : ''}`;
const cls = `oc-av${large ? ' oc-av-lg' : ''}`;
const idAttr = id ? ` id="${esc(id)}"` : '';
const idAttr = id ? ` id="${esc(id)}"` : '';
const state = presenceState(presence);
const state = presenceState(presence);
- if (!state) return `<span class="${cls}" style="--av:${h}"${idAttr} aria-hidden="true">${esc(letters)}</span>`;
- return `<span class="${cls}" style="--av:${h}"${idAttr} role="img" aria-label="${esc(`${String(name || '?')}, ${PRESENCE[state].a11y}`)}">${esc(letters)}${presenceDot(presence)}</span>`;
+ let photo = '';
+ try { const u = new URL(String(avatarUrl || '')); if (u.protocol === 'https:' && u.hostname.toLowerCase() === 'openvibe.media') photo = `<img src="${esc(u.href)}" alt="" loading="lazy" referrerpolicy="no-referrer">`; } catch { /* initials are the fallback */ }
+ const content = photo || esc(letters);
+ if (!state) return `<span class="${cls}" style="--av:${h}"${idAttr} aria-hidden="true">${content}</span>`;
+ return `<span class="${cls}" style="--av:${h}"${idAttr} role="img" aria-label="${esc(`${String(name || '?')}, ${PRESENCE[state].a11y}`)}">${content}${presenceDot(presence)}</span>`;
}
}
/** An https address a card may link to; anything else (javascript:, http:, a relative path) is no link. */
/** An https address a card may link to; anything else (javascript:, http:, a relative path) is no link. */
@@ -34,7 +34,7 @@ t('export, then erase and confirm', async () => {
@@ -34,7 +34,7 @@ t('export, then erase and confirm', async () => {
{
{
const missing = await uncovered(d, {
const missing = await uncovered(d, {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
- 'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions',
+ 'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_contacts', 'dm_participants', 'dm_view_defaults', 'dm_view_preferences', 'emotes', 'inbox_actions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
exempt: {
exempt: {
@@ -30,7 +30,11 @@ const navLabel = (page, key) => {
@@ -30,7 +30,11 @@ const navLabel = (page, key) => {
t('boot', async () => {
t('boot', async () => {
h = await boot({ env: { CHAT_WEB_URL: SITE } });
h = await boot({ env: { CHAT_WEB_URL: SITE } });
alice = h.addUser('alice', { subject: ids.newId('user') });
alice = h.addUser('alice', { subject: ids.newId('user') });
+ alice.avatar_url = 'https://openvibe.media/fixture/alice.png';
+ h.live.users.get(alice.id).avatar_url = alice.avatar_url;
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
+ bob.avatar_url = 'https://openvibe.media/fixture/bob.png';
+ h.live.users.get(bob.id).avatar_url = bob.avatar_url;
carol = h.addUser('carol', { subject: ids.newId('user') });
carol = h.addUser('carol', { subject: ids.newId('user') });
for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
for (const u of [alice, bob, carol]) { await h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
});
});
@@ -46,6 +50,7 @@ t('the inbox: your conversations only, unread counts in the list and the navigat
@@ -46,6 +50,7 @@ t('the inbox: your conversations only, unread counts in the list and the navigat
assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);
assert.match(inbox.text, /id="oc-inbox-status"[^>]*>2 unread</);
assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');
assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages (2)');
assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');
assert.ok(inbox.text.includes('are you there?'), 'the last message as a preview');
+ assert.ok(inbox.text.includes('src="https://openvibe.media/fixture/alice.png"'), 'DM list rows use the person picture from Network');
const carolInbox = await req('GET', '/messages', { headers: cookie(carol) });
const carolInbox = await req('GET', '/messages', { headers: cookie(carol) });
assert.ok(!carolInbox.text.includes(`/messages/${conv}`), 'not in anyone else\'s inbox');
assert.ok(!carolInbox.text.includes(`/messages/${conv}`), 'not in anyone else\'s inbox');
assert.strictEqual(navLabel(carolInbox.text, '/messages'), 'Messages');
assert.strictEqual(navLabel(carolInbox.text, '/messages'), 'Messages');
@@ -57,6 +62,11 @@ t('the inbox: your conversations only, unread counts in the list and the navigat
@@ -57,6 +62,11 @@ t('the inbox: your conversations only, unread counts in the list and the navigat
assert.ok(!/\d+ new<\/span>/.test(inbox.text));
assert.ok(!/\d+ new<\/span>/.test(inbox.text));
assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages');
assert.strictEqual(navLabel(inbox.text, '/messages'), 'Messages');
assert.strictEqual((await dmApi('GET', '/unread', bob)).body.unread, 0);
assert.strictEqual((await dmApi('GET', '/unread', bob)).body.unread, 0);
+ const defaultView = await dmApi('GET', `/conversations/${conv}/view`, bob);
+ assert.strictEqual(defaultView.body.view, 'bubbles', 'one-to-one conversations default to bubbles');
+ assert.strictEqual((await dmApi('PUT', `/conversations/${conv}/view`, bob, { view: 'room' })).body.view, 'room');
+ assert.match((await req('GET', `/messages/${conv}`, { headers: cookie(bob) })).text, /oc-feed oc-room-feed" id="oc-feed"/);
+ assert.strictEqual((await dmApi('GET', `/conversations/${conv}/view`, bob)).body.view, 'room', 'view choice is saved per person and conversation');
// Rooms count too.
// Rooms count too.
await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Book Club' } });
await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Book Club' } });
@@ -30,6 +30,7 @@ const TABLES = [
@@ -30,6 +30,7 @@ const TABLES = [
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
+ 'dm_contacts', 'dm_view_defaults', 'dm_view_preferences',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',
'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',
'room_sync_clocks', 'room_control_leases',
'room_sync_clocks', 'room_control_leases',
@@ -40,6 +41,7 @@ const INDEXES = [
@@ -40,6 +41,7 @@ const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
+ 'idx_dm_contacts_user',
'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',
'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',
];
];
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
@@ -171,10 +171,15 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<cha
@@ -171,10 +171,15 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<cha
assert.doesNotMatch(r.text, /Ann and Bob/, 'private servers are not listed');
assert.doesNotMatch(r.text, /Ann and Bob/, 'private servers are not listed');
r = await page('/s/saturday-raiders', dee);
r = await page('/s/saturday-raiders', dee);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /class="oc-srv"/);
+ assert.match(r.text, /class="oc-app"/, 'the app shell');
+ assert.match(r.text, /aria-label="Chat destinations"/);
+ assert.match(r.text, /title="Home"/);
+ assert.match(r.text, /title="Vibe"/);
+ assert.match(r.text, /class="oc-col oc-col-members"/, 'the members column');
assert.match(r.text, /aria-current="page"[^>]*>.*general/s);
assert.match(r.text, /aria-current="page"[^>]*>.*general/s);
assert.match(r.text, /Voice channels/);
assert.match(r.text, /Voice channels/);
- assert.match(r.text, /<h3 class="oc-srv-group">Owner/);
+ assert.match(r.text, /<h3 class="oc-side-group">Owner/);
+ assert.match(r.text, /class="oc-you"/, 'the you-bar');
assert.match(r.text, /id="oc-compose"/, 'a member writes');
assert.match(r.text, /id="oc-compose"/, 'a member writes');
assert.doesNotMatch(r.text, /Server settings/, 'a member sees no settings');
assert.doesNotMatch(r.text, /Server settings/, 'a member sees no settings');
r = await page('/s/saturday-raiders/lounge', null);
r = await page('/s/saturday-raiders/lounge', null);
@@ -194,7 +199,10 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<cha
@@ -194,7 +199,10 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<cha
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
assert.match((await page('/s/saturday-raiders/general', dee)).text, /from the form/);
assert.match((await page('/s/saturday-raiders/general', dee)).text, /from the form/);
r = await page('/servers/new', cat, { method: 'POST', form: { name: 'Book Club', private: '1' } });
r = await page('/servers/new', cat, { method: 'POST', form: { name: 'Book Club', private: '1' } });
- assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);
+ assert.strictEqual(r.status, 303);
+ assert.match(r.location, /^\/s\/book-club\?invite=[a-z0-9]+$/, 'a new server ends with its invite link');
+ const newCode = new URL(r.location, 'http://x').searchParams.get('invite');
+ assert.match((await page(`/s/book-club?invite=${newCode}`, cat)).text, /Your invite link/);
const code = (await api('POST', '/book-club/invites', cat, {})).body.invite.code;
const code = (await api('POST', '/book-club/invites', cat, {})).body.invite.code;
r = await page(`/join/${code}`, dee);
r = await page(`/join/${code}`, dee);
assert.match(r.text, /You are invited to/);
assert.match(r.text, /You are invited to/);
@@ -230,9 +238,14 @@ t('the official server: only chat staff take /s/openvibe; it is marked official
@@ -230,9 +238,14 @@ t('the official server: only chat staff take /s/openvibe; it is marked official
assert.strictEqual(list[0].slug, 'openvibe', 'listed first, whatever its size');
assert.strictEqual(list[0].slug, 'openvibe', 'listed first, whatever its size');
assert.ok(list.slice(1).every((x) => x.official === false), 'no other server is official');
assert.ok(list.slice(1).every((x) => x.official === false), 'no other server is official');
assert.strictEqual((await api('POST', '/', staff, { name: 'Explore', slug: 'explore' })).status, 422, 'the other reserved words stay reserved, even for staff');
assert.strictEqual((await api('POST', '/', staff, { name: 'Explore', slug: 'explore' })).status, 422, 'the other reserved words stay reserved, even for staff');
- assert.match((await page('/servers', null)).text, /<strong>OpenVibe<\/strong><span class="oc-badge oc-badge-official">official<\/span>/);
+ const explore = (await page('/servers', null)).text;
+ assert.match(explore, /<h3>OpenVibe <span class="oc-badge oc-badge-official">official<\/span>/, 'Explore shows the official server as a card');
+ assert.match(explore, /class="oc-chips"/, 'the categories');
+ assert.match(explore, /Gaming/, 'a category chip');
+ assert.match(explore, /Start a server/, 'the card that starts one');
+ assert.doesNotMatch(explore, /No other public server yet\./, 'that line is never shown');
const official = (await page('/s/openvibe', null)).text;
const official = (await page('/s/openvibe', null)).text;
- assert.match(official, /oc-srv-name">OpenVibe<\/h1><span class="oc-badge oc-badge-official">official<\/span>/);
+ assert.match(official, /oc-side-name">OpenVibe<\/h1><span class="oc-badge oc-badge-official">official<\/span>/);
const canonical = (/<link rel="canonical" href="([^"]*)"/.exec(official) || [])[1] || '';
const canonical = (/<link rel="canonical" href="([^"]*)"/.exec(official) || [])[1] || '';
assert.match(canonical, /\/s\/openvibe$/, 'canonical is the URL served and listed in the sitemap, not the default channel');
assert.match(canonical, /\/s\/openvibe$/, 'canonical is the URL served and listed in the sitemap, not the default channel');
assert.ok(!canonical.includes('/s/openvibe/general'), 'no canonical to the channel the server happens to default to');
assert.ok(!canonical.includes('/s/openvibe/general'), 'no canonical to the channel the server happens to default to');
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * The app shell (the plan, 2026-11-09, stage V2) and Explore (stage V3).
+ *
+ * The shell: the rail (Home, Vibe, your servers, Add, Explore), the side column (Home's DMs and Friends; a
+ * server's channels with their categories and the people in each voice channel, and the you-bar), the members
+ * column grouped by role, the phone's bottom bar (Shared's app bar), and the shared user popout on every name.
+ *
+ * Explore: the category chips, the featured servers as cards, the Start sheet with its templates, and a server
+ * that starts with the template's channels and ends with its invite link.
+ *
+ * Friends and requests: the people you talk to, on the side column and over the API.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('shell');
+let h, dee, bob, cat;
+const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });
+const dmApi = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });
+const page = async (path, u) => {
+ const headers = u ? { cookie: `ov_token=${u.token}` } : {};
+ const r = await fetch(`${h.base}${path}`, { headers });
+ return { status: r.status, text: await r.text() };
+};
+
+t('boot', async () => {
+ h = await boot();
+ dee = h.addUser('dee', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ for (const u of [dee, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('the shell: the rail, the side column, the members column and the bottom bar', async () => {
+ const made = await api('POST', '/', dee, { name: 'Saturday Raiders', description: 'Raids on Saturdays' });
+ const slug = made.body.server.slug;
+ await api('POST', `/${slug}/channels`, dee, { name: 'planning' });
+ await api('POST', `/${slug}/channels`, dee, { name: 'Lounge', kind: 'voice' });
+ await api('POST', `/${slug}/join`, bob);
+ const r = await page(`/s/${slug}`, dee);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /class="oc-app"/, 'the shell');
+ assert.match(r.text, /<nav class="oc-rail" aria-label="Chat destinations">/, 'the rail');
+ assert.match(r.text, /title="Home"/);
+ assert.match(r.text, /title="Vibe"/);
+ assert.match(r.text, /class="oc-rail-icon oc-rail-add" href="\/servers#oc-new-server"/, 'Add a server');
+ assert.match(r.text, /class="oc-rail-icon oc-rail-explore"/, 'Explore');
+ assert.match(r.text, /class="oc-side"/, 'the side column');
+ assert.match(r.text, /Text channels/, 'the channel categories');
+ assert.match(r.text, /Voice channels/);
+ assert.match(r.text, /class="oc-you"/, 'the you-bar');
+ assert.match(r.text, /class="oc-col oc-col-members"/, 'the members column');
+ assert.match(r.text, /<h3 class="oc-side-group">Owner/, 'the people, grouped by role');
+ assert.match(r.text, /data-oc-user="\d+"/, 'every name opens the shared user popout');
+ // The phone's bottom bar is Shared's app bar (five places).
+ assert.match(r.text, /"label":"Home","href":"\/messages"/);
+ assert.match(r.text, /"label":"Servers","href":"\/servers"/);
+ assert.match(r.text, /"label":"Vibe","href":"\/vibe"/);
+ assert.match(r.text, /"label":"Activity","href":"\/notifications"/);
+ assert.match(r.text, /"label":"You","href":"\/settings"/);
+});
+
+t('Home: the DMs and the people you talk to down the side', async () => {
+ const conv = await dmApi('POST', '/conversations', dee, { user_ids: [bob.id] });
+ const id = (conv.body.conversation && conv.body.conversation.id) || conv.body.id;
+ await dmApi('POST', `/conversations/${id}/messages`, dee, { message: 'Saturday?' });
+ const r = await page('/messages', dee);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /id="oc-side-dms"/, 'the DMs in the side column');
+ assert.match(r.text, /id="oc-side-friends"/, 'Friends');
+ assert.match(r.text, /id="oc-side-requests"/, 'Requests');
+ assert.match(r.text, /Find or start a conversation/);
+ assert.match(r.text, /Saturday\\?/, 'the last line of the conversation');
+});
+
+t('friends and requests: add, list, accept and remove', async () => {
+ const added = await dmApi('POST', '/contacts', dee, { username: 'bob' });
+ assert.strictEqual(added.status, 200);
+ assert.strictEqual(added.body.status, 'outgoing');
+ const list = await dmApi('GET', '/contacts', dee);
+ assert.strictEqual(list.status, 200);
+ assert.strictEqual(list.body.requests.length, 0, 'the request waits for them');
+ assert.strictEqual(list.body.asked.length, 1, 'and you see that you asked');
+ const theirs = await dmApi('GET', '/contacts', bob);
+ assert.strictEqual(theirs.body.requests.length, 1, 'and they see the request');
+ assert.strictEqual((await dmApi('POST', `/contacts/${dee.id}/accept`, bob)).status, 200);
+ const friends = await dmApi('GET', '/contacts', dee);
+ assert.strictEqual(friends.body.friends.length, 1, 'both are friends now');
+ assert.match((await page('/messages', dee)).text, /id="oc-side-friends"/);
+ assert.strictEqual((await dmApi('DELETE', `/contacts/${bob.id}`, dee)).status, 200);
+ assert.strictEqual((await dmApi('GET', '/contacts', dee)).body.friends.length, 0);
+ // A blocked person is never a friend.
+ await dmApi('POST', `/blocks/${bob.id}`, dee);
+ assert.strictEqual((await dmApi('POST', '/contacts', dee, { username: 'bob' })).status, 403);
+});
+
+t('Explore: the chips, the cards and the Start sheet', async () => {
+ const r = await page('/servers', dee);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /class="oc-chips"/);
+ for (const c of ['Gaming', 'Streams', 'Dev', 'Art', 'Music', 'Study', 'AI']) assert.match(r.text, new RegExp(`>${c}</a>`), `the ${c} chip`);
+ assert.match(r.text, /oc-server-card/, 'the servers as cards');
+ assert.match(r.text, /id="oc-new-server"/, 'the Start sheet');
+ for (const [id, name] of [['friends', 'Friends'], ['gaming', 'Gaming group'], ['streams', 'Stream community'], ['study', 'Study group'], ['creators', 'Creators'], ['blank', 'Blank']]) {
+ assert.match(r.text, new RegExp(`value="${id}"`), `the ${name} template`);
+ }
+ assert.match(r.text, /name="visibility" value="public"/);
+ assert.match(r.text, /name="visibility" value="private"/);
+ assert.doesNotMatch(r.text, /No other public server yet\\./);
+ // A search and a category filter.
+ assert.match(r.text, /name="q"/);
+ assert.match((await page('/servers?category=Gaming', dee)).text, /class="oc-chip is-active"[^>]*>Gaming</);
+});
+
+t('a server starts from a template and ends with its invite link', async () => {
+ const form = new URLSearchParams({ name: 'Book Club', template: 'gaming', category: 'Study', visibility: 'public' });
+ const res = await fetch(`${h.base}/servers/new`, { method: 'POST', headers: { cookie: `ov_token=${dee.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: form.toString(), redirect: 'manual' });
+ assert.strictEqual(res.status, 303);
+ const location = res.headers.get('location');
+ assert.match(location, /^\/s\/book-club\?invite=[a-z0-9]+$/, 'the invite link at the end');
+ const slug = location.split('/')[2].split('?')[0];
+ const server = await api('GET', `/${slug}`, dee);
+ const names = server.body.channels.map((c) => c.name).sort();
+ assert.deepStrictEqual(names, ['clips', 'general', 'looking-for-group', 'voice'], 'the template\'s channels');
+ assert.strictEqual(server.body.server.category, 'Study');
+ assert.strictEqual(server.body.server.template, 'gaming');
+ const shown = await page(location, dee);
+ assert.match(shown.text, /Your invite link/, 'the invite is shown');
+ assert.match(shown.text, /\/join\/[a-z0-9]+/);
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });