openvibers/openvibe.chat Commits

Chat V2: the app shell (rail, side column, members, phone panels), Explore and the Start sheet (#432)

OP OpenVibers committed 5 days ago · 22c3dfd927 · 25 files, +1414 −89

* feat(chat): add bubble and room views for DMs

* fix(chat): avoid migration number collision

* Add global navigation to server chat shell

* openvibe-shared v4.165.0 (was v4.163.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v4.165.0 release.

* Merge remote-tracking branch 'origin/pin-bump/openvibe-shared-v4.165.0' into chat/v2-app-shell

* Merge remote-tracking branch 'origin/feat/chat-views-1109' into chat/v2-app-shell

* Chat V2 shell, in progress: the app shell, Home with friends and requests, Explore and server templates

* Chat V2/V3 shell: account-data coverage for new tables, pg-schema list, phone overflow fix

* Merge main (SDK 0.117, Shared 4.172)

* Shell design pass: pages in a gutter, a channel that fills the phone, one way to start a server

- shell({ page: true }) wraps Explore and the Messages list in .oc-page: it scrolls on its own with
  the page gutter (they touched the left edge on a phone).
- On a phone the shell takes the height between the navbar and the app bar, so a channel fills
  the screen and its composer sits at the bottom (it floated mid-screen over empty space).
- The Start sheet opens from any link to it (the rail's +, the side column, the Start card) and
  scrolls inside its column; with the script running its own bar is gone (three 'Start a server'
  on one screen). Step 2: label above field, two columns on a computer.
- Server cards are not chat attachment cards (no 380 px cap; the banner bleeds); the template
  radios stay inside the page that scrolls (they lengthened the document).

* Home's side column ends with the you-bar, like a server's

* Migrations above main's newest (0050): 0051–0054 in their own order

0049 sat below main's 0050_vibe_origin_site; a database already at 0050 would apply it out of order.

* A message's author opens the profile card too, live messages included

One delegated listener: a click on .oc-msg[data-user] .oc-name opens the shared card (a modifier
still opens the profile page); members and people rows keep their hover card.

* A DM's head and composer sit in the gutter like a channel's

* Explore never writes ?q= back into the page; the bubble view draws a message's card

security-private: the query (private words in the test) came back in the search box and the
category chips. link-cards: a DM in the bubble view (the default) left out the message's card
('Watch together').

Changes

ddiff --git a/migrations/0051_dm_view_preferences.sql b/migrations/0051_dm_view_preferences.sql
nnew file mode 100644
iindex 0000000..3975fcf
---- /dev/null
++++ b/migrations/0051_dm_view_preferences.sql
@@@ -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)
++);
ddiff --git a/migrations/0052_shell.sql b/migrations/0052_shell.sql
nnew file mode 100644
iindex 0000000..e8931f6
---- /dev/null
++++ b/migrations/0052_shell.sql
@@@ -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);
ddiff --git a/migrations/0053_dm_view_defaults.sql b/migrations/0053_dm_view_defaults.sql
nnew file mode 100644
iindex 0000000..25c3715
---- /dev/null
++++ b/migrations/0053_dm_view_defaults.sql
@@@ -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)
++);
ddiff --git a/migrations/0054_server_explore.sql b/migrations/0054_server_explore.sql
nnew file mode 100644
iindex 0000000..578b977
---- /dev/null
++++ b/migrations/0054_server_explore.sql
@@@ -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";
ddiff --git a/public/web/chat-shell.css b/public/web/chat-shell.css
nnew file mode 100644
iindex 0000000..974c9b0
---- /dev/null
++++ b/public/web/chat-shell.css
@@@ -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; }
ddiff --git a/public/web/chat-shell.js b/public/web/chat-shell.js
nnew file mode 100644
iindex 0000000..869ceea
---- /dev/null
++++ b/public/web/chat-shell.js
@@@ -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);
++ }
++})();
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex d62d8d7..61d5d13 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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); }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 230963b..8957ed3 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -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() {
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 70aaabe..79267b9 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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))'],
ddiff --git a/server/chat/contacts.js b/server/chat/contacts.js
nnew file mode 100644
iindex 0000000..783329c
---- /dev/null
++++ b/server/chat/contacts.js
@@@ -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 };
ddiff --git a/server/chat/dm-routes.js b/server/chat/dm-routes.js
iindex 36c0436..423df18 100644
---- a/server/chat/dm-routes.js
++++ b/server/chat/dm-routes.js
@@@ -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 authentication
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.
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 user
ddiff --git a/server/chat/view-defaults.js b/server/chat/view-defaults.js
nnew file mode 100644
iindex 0000000..10efa24
---- /dev/null
++++ b/server/chat/view-defaults.js
@@@ -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 };
ddiff --git a/server/rooms/server-templates.js b/server/rooms/server-templates.js
nnew file mode 100644
iindex 0000000..0c91c68
---- /dev/null
++++ b/server/rooms/server-templates.js
@@@ -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 };
ddiff --git a/server/rooms/servers.js b/server/rooms/servers.js
iindex 55d6742..413d1d4 100644
---- a/server/rooms/servers.js
++++ b/server/rooms/servers.js
@@@ -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, position
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 (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 m
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))]);
}
ddiff --git a/server/web/icons.js b/server/web/icons.js
iindex c53a098..15cc85f 100644
---- a/server/web/icons.js
++++ b/server/web/icons.js
@@@ -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"/>'),
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex b3c96f5..81baa82 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -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 () {
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 6037c4e..f5e63de 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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>', html
const 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>', html
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 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>', html
const 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/a
const 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, {
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 6bcd379..c7a4c96 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -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);
ddiff --git a/server/web/shell.js b/server/web/shell.js
nnew file mode 100644
iindex 0000000..a24fdd4
---- /dev/null
++++ b/server/web/shell.js
@@@ -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 };
ddiff --git a/server/web/ui.js b/server/web/ui.js
iindex 423c809..58a405b 100644
---- a/server/web/ui.js
++++ b/server/web/ui.js
@@@ -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. */
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex dbdaf48..4740950 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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: {
ddiff --git a/test/dm-inbox.test.js b/test/dm-inbox.test.js
iindex d15e58a..2571a23 100644
---- a/test/dm-inbox.test.js
++++ b/test/dm-inbox.test.js
@@@ -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 navigat
assert.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 navigat
assert.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' } });
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 38fc80a..a76bcfe 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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',
ddiff --git a/test/servers.test.js b/test/servers.test.js
iindex 34b2b7f..f244b96 100644
---- a/test/servers.test.js
++++ b/test/servers.test.js
@@@ -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');
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/<cha
assert.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 official
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.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');
ddiff --git a/test/shell.test.js b/test/shell.test.js
nnew file mode 100644
iindex 0000000..faa4e5d
---- /dev/null
++++ b/test/shell.test.js
@@@ -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(); });