openvibers/openvibe.chat Commits
The rail names its places, a server has a sheet, one composer, and Activity is a page (#466)
Changes
@@@ -8,25 +8,41 @@.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; }++/* ── The rail (72 px on a computer): every place is a tile that carries its name. Home, Global and Vibe, your++ servers in their own colours (the colour a server has in every list), then New and Explore. A bar beside the tile++ says where you are: a short one under the pointer, the full one at the open place. The name follows the pointer's++ tile (chat-shell.js .oc-rail-tip); on a phone it is written beside the tile (below). ── */++.oc-rail { display: flex; flex-direction: column; align-items: center; gap: 8px; 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-icon, .oc-rail-menu { position: relative; flex: 0 0 auto; margin: 0; padding: 0; border: 0; background: none; color: var(--oc-text); font: inherit; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }++.oc-rail-icon { display: grid; place-items: center; width: 72px; height: 48px; }++.oc-rail-icon[data-oc-server] { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }++.oc-rail-tile { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 800; font-size: .95rem; letter-spacing: .02em; transition: border-radius .18s ease, background-color .18s ease, color .18s ease, transform .16s ease; }++.oc-rail-tile .oc-i { width: 22px; height: 22px; }++.oc-rail-icon:hover .oc-rail-tile, .oc-rail-icon:focus-visible .oc-rail-tile, .oc-rail-icon.is-active .oc-rail-tile { border-radius: 16px; }++.oc-rail-icon:active .oc-rail-tile { transform: scale(.94); }++.oc-rail-icon:focus-visible { outline: 0; }++.oc-rail-icon:focus-visible .oc-rail-tile { outline: 2px solid var(--oc-accent); outline-offset: 2px; }++.oc-rail-icon::before { content: ''; position: absolute; left: 0; top: 50%; width: 4px; height: 0; border-radius: 0 4px 4px 0; background: var(--oc-text); transform: translateY(-50%); transition: height .18s ease; }++.oc-rail-icon:hover::before, .oc-rail-icon:focus-visible::before { height: 16px; }++.oc-rail-icon.is-active::before { height: 32px; }++.oc-rail-home:hover .oc-rail-tile, .oc-rail-global:hover .oc-rail-tile, .oc-rail-vibe:hover .oc-rail-tile, .oc-rail-explore:hover .oc-rail-tile { background: color-mix(in srgb, var(--oc-accent) 24%, var(--oc-panel-2)); }++.oc-rail-home.is-active .oc-rail-tile, .oc-rail-global.is-active .oc-rail-tile, .oc-rail-vibe.is-active .oc-rail-tile, .oc-rail-explore.is-active .oc-rail-tile { background: var(--oc-accent); color: var(--oc-on-accent); }++.oc-rail-server .oc-rail-tile { background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; }++.oc-rail-server:hover .oc-rail-tile { background: hsl(var(--oc-hue, 215) 56% 40%); }++.oc-rail-add .oc-rail-tile { color: var(--oc-ok); }++.oc-rail-add:hover .oc-rail-tile { background: var(--oc-ok-fill); color: #06210f; }++.oc-rail-explore .oc-rail-tile { color: var(--oc-accent); }++.oc-rail-explore .oc-rail-tile .oc-i { width: 20px; height: 20px; }.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; }++.oc-rail-dot { position: absolute; right: 8px; bottom: -3px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red-fill); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; box-shadow: 0 0 0 3px var(--oc-bg); }++.oc-rail-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++.oc-rail-more { display: none; }++.oc-rail-tip { position: fixed; z-index: 60; transform: translate(-4px, -50%); padding: 6px 10px; border-radius: 8px; background: var(--oc-text); color: var(--oc-bg); font-size: .84rem; font-weight: 700; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity .12s ease, transform .12s ease; }++.oc-rail-tip.is-on { opacity: 1; transform: translate(0, -50%); }--/* 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; }++/* On a phone the side column opens from the strip: this button, with the word for what it lists. */++.oc-rail-menu { display: none; }/* ── 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); }@@@ -117,10 +133,40 @@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; }++ /* The strip: each place is a chip with its tile and its name, so nothing here is a letter to be guessed. The list++ button is first, with the word for what it opens; then Global chat, your servers and New. The open place is the++ filled chip (a server's in its own colour); the open server's chip carries a small arrow: tap it again, or hold++ any server, for its sheet. Home, Vibe and Explore are the bottom bar's while that bar is there. */++ .oc-app > .oc-rail { flex-direction: row; justify-content: flex-start; align-items: center; gap: 8px; padding: 8px 12px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); min-width: 0; scroll-padding-inline: 12px; overscroll-behavior-x: contain; }++ .oc-app > .oc-rail::after { content: ''; position: sticky; right: -12px; order: 9; flex: 0 0 36px; align-self: stretch; margin-left: -44px; background: linear-gradient(90deg, transparent, var(--oc-bg)); pointer-events: none; opacity: 0; transition: opacity .15s ease; }++ .oc-app > .oc-rail[data-fade="end"]::after, .oc-app > .oc-rail[data-fade="both"]::after { opacity: 1; }++ .oc-rail-icon, .oc-rail-menu { display: inline-flex; align-items: center; gap: 8px; width: auto; height: 44px; padding: 0 14px 0 6px; border: 1px solid transparent; border-radius: 22px; background: var(--oc-panel-2); order: 2; transition: background-color .18s ease, border-color .18s ease, transform .12s ease; }++ .oc-rail-icon:active, .oc-rail-menu:active { transform: scale(.97); }++ .oc-rail-icon::before { content: none; }++ .oc-rail-icon:focus-visible, .oc-rail-menu:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }++ .oc-rail-icon:focus-visible .oc-rail-tile { outline: 0; }++ .oc-rail-tile, .oc-rail-icon:hover .oc-rail-tile, .oc-rail-icon:focus-visible .oc-rail-tile, .oc-rail-icon.is-active .oc-rail-tile { width: 32px; height: 32px; border-radius: 50%; font-size: .76rem; }++ .oc-rail-icon:active .oc-rail-tile { transform: none; }++ .oc-rail-home .oc-rail-tile, .oc-rail-global .oc-rail-tile, .oc-rail-vibe .oc-rail-tile, .oc-rail-add .oc-rail-tile, .oc-rail-explore .oc-rail-tile { background: color-mix(in srgb, var(--oc-text) 9%, transparent); }++ .oc-rail-tile .oc-i { width: 18px; height: 18px; }++ .oc-rail-label { position: static; width: auto; height: auto; max-width: 15ch; clip: auto; text-overflow: ellipsis; font-size: .9rem; font-weight: 650; color: var(--oc-muted); }++ .oc-rail-icon.is-active { background: color-mix(in srgb, var(--oc-accent) 20%, var(--oc-panel-2)); border-color: color-mix(in srgb, var(--oc-accent) 55%, transparent); }++ .oc-rail-icon.is-active .oc-rail-tile { background: var(--oc-accent); color: var(--oc-on-accent); }++ .oc-rail-server.is-active { background: color-mix(in srgb, hsl(var(--oc-hue, 215) 60% 50%) 24%, var(--oc-panel-2)); border-color: hsl(var(--oc-hue, 215) 55% 55% / .7); }++ .oc-rail-server .oc-rail-tile, .oc-rail-server.is-active .oc-rail-tile, .oc-rail-server:hover .oc-rail-tile { background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; }++ .oc-rail-icon.is-active .oc-rail-label { color: var(--oc-text); }++ .oc-rail-more { display: grid; place-items: center; margin: 0 -6px 0 -4px; color: var(--oc-muted); }++ .oc-rail-more .oc-i { width: 16px; height: 16px; transform: rotate(90deg); }++ .oc-rail-dot { position: static; margin: 0 -6px 0 -2px; box-shadow: none; }++ /* The list button stays at the strip's start while the places scroll under it. */++ .oc-rail-menu { order: 0; padding: 0 14px 0 12px; position: sticky; left: 0; z-index: 1; box-shadow: 0 0 0 8px var(--oc-bg), 14px 0 10px -2px var(--oc-bg); }++ .oc-rail-menu .oc-i { width: 20px; height: 20px; }++ .oc-rail-menu .oc-rail-label { color: var(--oc-text); }++ .oc-app[data-oc-panel="side"] .oc-rail-menu { background: var(--oc-accent); color: var(--oc-on-accent); }++ .oc-app[data-oc-panel="side"] .oc-rail-menu .oc-rail-label { color: inherit; }++ .oc-rail-divider { order: 1; width: 1px; height: 24px; flex: 0 0 1px; }++ .oc-rail-tip { display: none; }.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; }@@@ -132,6 +178,13 @@.oc-side-list .oc-row > a, .oc-side-list .oc-row > span { min-height: 44px; }}++/* Home, Vibe and Servers are the bottom bar's on a phone. That bar is drawn by a script, so the strip leaves them out++ wherever scripts run, from the first paint (a strip that waited to see the bar showed three chips and took them++ away). Without scripts there is no bottom bar and the strip keeps all of them. */++@media (max-width: 760px) and (scripting: enabled) {++ .oc-rail .oc-rail-home, .oc-rail .oc-rail-vibe, .oc-rail .oc-rail-explore { display: none; }++}++/* 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; }@@@ -254,3 +307,67 @@ fieldset.oc-role-set { min-width: 0; padding: 0 0 14px; }.oc-app .ovt-main { min-height: 0; height: 100%; }.oc-app .ovt-scroll { overflow-y: auto; min-height: 0; }.oc-app .ovt-head, .oc-app .ovt-dock { position: static; }++++/* ── A server's sheet: what it is, your rank in it, and what you may do with it. Hold a server (or right-click it),++ tap the open server's chip again, or the three dots on its card. A card in the middle on a computer; on a phone it++ rises from the bottom edge. ── */++.oc-ssheet { width: min(380px, calc(100vw - 24px)); max-height: min(560px, calc(100dvh - 24px)); margin: auto; padding: 0; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); color: var(--oc-text); box-shadow: 0 24px 60px rgba(0, 0, 0, .5); overflow-y: auto; }++.oc-ssheet:focus { outline: 0; }++.oc-ssheet::backdrop { background: rgba(4, 8, 16, .58); }++.oc-ssheet[open] { animation: oc-ssheet-in .22s cubic-bezier(.2, .9, .3, 1.12); }++@keyframes oc-ssheet-in { from { opacity: 0; transform: translateY(calc(14px * var(--ov-move, 1))) scale(.98); } }++.oc-ssheet [hidden] { display: none !important; }++.oc-ssheet-head { display: flex; align-items: center; gap: 12px; padding: 16px 10px 14px 16px; }++.oc-ssheet-head .oc-srv-icon { width: 48px; height: 48px; border-radius: 15px; font-size: 1rem; }++.oc-ssheet-id { flex: 1; min-width: 0; }++.oc-ssheet-id h2 { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0; font-size: 1.1rem; line-height: 1.25; overflow-wrap: anywhere; }++.oc-ssheet-id h2 .oc-badge { margin: 0; }++.oc-ssheet-id p { margin: 3px 0 0; font-size: .88rem; }++.oc-ssheet-close { flex: none; align-self: flex-start; }++.oc-ssheet-list { display: grid; gap: 2px; padding: 6px 8px 10px; border-top: 1px solid var(--oc-line); }++.oc-ssheet-list form { margin: 0; }++.oc-ssheet .oc-pop-item { min-height: 48px; border: 0; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; }++.oc-ssheet .oc-pop-item .oc-i { width: 20px; height: 20px; color: var(--oc-muted); }++.oc-ssheet .oc-pop-item b { display: block; font-weight: 600; }++.oc-ssheet .oc-pop-item small { display: block; font-size: .8rem; font-weight: 400; color: var(--oc-muted); }++.oc-ssheet-danger > summary { list-style: none; color: var(--oc-red); }++.oc-ssheet-danger > summary::-webkit-details-marker { display: none; }++.oc-ssheet .oc-ssheet-danger > summary .oc-i { color: currentColor; }++.oc-ssheet-confirm { margin: 2px 0 4px; padding: 12px; border-radius: 12px; background: color-mix(in srgb, var(--oc-red-fill) 11%, transparent); }++.oc-ssheet-confirm p { margin: 0 0 10px; font-size: .92rem; }++.oc-ssheet-confirm .oc-ssheet-go { display: flex; gap: 8px; margin: 0; }++.oc-ssheet-go button { flex: 1; min-height: 44px; }++@media (max-width: 760px) {++ .oc-ssheet { width: 100%; max-width: none; max-height: 86dvh; margin: auto 0 0; border-width: 1px 0 0; border-radius: 22px 22px 0 0; padding-bottom: env(safe-area-inset-bottom); }++ .oc-ssheet[open] { animation: oc-ssheet-up .26s cubic-bezier(.2, .9, .3, 1.06); }++ .oc-ssheet::before { content: ''; display: block; width: 40px; height: 4px; margin: 8px auto -4px; border-radius: 4px; background: var(--oc-line); }++}++@keyframes oc-ssheet-up { from { opacity: .6; transform: translateY(calc(100% * var(--ov-move, 1))); } }++++/* A server's card where you are in it: your rank in words, Open, and the three dots for its sheet. */++.oc-card-yours { color: var(--oc-text); font-weight: 700; }++.oc-card > .oc-card-go { display: flex; gap: 8px; margin: 6px 14px 0; }++.oc-card-go > .oc-button { flex: 1; text-align: center; }++.oc-card-go > .oc-card-more { flex: none; border: 1px solid var(--oc-line); border-radius: 10px; color: var(--oc-text); }++++/* ── Activity: what is waiting for you, by kind; nothing waiting is said as that ── */++.oc-act-sec { margin: 0 0 18px; }++.oc-act-row { display: flex; align-items: center; gap: 10px; padding: 6px 8px; }++.oc-act-who { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }++.oc-act-row button { min-height: 40px; flex: none; }++.oc-act-servers, .oc-act-chans { list-style: none; margin: 0; padding: 0; }++.oc-act-servers { display: grid; gap: 10px; }++.oc-act-server { padding: 6px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }++.oc-act-server-head { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 4px 8px; border-radius: 10px; color: var(--oc-text); text-decoration: none; }++.oc-act-server-head:hover { background: var(--oc-panel-2); }++.oc-act-server-head strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-act-chans { display: grid; gap: 1px; }++.oc-act-server .oc-act-chans { padding-left: 42px; }++.oc-act-clear { display: grid; justify-items: center; gap: 6px; max-width: 420px; margin: 9vh auto 0; text-align: center; }++.oc-act-clear-mark { display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; background: color-mix(in srgb, var(--oc-ok-fill) 16%, transparent); color: var(--oc-ok); animation: oc-act-pop .42s cubic-bezier(.2, .9, .3, 1.4) both; }++.oc-act-clear-mark .oc-i { width: 30px; height: 30px; stroke-width: 2.4; }++.oc-act-clear h2 { margin: 8px 0 0; font-size: 1.2rem; }++.oc-act-clear p { margin: 0; }++.oc-act-clear .oc-act-clear-go { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 12px; }++.oc-act-clear-go .oc-button { min-height: 44px; display: inline-flex; align-items: center; }++@keyframes oc-act-pop { from { opacity: 0; transform: scale(calc(1 - .4 * var(--ov-move, 1))); } }@@@ -36,6 +36,8 @@function panel(name) {app.setAttribute('data-oc-panel', name);app.classList.toggle('is-members', name === 'members');++ var listButton = app.querySelector('.oc-rail-menu');++ if (listButton) listButton.setAttribute('aria-expanded', name === 'side' ? 'true' : 'false');document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {b.setAttribute('aria-pressed', b.getAttribute('data-oc-panel-toggle') === name ? 'true' : 'false');});@@@ -88,6 +90,153 @@}});++ // ── The rail: on a computer one label follows the tile under the pointer (the rail scrolls, so a label inside it++ // would be cut off); on a phone the strip fades at the edge that has more, and starts with the open place in view ──++ var isPhone = function () { return !!(window.matchMedia && window.matchMedia('(max-width: 760px)').matches); };++ var railEl = app.querySelector('.oc-rail');++ if (railEl) {++ var tip = null;++ var showTip = function (el) {++ var label = el.querySelector('.oc-rail-label');++ if (!label || isPhone()) return;++ if (!tip) { tip = document.createElement('span'); tip.className = 'oc-rail-tip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip); }++ var box = el.getBoundingClientRect();++ tip.textContent = label.textContent;++ tip.style.left = Math.round(box.right + 4) + 'px';++ tip.style.top = Math.round(box.top + box.height / 2) + 'px';++ tip.classList.add('is-on');++ };++ var hideTip = function () { if (tip) tip.classList.remove('is-on'); };++ var placeOf = function (e) { return e.target && e.target.closest ? e.target.closest('.oc-rail-icon') : null; };++ railEl.addEventListener('pointerover', function (e) { var el = placeOf(e); if (el && e.pointerType !== 'touch') showTip(el); });++ railEl.addEventListener('pointerout', function (e) { var el = placeOf(e); if (el && !el.contains(e.relatedTarget)) hideTip(); });++ railEl.addEventListener('focusin', function (e) { var el = placeOf(e); if (el && el.matches(':focus-visible')) showTip(el); });++ railEl.addEventListener('focusout', hideTip);++ var fade = function () {++ hideTip();++ if (!isPhone()) { railEl.removeAttribute('data-fade'); return; }++ var max = railEl.scrollWidth - railEl.clientWidth, x = railEl.scrollLeft;++ var start = x > 4, end = x < max - 4;++ if (start || end) railEl.setAttribute('data-fade', start && end ? 'both' : start ? 'start' : 'end');++ else railEl.removeAttribute('data-fade');++ };++ railEl.addEventListener('scroll', fade, { passive: true });++ window.addEventListener('resize', fade);++ var here = railEl.querySelector('.oc-rail-icon.is-active');++ if (here && isPhone()) { // only as far as it takes to show the open place whole, past the list button++ var box = here.getBoundingClientRect(), rbox = railEl.getBoundingClientRect();++ var listButton = railEl.querySelector('.oc-rail-menu');++ var lead = listButton ? listButton.getBoundingClientRect().right + 16 : rbox.left + 12;++ if (box.right > rbox.right - 12) railEl.scrollLeft += box.right - (rbox.right - 12);++ box = here.getBoundingClientRect();++ if (box.left < lead) railEl.scrollLeft -= lead - box.left;++ }++ fade();++ }++++ // ── A server's sheet: what it is, your rank in it, what you may do with it. It opens when a server is held (a++ // phone) or right-clicked (a computer), from the open server's chip on a phone, and from the three dots on a++ // server's card. The page carries one template (shell.js SERVER_SHEET); the facts are on the element. ──++ var RANK = { owner: 'You own this server', admin: 'You are an admin here', mod: 'You are a moderator here', member: 'You are a member' };++ var sheetAt = 0;++ function lettersOf(name) {++ return String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(function (w) { return w.charAt(0); }).join('').toUpperCase() || '?';++ }++ function serverSheet(el) {++ var tpl = document.getElementById('oc-srv-sheet');++ if (!tpl || !tpl.content || !window.HTMLDialogElement || !el.getAttribute('data-oc-server')) return false;++ var open = document.querySelector('dialog.oc-ssheet');++ if (open) open.remove();++ var dlg = tpl.content.firstElementChild.cloneNode(true);++ var part = function (k) { return dlg.querySelector('[data-s="' + k + '"]'); };++ var slug = el.getAttribute('data-oc-server'), name = el.getAttribute('data-oc-name') || slug;++ var role = el.getAttribute('data-oc-role') || '', members = Number(el.getAttribute('data-oc-members')) || 0;++ var official = el.hasAttribute('data-oc-official'), closed = el.hasAttribute('data-oc-private');++ var base = '/s/' + encodeURIComponent(slug);++ var staff = role === 'owner' || role === 'admin';++ var mark = part('mark');++ mark.style.setProperty('--oc-hue', el.getAttribute('data-oc-hue') || '215');++ mark.textContent = lettersOf(name);++ part('name').textContent = name;++ part('official').hidden = !official;++ part('private').hidden = !closed;++ part('sub').textContent = (RANK[role] || 'You are not in this server') + ' · ' + members + (members === 1 ? ' member' : ' members');++ // The sheet carries only what the person may do somewhere (shell.js serverSheet): a part may not be there.++ var show = function (k, on, to) {++ var n = part(k);++ if (!n) return null;++ n.hidden = !on;++ if (on && to) n.setAttribute(n.tagName === 'FORM' ? 'action' : 'href', to);++ return n;++ };++ show('open', !(el.classList.contains('oc-rail-icon') && el.classList.contains('is-active')), base);++ show('invite', staff || (role === 'mod' && !closed), base + '/settings#oc-invites');++ var settings = show('settings', staff || role === 'mod', base + '/settings');++ if (settings && role === 'mod') { settings.querySelector('b').textContent = 'Members and invites'; settings.querySelector('small').textContent = 'Who is in it, and who is banned'; }++ show('leave', !!role && role !== 'owner', base + '/leave');++ var del = show('delete', role === 'owner' && !official);++ if (del) {++ del.querySelector('form').setAttribute('action', base + '/delete');++ part('delete-line').textContent = 'Delete ' + name + ' and all its channels? Their messages go with it, and it cannot be brought back.';++ part('keep').addEventListener('click', function () { del.open = false; });++ }++ part('close').addEventListener('click', function () { dlg.close(); });++ // A tap on the dimmed page closes it; the tap that is still ending the hold that opened it does not.++ dlg.addEventListener('click', function (e) { if (e.target === dlg && Date.now() - sheetAt > 500) dlg.close(); });++ dlg.addEventListener('close', function () { dlg.remove(); });++ document.body.appendChild(dlg);++ dlg.setAttribute('tabindex', '-1');++ dlg.showModal();++ dlg.focus(); // the sheet itself, not its Close button: nothing in it is picked on the person's behalf++ sheetAt = Date.now();++ return true;++ }++ var serverOf = function (e) { return e.target && e.target.closest ? e.target.closest('[data-oc-server]') : null; };++ document.addEventListener('contextmenu', function (e) {++ var el = serverOf(e);++ if (!el) return;++ if (Date.now() - sheetAt < 600 || serverSheet(el)) e.preventDefault();++ });++ // Where a hold raises no context menu (iOS), the hold itself opens the sheet.++ var holdTimer = 0, holdX = 0, holdY = 0, held = false;++ var dropHold = function () { if (holdTimer) { clearTimeout(holdTimer); holdTimer = 0; } };++ document.addEventListener('pointerdown', function (e) {++ held = false;++ var el = e.pointerType === 'touch' ? serverOf(e) : null;++ if (!el) return;++ holdX = e.clientX; holdY = e.clientY;++ dropHold();++ holdTimer = setTimeout(function () { holdTimer = 0; if (Date.now() - sheetAt > 600 && serverSheet(el)) held = true; }, 480);++ }, { passive: true });++ document.addEventListener('pointermove', function (e) { if (holdTimer && (Math.abs(e.clientX - holdX) > 10 || Math.abs(e.clientY - holdY) > 10)) dropHold(); }, { passive: true });++ document.addEventListener('pointerup', dropHold, { passive: true });++ document.addEventListener('pointercancel', dropHold, { passive: true });++ document.addEventListener('click', function (e) {++ if (held) { held = false; e.preventDefault(); e.stopPropagation(); return; }++ var el = e.target && e.target.closest ? e.target.closest('.oc-card-more[data-oc-server], .oc-rail-server.is-active[data-oc-server]') : null;++ if (!el || e.ctrlKey || e.metaKey || e.shiftKey) return;++ if (el.classList.contains('oc-rail-server') && !isPhone()) return; // a computer: the open server's tile is a link like the rest++ if (serverSheet(el)) e.preventDefault();++ }, true);++++ // ── The composer: Send is lit while there is something to send; Add opens what the place takes (its pictures) ──++ var composeForm = document.getElementById('oc-compose'), composeInput = document.getElementById('oc-input');++ if (composeForm && composeInput) {++ var syncSend = function () { composeForm.classList.toggle('is-empty', !composeInput.value.trim()); };++ composeInput.addEventListener('input', syncSend);++ syncSend();++ var addButton = composeForm.querySelector('[data-oc-attach]'), addPanel = document.getElementById('oc-add-panel');++ if (addButton && addPanel) {++ addButton.addEventListener('click', function () { addPanel.open = !addPanel.open; });++ addPanel.addEventListener('toggle', function () {++ addButton.setAttribute('aria-expanded', addPanel.open ? 'true' : 'false');++ addButton.setAttribute('aria-label', addPanel.open ? 'Close' : 'Add a picture');++ addButton.title = addPanel.open ? 'Close' : 'Add a picture';++ if (addPanel.open) { var first = addPanel.querySelector('button, input:not([type=hidden])'); if (first) first.focus({ preventScroll: true }); }++ });++ }++ }++// ── 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 || '');@@@ -530,14 +530,40 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-js .oc-msg.oc-sel .oc-del button { visibility: visible; }.oc-msg.oc-sel { background: var(--oc-panel-2); }}--.oc-dock { display: flex; align-items: flex-end; gap: 6px; flex-wrap: wrap; margin-top: 8px; }--.oc-dock > .oc-compose { flex: 1 1 0; min-width: 0; }--.oc-dock > .oc-pic-compose { margin: 0; order: 0; }++/* ── The composer (shell.js composer): one box. The text is in it, Add is on its left where the place takes++ pictures, Send on its right; what is being added waits in the tray above the text. The box takes the focus ring,++ not the text inside it. Send is lit while there is something to send (chat-shell.js .is-empty). The picture panel++ opens above the box; without the script its own button stays and Add is not shown. ── */++.oc-dock { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }++.oc-dock > .oc-compose { display: block; min-width: 0; }++.oc-compose-box { border: 1px solid var(--oc-line); border-radius: 26px; background: var(--oc-bg); transition: border-color .15s ease, box-shadow .15s ease; }++.oc-compose-box:focus-within { border-color: color-mix(in srgb, var(--oc-accent) 65%, var(--oc-line)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--oc-accent) 16%, transparent); }++.oc-compose-row { display: flex; align-items: flex-end; gap: 2px; padding: 5px; }++.oc-compose-box textarea { flex: 1; min-width: 0; min-height: 40px; max-height: 40vh; margin: 0; padding: 9px 8px; border: 0; border-radius: 14px; background: transparent; line-height: 1.4; resize: none; field-sizing: content; }++.oc-compose-box textarea:focus-visible { outline: 0; }++.oc-compose-row > textarea:first-child { padding-left: 14px; }++.oc-compose-add { display: none; place-items: center; width: 40px; height: 40px; flex: none; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--oc-muted); cursor: pointer; transition: background-color .15s ease, color .15s ease, transform .22s cubic-bezier(.2, .9, .3, 1.2); }++.oc-js .oc-compose-add { display: grid; }++.oc-compose-add:hover { background: var(--oc-panel-2); color: var(--oc-text); }++.oc-compose-add[aria-expanded="true"] { transform: rotate(45deg); background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-accent); }++.oc-compose-add .oc-i { width: 22px; height: 22px; }++.oc-compose-box .oc-send { width: 40px; height: 40px; border: 0; background: var(--oc-accent); color: var(--oc-on-accent); transition: background-color .15s ease, color .15s ease, transform .2s cubic-bezier(.2, .9, .3, 1.3); }++.oc-compose.is-empty .oc-compose-box .oc-send { background: var(--oc-panel-2); color: var(--oc-muted); transform: scale(.9); }++.oc-compose-box .oc-send:active { transform: scale(.92); }++.oc-compose-tray { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 10px 2px; }++.oc-compose-tray[hidden] { display: none; }++.oc-dock > .oc-pic-compose { order: -1; margin: 0; }.oc-dock > .oc-pic-compose > summary { list-style: none; width: 44px; height: 44px; }.oc-dock > .oc-pic-compose > summary::-webkit-details-marker { display: none; }--.oc-dock > .oc-pic-compose[open] { order: 2; flex: 1 1 100%; }--.oc-dock > .oc-pic-compose[open] > summary { color: var(--oc-accent); }--.oc-compose textarea { min-height: 44px; max-height: 40vh; border-radius: 22px; padding: 10px 16px; resize: none; field-sizing: content; }++.oc-js .oc-dock > .oc-pic-compose > summary, .oc-js .oc-dock > .oc-pic-compose:not([open]) { display: none; }++.oc-dock > .oc-pic-compose[open] { padding: 14px; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); animation: oc-add-rise .2s ease; }++.oc-dock > .oc-pic-compose .oc-pic-form { margin: 0; max-width: none; }++@keyframes oc-add-rise { from { opacity: 0; transform: translateY(calc(8px * var(--ov-move, 1))); } }++@media (max-width: 760px) {++ .oc-compose-row { padding: 3px; }++ .oc-compose-add, .oc-compose-box .oc-send { width: 44px; height: 44px; }++ .oc-compose-box textarea { min-height: 44px; padding-top: 11px; padding-bottom: 11px; }++}.oc-send { display: inline-grid; place-items: center; width: 44px; height: 44px; padding: 0; border-radius: 50%; flex: none; }.oc-send .oc-i { width: 20px; height: 20px; stroke-width: 2.4; }/* The conversation takes the screen's height (header, notes and composer around it), not a fixed box. */@@@ -283,7 +283,7 @@if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScripte.preventDefault();ws.send(JSON.stringify({ type: 'chat', message: text }));-- input.value = '';++ input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true }));input.focus();});}@@@ -343,7 +343,7 @@.then(function (x) {if (!x.ok) { showError((x.d && x.d.error) || 'Could not send'); return; }if (x.d && x.d.message) { latest = Math.max(latest, Number(x.d.message.id) || 0); append(dmItem(x.d.message), x.d.message.id); }-- input.value = ''; input.focus();++ input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();}).catch(function () { form.submit(); }).then(function () { if (btn) btn.disabled = false; });@@@ -477,7 +477,7 @@if (!ws || ws.readyState !== 1) return;e.preventDefault();ws.send(JSON.stringify({ type: 'room_message', message: text }));-- input.value = ''; input.focus();++ input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();});}if (feed) feed.addEventListener('submit', function (e) {@@@ -32,6 +32,8 @@ const ICONS = {retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),pencil: svg('<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>'),plus: svg('<path d="M12 5v14M5 12h14"/>'),++ close: svg('<path d="M6 6l12 12M18 6 6 18"/>'),++ enter: svg('<path d="M5 12h13M13 6l6 6-6 6"/>'),phone: svg('<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>'),ban: svg('<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>'),key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),@@@ -109,7 +109,7 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimitawait page(req, res, 200, {actor, title: 'Inbox', path: '/inbox', active: 'inbox', robots: 'noindex, nofollow', shell: true, footer: 'none',body: shell.shell({-- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),side: shell.sideInbox({ inboxes: mine, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),sideLabel: 'Inbox',main: `<h1>Inbox</h1>@@@ -168,7 +168,7 @@ ${list}const mineServers = (await require('../rooms/servers').list(actor.user)).mine;const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);const body = `<div class="ibk" id="ib" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">${shell.shell({-- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),side: conversationsRail({ inbox, list, filter, current, me, nameOf, waiting: counts.waiting }),sideLabel: 'Conversations',main: pane,@@@ -271,7 +271,7 @@ function createWebRoutes({ config }) {}} catch { /* the names stay plain */ }const composer = actor.kind === 'user'-- ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<div class="oc-dock"><form class="oc-compose" method="post" action="/send" id="oc-compose"><label for="oc-input" class="oc-sr">Message to everyone</label><textarea id="oc-input" name="message" maxlength="500" rows="1" required placeholder="Message everyone on OpenVibe"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`)++ ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : shell.composer({ action: '/send', label: 'Message to everyone', placeholder: 'Message everyone on OpenVibe', max: 500 })): `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const signedIn = actor.kind === 'user';@@@ -314,7 +314,7 @@ function createWebRoutes({ config }) {const contacts = await require('../chat/contacts').of(me.id);const mineServers = (await require('../rooms/servers').list(me)).mine;return shell.shell({-- rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: shell.railServers(mineServers) }),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: `<header class="oc-chan-head"><h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>@@@ -372,6 +372,63 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', htmlreturn `<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>`;};++ // ── Activity (the bottom bar's fourth place): what is waiting for you, in one page. People who asked to be your++ // friend, conversations with something new, and the channels and rooms with unread messages, each a row that opens++ // the place. Nothing waiting is said as that, with the two places most people go next. ──++ router.get('/activity', async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const me = actor.user;++ const serversApi = require('../rooms/servers');++ const convs = await dm.getConversations(me.id);++ const participants = await Promise.all(convs.map((c) => dm.getParticipants(c.id)));++ await presence.attachTo(me.subject_id, participants.flat());++ const dms = convs.map((c, i) => {++ const others = participants[i].filter((p) => p.id !== me.id);++ return {++ id: c.id, title: c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you',++ 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, presence: others.length === 1 ? others[0].presence : null,++ avatarUrl: others.length === 1 ? others[0].avatar_url : '',++ };++ });++ const unread = dms.reduce((n, d) => n + d.unread, 0);++ const mineServers = (await serversApi.list(me)).mine;++ const contacts = await require('../chat/contacts').of(me.id);++ // The channels with something new, by server (a dozen servers at most: the rest are on the Servers page).++ const busy = mineServers.filter((sv) => sv.unread).slice(0, 12);++ const busyChannels = await Promise.all(busy.map(async (sv) => {++ try { return (await serversApi.channels(await serversApi.bySlug(sv.slug), me)).filter((ch) => ch.unread); } catch { return []; }++ }));++ const myRooms = (await require('../rooms/rooms').list(me)).mine.filter((r) => r.unread);++ const requests = contacts.requests || [];++ const requestRows = requests.map((r) => `<li class="oc-row oc-act-row"><span class="oc-act-who">${ui.initials(r.display_name || r.username || '?', null, { avatarUrl: r.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.display_name || r.username || '?')}</strong></span><span class="oc-row-sub">@${esc(r.username || '')} asked to be your friend</span></span></span><form class="oc-inline" method="post" action="/messages/contacts/${Number(r.id)}/accept"><button type="submit">Accept</button></form></li>`);++ const convRows = dms.filter((d) => d.unread).map((d) => convRow({ href: `/messages/${d.id}`, title: d.title, preview: d.preview, at: d.at, unread: d.unread, personPresence: d.presence, avatarUrl: d.avatarUrl }));++ const serverBlocks = busy.map((sv, i) => `<li class="oc-act-server"><a class="oc-act-server-head" href="/s/${esc(sv.slug)}">${ui.serverMark(sv)}<strong>${esc(sv.name)}</strong></a>++<ul class="oc-act-chans">${busyChannels[i].map((ch) => `<li><a class="oc-chan is-unread" href="/s/${esc(sv.slug)}/${esc(ch.slug)}">${ch.kind === 'voice' ? IC.voice : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(ch.name)}</span><span class="oc-chan-dot" aria-label="${ch.unread} unread">${ch.unread > 99 ? '99+' : ch.unread}</span></a></li>`).join('')}</ul></li>`);++ const roomRows = myRooms.map((r) => `<li><a class="oc-chan is-unread" href="/r/${esc(r.slug)}"><span class="oc-chan-hash" aria-hidden="true">#</span><span class="oc-chan-name">${esc(r.name)}</span><span class="oc-chan-dot" aria-label="${r.unread} unread">${r.unread > 99 ? '99+' : r.unread}</span></a></li>`);++ const section = (id, title, n, inner) => (n ? `<section class="oc-act-sec" aria-labelledby="${id}"><h2 class="oc-list-head" id="${id}">${title} <span class="oc-muted">${n}</span></h2>${inner}</section>` : '');++ const waiting = requests.length + convRows.length + busy.length + roomRows.length;++ const body = waiting++ ? [++ section('oc-act-requests', 'Friend requests', requests.length, `<ul class="oc-list">${requestRows.join('')}</ul>`),++ section('oc-act-convs', 'New messages', convRows.length, `<ul class="oc-list">${convRows.join('\n')}</ul>`),++ section('oc-act-servers', 'In your servers', busy.length, `<ul class="oc-act-servers">${serverBlocks.join('')}</ul>`),++ section('oc-act-rooms', 'In your rooms', roomRows.length, `<ul class="oc-act-chans oc-act-rooms">${roomRows.join('')}</ul>`),++ ].join('\n')++ : `<section class="oc-act-clear"><span class="oc-act-clear-mark" aria-hidden="true">${IC.check}</span><h2>You are all caught up</h2><p class="oc-muted">New messages, friend requests and unread channels show up here.</p><p class="oc-act-clear-go"><a class="oc-button" href="/messages">Open your messages</a><a class="oc-button oc-button--quiet" href="/servers">Find a server</a></p></section>`;++ await page(req, res, 200, {++ actor, shell: true, title: 'Activity', path: '/activity', active: 'activity', robots: 'noindex, nofollow', footer: 'none',++ body: shell.shell({++ rail: shell.rail({ dmUnread: unread, servers: shell.railServers(mineServers), list: 'Chats' }),++ 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: 'Activity', sub: waiting ? 'What is waiting for you' : 'Nothing is waiting for you' })}\n${body}`,++ mainLabel: 'Activity',++ page: true,++ }),++ });++ });++router.get('/messages', async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const me = actor.user;@@@ -413,7 +470,7 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', htmlactor, shell: true, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',page: { view: 'inbox', unread, me: me.id },body: shell.shell({-- rail: shell.rail({ home: true, dmUnread: unread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ home: true, dmUnread: unread, servers: shell.railServers(mineServers) }),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}@@@ -501,7 +558,7 @@ ${blockedList}`,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', avatarUrl: other.avatar_url }) : '';-- let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;++ let composer = shell.composer({ action: `/messages/${id}`, label: 'Reply', placeholder: 'Write a message' });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.@@@ -529,7 +586,7 @@ ${blockedList}`,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 })) }),++ rail: shell.rail({ home: true, dmUnread, servers: shell.railServers(mineServers) }),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}@@@ -645,7 +702,7 @@ ${composer}`,await page(req, res, 200, {actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', shell: true, footer: 'none',body: shell.shell({-- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),side: shell.sideSettings({ items: sections, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),sideLabel: 'Settings',main: `${ui.head({ title: 'Settings', sub: 'Saved to your account: here, in Live chat and on every device' })}${saved}${error}@@@ -787,7 +844,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');/** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */-- const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary class="oc-icon-btn" aria-label="Add a picture" title="Add a picture">${ICONS.image}</summary>++ const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose" id="oc-add-panel"><summary class="oc-icon-btn" aria-label="Add a picture" title="Add a picture">${ICONS.image}</summary><form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" data-room="${esc(room.slug)}" data-picture-upload-form><input type="hidden" name="back" value="${esc(back)}"><div class="oc-field oc-field--wide"><span>Picture</span>@@@ -896,7 +953,7 @@ ${create}actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', shell: true, footer: 'none',description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',body: shell.shell({-- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),side: sideRooms,sideLabel: 'Rooms',main: content,@@@ -972,7 +1029,7 @@ ${people}const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';let composer;if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;-- else if (can.post) composer = `<div class="oc-dock">${room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`)}<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="${room.kind === 'system' ? 'Post an announcement' : 'Write a message'}"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${ICONS.send}</button></form></div>`;++ else if (can.post) composer = shell.composer({ action: `/r/${room.slug}`, label: `Message to ${room.name}`, placeholder: room.kind === 'system' ? 'Post an announcement' : 'Write a message', add: room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`) });else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;@@@ -1009,7 +1066,7 @@ ${people}page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },body: shell.shell({-- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),side: shell.sideRooms({mine: listed.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),public: listed.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),@@@ -1165,7 +1222,7 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';const listedRooms = await rooms.list(actor.user);const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);const roomShell = (main) => shell.shell({-- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),++ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),side: shell.sideRooms({mine: listedRooms.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),public: listedRooms.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),@@@ -24,6 +24,7 @@ const ctx = require('../live-context');const LIVE = 'https://openvibe.live';const RANK_TITLE = { owner: 'Owner', admin: 'Admins', mod: 'Moderators', member: 'Members' };const ROLE_NAME = { owner: 'owner', admin: 'admin', mod: 'moderator', member: 'member', blocked: 'banned' };++const YOURS = { owner: 'You own it', admin: 'You are an admin', mod: 'You moderate it', member: 'You are in it' };const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', member: 'a member' };const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" 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"/></svg>';@@@ -84,7 +85,7 @@ function mountServerPages(router, h) {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>`++ ? `<p class="oc-card-go"><a class="oc-button oc-button--quiet" href="/s/${esc(sv.slug)}">Open</a><a class="oc-icon-btn oc-card-more" href="/s/${esc(sv.slug)}" aria-label="More about ${esc(sv.name)}" title="More"${shell.serverData(shell.railServers([sv])[0])}>${IC.more}</a></p>`: (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">@@@ -92,7 +93,7 @@ function mountServerPages(router, h) {<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>++<p class="oc-muted oc-card-meta">${isMember && YOURS[sv.role] ? `<span class="oc-card-yours">${YOURS[sv.role]}</span> · ` : ''}${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>`;@@@ -132,7 +133,7 @@ ${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',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 })) }),++ rail: shell.rail({ explore: true, dmUnread: me ? await require('../chat/dm').getTotalUnread(me.id) : 0, servers: shell.railServers(mine) }),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>@@@ -216,7 +217,7 @@ ${sheet}`,const replyForm = me && post && data.viewer && data.viewer.can_reply ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/replies"><label>Reply <textarea name="body" maxlength="40000" rows="4" required></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Reply</button></form>` : '';const modForm = me && post && data.viewer && data.viewer.can_moderate ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/state"><label><input type="checkbox" name="pinned" value="true"${post.pinned ? ' checked' : ''}> Pinned</label><label><input type="checkbox" name="locked" value="true"${post.locked ? ' checked' : ''}> Locked</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save moderation</button></form>` : '';const shellBody = shell.shell({-- rail: shell.rail({ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })), dmUnread }),++ rail: shell.rail({ servers: shell.railServers(mine, server.slug), dmUnread }),side: shell.sideServer({ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,categories: [{ name: 'Forum channels', channels: chans.filter((c) => c.kind === 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) },{ name: 'Other channels', channels: chans.filter((c) => c.kind !== 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) }], occupants: {},@@@ -256,7 +257,7 @@ ${sheet}`,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>`;-- else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>${pictureComposer ? pictureComposer(room, back) : ''}`;++ else if (rcan.post) composer = shell.composer({ action: back, label: `Message ${isVoice ? '' : '#'}${room.name}`, placeholder: `Message ${isVoice ? '' : '#'}${room.name}`, add: pictureComposer ? pictureComposer(room, back) : '' });else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;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)) : [];@@@ -299,7 +300,7 @@ ${sheet}`,},body: shell.shell({rail: shell.rail({-- servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })),++ servers: shell.railServers(mine, server.slug),dmUnread,}),side: shell.sideServer({@@@ -379,7 +380,7 @@ ${composer}`,async function serverRail(me, activeSlug) {const mine = me ? (await servers.list(me)).mine : [];const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;-- return shell.rail({ dmUnread, servers: mine.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === activeSlug })) });++ return shell.rail({ dmUnread, servers: shell.railServers(mine, activeSlug) });}router.get('/s/:slug/welcome', async (req, res) => {@@@ -28,32 +28,69 @@ 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: 'Servers', href: '/servers', icon: 'fa-people-group', page: 'servers' }, // not the globe: that is Global chat{ label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles', page: 'vibe' },-- { label: 'Activity', href: '/notifications', icon: 'fa-bell', page: 'notifications' },++ { label: 'Activity', href: '/activity', icon: 'fa-bell', page: 'activity' },{ 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>`;++/** What the rail and a server's sheet need of each server (servers.list(me).mine). */++const railServers = (mine, active) => (mine || []).map((s) => ({++ slug: s.slug, name: s.name, unread: s.unread, role: s.role || '', members: Number(s.members) || 0,++ private: s.visibility === 'private', official: !!s.official, active: !!active && s.slug === active,++}));++++/** A server's facts on the element that opens its sheet (chat-shell.js reads them: hold, right-click, or the open one). */++const serverData = (s) => ` data-oc-server="${esc(s.slug)}" data-oc-name="${esc(s.name)}" data-oc-role="${esc(s.role || '')}" data-oc-members="${Number(s.members) || 0}" data-oc-hue="${ui.hueOf(s.slug)}"${s.private ? ' data-oc-private' : ''}${s.official ? ' data-oc-official' : ''}`;++++/**++ * A server's sheet, once a page: what it is, your rank in it, and what you may do with it (open, invite, settings,++ * leave, delete). chat-shell.js fills a copy from the element that was held; without the script a server's own page++ * has the same actions in its side column. It carries only what the person may do in at least one of their servers:++ * someone who runs none is not shown a word about settings.++ */++function serverSheet(servers) {++ const runs = servers.some((s) => ['owner', 'admin', 'mod'].includes(s.role));++ const owns = servers.some((s) => s.role === 'owner' && !s.official);++ const leaves = servers.some((s) => s.role && s.role !== 'owner');++ return `<template id="oc-srv-sheet"><dialog class="oc-ssheet" aria-labelledby="oc-ssheet-name">++<header class="oc-ssheet-head"><span class="oc-srv-icon oc-srv-icon--row" data-s="mark" aria-hidden="true"></span><div class="oc-ssheet-id"><h2 id="oc-ssheet-name"><span data-s="name"></span><span class="oc-badge oc-badge-official" data-s="official">official</span><span class="oc-badge" data-s="private">private</span></h2><p class="oc-muted" data-s="sub"></p></div><button type="button" class="oc-icon-btn oc-ssheet-close" data-s="close" aria-label="Close" title="Close">${IC.close}</button></header>++<nav class="oc-ssheet-list" aria-label="This server">++<a class="oc-pop-item" data-s="open">${IC.enter}<span>Open</span></a>++${runs ? `<a class="oc-pop-item" data-s="invite">${IC.members}<span>Invite people</span></a>++<a class="oc-pop-item" data-s="settings">${IC.gear}<span><b>Server settings</b><small>Its name, channels, roles and members</small></span></a>` : ''}++${leaves ? `<form method="post" data-s="leave"><button type="submit" class="oc-pop-item">${IC.leave}<span>Leave this server</span></button></form>` : ''}++${owns ? `<details class="oc-ssheet-danger" data-s="delete"><summary class="oc-pop-item">${IC.trash}<span>Delete this server</span></summary>++<form method="post" class="oc-ssheet-confirm"><input type="hidden" name="confirm" value="1"><p data-s="delete-line"></p><p class="oc-ssheet-go"><button type="submit" class="oc-danger">Delete it</button><button type="button" class="oc-button--quiet" data-s="keep">Keep it</button></p></form></details>` : ''}++</nav></dialog></template>`;++}++/**-- * The rail: Home, Global chat and Vibe, your servers, then Add a server and Explore.-- * o: { servers: [{ slug, name, unread, active }], dmUnread, home, global, vibe, explore }++ * The rail: the list button (a phone), Home, Global chat and Vibe, your servers, then New and Explore. Every place++ * carries its name: beside the tile on a phone, where the rail is a strip of chips, and as a label that follows the++ * pointer's tile on a computer (chat-shell.js). On a phone Home, Vibe and Explore are the bottom bar's, so the strip++ * leaves them out while that bar is there (chat-shell.css).++ * o: { servers: railServers(mine, activeSlug), dmUnread, home, global, vibe, explore, list }*/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>`;++ const list = o.list || (o.home || o.global ? 'Chats' : servers.some((s) => s.active) ? 'Channels' : o.explore ? 'Yours' : 'Browse');++ const dot = (n) => (n ? `<span class="oc-rail-dot" aria-label="${n} unread">${n > 99 ? '99+' : n}</span>` : '');++ const place = (cls, href, name, inner, on, extra = '') => `<a class="oc-rail-icon ${cls}${on ? ' is-active' : ''}" href="${href}"${on ? ' aria-current="page"' : ''}><span class="oc-rail-tile" aria-hidden="true">${inner}</span><span class="oc-rail-label">${esc(name)}</span>${extra}</a>`;++ const server = (s) => `<a class="oc-rail-icon oc-rail-server${s.active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" style="--oc-hue:${ui.hueOf(s.slug)}"${s.active ? ' aria-current="page"' : ''}${serverData(s)}><span class="oc-rail-tile" aria-hidden="true">${esc(ui.serverLetters(s.name))}</span><span class="oc-rail-label">${esc(s.name)}</span>${s.active ? `<span class="oc-rail-more" aria-hidden="true">${IC.chevron}</span>` : ''}${dot(s.unread)}</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-global${o.global ? ' is-active' : ''}" href="/" title="Global chat"${o.global ? ' aria-current="page"' : ''}>${IC.globe}<span class="oc-sr">Global chat</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>++<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-expanded="false" title="Show the list">${IC.menu}<span class="oc-rail-label">${esc(list)}</span></button>++${place('oc-rail-home', '/messages', 'Home', IC.chat, o.home, dot(o.dmUnread))}++${place('oc-rail-global', '/', 'Global', IC.globe, o.global)}++${place('oc-rail-vibe', '/vibe', 'Vibe', IC.spark, o.vibe)}<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>`;++${place('oc-rail-add', '/servers#oc-new-server', 'New', IC.plus, false)}++${place('oc-rail-explore', '/servers', 'Explore', IC.search, o.explore)}++${servers.length ? serverSheet(servers) : ''}</nav>`;}/** One person in a list: their mark, their name and a muted line under it. */@@@ -184,6 +221,20 @@ ${inboxes.length ? `<ul class="oc-side-list">${inboxes.map(row).join('')}</ul>`${youBar(o.you || {})}`;}++/**++ * The composer, the same wherever a person writes (Global chat, a conversation, a room, a channel): one box with the++ * text in it, Add on its left where the place takes pictures, Send on its right. What is being added waits in the++ * tray above the text. It posts as a plain form; chat.js sends it over the socket's API instead, and chat-shell.js++ * grows the box with the text and lights Send when there is something to send.++ * o: { action, label, placeholder, max, add: the panel Add opens (pictureComposer's), hint }++ */++function composer(o = {}) {++ const add = o.add || '';++ return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose"><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>++<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div>++<div class="oc-compose-row">${add ? `<button type="button" class="oc-compose-add" data-oc-attach aria-expanded="false" aria-controls="oc-add-panel" aria-label="Add a picture" title="Add a picture">${IC.plus}</button>` : ''}<textarea id="oc-input" name="message" maxlength="${Number(o.max) || 2000}" rows="1" required placeholder="${esc(o.placeholder || 'Write a message')}"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></div></div></form>${add}</div>`;++}++/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */function youBar(o = {}) {if (!o.name) return '';@@@ -231,4 +282,4 @@ ${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersL</div>`;}--module.exports = { APP_BAR, rail, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };++module.exports = { APP_BAR, rail, railServers, serverData, composer, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };@@@ -195,8 +195,8 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<chaassert.strictEqual(r.status, 200);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, /<span class="oc-rail-label">Home<\/span>/);++ assert.match(r.text, /<span class="oc-rail-label">Vibe<\/span>/);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/);@@@ -43,8 +43,8 @@ t('the shell: the rail, the side column, the members column and the bottom bar',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, /<span class="oc-rail-label">Home<\/span>/);++ assert.match(r.text, /<span class="oc-rail-label">Vibe<\/span>/);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');@@@ -58,8 +58,40 @@ t('the shell: the rail, the side column, the members column and the bottom bar',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":"Activity","href":"\/activity"/, 'Activity is a page that exists');assert.match(r.text, /"label":"You","href":"\/settings"/);++ assert.match(r.text, /"label":"Servers","href":"\/servers","icon":"fa-people-group"/, 'Servers is not the globe: that is Global chat');++ // Every place on the rail carries its name; a server carries what its sheet says (hold it, or right-click it).++ assert.match(r.text, /class="oc-rail-icon oc-rail-server is-active" href="\/s\/saturday-raiders"[^>]* data-oc-server="saturday-raiders" data-oc-name="Saturday Raiders" data-oc-role="owner" data-oc-members="2"/, 'the open server, with your rank in it');++ assert.match(r.text, /<span class="oc-rail-label">Saturday Raiders<\/span>/, 'its name, not its initials alone');++ assert.match(r.text, /<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-expanded="false"[^>]*>.*<span class="oc-rail-label">Channels<\/span><\/button>/, 'the list button says what it lists');++ assert.match(r.text, /<template id="oc-srv-sheet"><dialog class="oc-ssheet"/, "a server's sheet, once a page");++ assert.ok(/data-s="settings"/.test(r.text) && /data-s="delete"/.test(r.text) && !/data-s="leave"/.test(r.text), 'the owner: settings and delete, and no leave (an owner cannot leave)');++ const asBob = await page(`/s/${slug}`, bob);++ assert.ok(/data-s="leave"/.test(asBob.text) && !/data-s="settings"/.test(asBob.text) && !/data-s="delete"/.test(asBob.text), 'a member: leave, and not a word about settings or deleting');++ // One composer: a box, Send on its right; no picture store in this run, so no Add.++ assert.match(r.text, /<form class="oc-compose" method="post" action="\/s\/saturday-raiders\/general" id="oc-compose">/);++ assert.match(r.text, /<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden><\/div>/);++ assert.match(r.text, /<textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Message #general"><\/textarea><button type="submit" class="oc-send" aria-label="Send"/);++});++++t('Activity: what is waiting for you, and nothing waiting said as that', async () => {++ let r = await page('/activity', bob);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<h1[^>]*>Activity<\/h1>/);++ assert.match(r.text, /You are all caught up/, 'nothing waiting is said as that');++ assert.match(r.text, /href="\/messages">Open your messages<\/a>/);++ assert.ok(!/href="\/notifications"/.test(r.text), 'nothing links to the page that never was');++ // Dee writes in the server Bob joined: it is waiting for Bob, under the server, by channel.++ const sent = await fetch(`${h.base}/s/saturday-raiders/general`, { method: 'POST', redirect: 'manual', headers: { cookie: `ov_token=${dee.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: 'message=raid+at+nine' });++ assert.strictEqual(sent.status, 303);++ r = await page('/activity', bob);++ assert.match(r.text, /In your servers <span class="oc-muted">1<\/span>/);++ assert.match(r.text, /class="oc-act-server-head" href="\/s\/saturday-raiders"/);++ assert.match(r.text, /<a class="oc-chan is-unread" href="\/s\/saturday-raiders\/general">/);++ assert.ok(!/You are all caught up/.test(r.text));++ const out = await page('/activity', null);++ assert.ok(out.status !== 404 && /Sign in/.test(out.text) && !/In your servers/.test(out.text), 'signed out: the way to sign in, and nothing of anyone');});t('Home: the DMs and the people you talk to down the side', async () => {@@@ -102,6 +134,8 @@ t('Explore: the chips, the cards and the Start sheet', async () => {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, /<span class="oc-card-yours">You own it<\/span> · 2 members/, 'your rank in a server you are in');++ assert.match(r.text, /class="oc-icon-btn oc-card-more" href="\/s\/saturday-raiders" aria-label="More about Saturday Raiders"/, 'and the way to its sheet');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`);
@@ -8,25 +8,41 @@
@@ -8,25 +8,41 @@
.oc-app > .oc-col-members { overflow: hidden; border-left: 0; }
.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); }
.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; }
+/* ── The rail (72 px on a computer): every place is a tile that carries its name. Home, Global and Vibe, your
+ servers in their own colours (the colour a server has in every list), then New and Explore. A bar beside the tile
+ says where you are: a short one under the pointer, the full one at the open place. The name follows the pointer's
+ tile (chat-shell.js .oc-rail-tip); on a phone it is written beside the tile (below). ── */
+.oc-rail { display: flex; flex-direction: column; align-items: center; gap: 8px; 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::-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-icon, .oc-rail-menu { position: relative; flex: 0 0 auto; margin: 0; padding: 0; border: 0; background: none; color: var(--oc-text); font: inherit; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
+.oc-rail-icon { display: grid; place-items: center; width: 72px; height: 48px; }
+.oc-rail-icon[data-oc-server] { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
+.oc-rail-tile { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 800; font-size: .95rem; letter-spacing: .02em; transition: border-radius .18s ease, background-color .18s ease, color .18s ease, transform .16s ease; }
+.oc-rail-tile .oc-i { width: 22px; height: 22px; }
+.oc-rail-icon:hover .oc-rail-tile, .oc-rail-icon:focus-visible .oc-rail-tile, .oc-rail-icon.is-active .oc-rail-tile { border-radius: 16px; }
+.oc-rail-icon:active .oc-rail-tile { transform: scale(.94); }
+.oc-rail-icon:focus-visible { outline: 0; }
+.oc-rail-icon:focus-visible .oc-rail-tile { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
+.oc-rail-icon::before { content: ''; position: absolute; left: 0; top: 50%; width: 4px; height: 0; border-radius: 0 4px 4px 0; background: var(--oc-text); transform: translateY(-50%); transition: height .18s ease; }
+.oc-rail-icon:hover::before, .oc-rail-icon:focus-visible::before { height: 16px; }
+.oc-rail-icon.is-active::before { height: 32px; }
+.oc-rail-home:hover .oc-rail-tile, .oc-rail-global:hover .oc-rail-tile, .oc-rail-vibe:hover .oc-rail-tile, .oc-rail-explore:hover .oc-rail-tile { background: color-mix(in srgb, var(--oc-accent) 24%, var(--oc-panel-2)); }
+.oc-rail-home.is-active .oc-rail-tile, .oc-rail-global.is-active .oc-rail-tile, .oc-rail-vibe.is-active .oc-rail-tile, .oc-rail-explore.is-active .oc-rail-tile { background: var(--oc-accent); color: var(--oc-on-accent); }
+.oc-rail-server .oc-rail-tile { background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; }
+.oc-rail-server:hover .oc-rail-tile { background: hsl(var(--oc-hue, 215) 56% 40%); }
+.oc-rail-add .oc-rail-tile { color: var(--oc-ok); }
+.oc-rail-add:hover .oc-rail-tile { background: var(--oc-ok-fill); color: #06210f; }
+.oc-rail-explore .oc-rail-tile { color: var(--oc-accent); }
+.oc-rail-explore .oc-rail-tile .oc-i { width: 20px; height: 20px; }
.oc-rail-divider { width: 32px; height: 2px; flex: 0 0 2px; border-radius: 2px; background: var(--oc-line); }
.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; }
+.oc-rail-dot { position: absolute; right: 8px; bottom: -3px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red-fill); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; box-shadow: 0 0 0 3px var(--oc-bg); }
+.oc-rail-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.oc-rail-more { display: none; }
+.oc-rail-tip { position: fixed; z-index: 60; transform: translate(-4px, -50%); padding: 6px 10px; border-radius: 8px; background: var(--oc-text); color: var(--oc-bg); font-size: .84rem; font-weight: 700; line-height: 1.2; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity .12s ease, transform .12s ease; }
+.oc-rail-tip.is-on { opacity: 1; transform: translate(0, -50%); }
-/* 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; }
+/* On a phone the side column opens from the strip: this button, with the word for what it lists. */
+.oc-rail-menu { display: none; }
/* ── The side column (240 px) ── */
/* ── 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 { 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); }
@@ -117,10 +133,40 @@
@@ -117,10 +133,40 @@
height: calc(100dvh - var(--ovnav-h, 56px) - var(--ovnav-appbar-h, 0px)); min-height: 360px; }
height: calc(100dvh - var(--ovnav-h, 56px) - var(--ovnav-appbar-h, 0px)); min-height: 360px; }
.oc-page { padding: 14px 16px 20px; }
.oc-page { padding: 14px 16px 20px; }
.oc-app.is-members { grid-template-columns: minmax(0, 1fr); }
.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; }
+ /* The strip: each place is a chip with its tile and its name, so nothing here is a letter to be guessed. The list
+ button is first, with the word for what it opens; then Global chat, your servers and New. The open place is the
+ filled chip (a server's in its own colour); the open server's chip carries a small arrow: tap it again, or hold
+ any server, for its sheet. Home, Vibe and Explore are the bottom bar's while that bar is there. */
+ .oc-app > .oc-rail { flex-direction: row; justify-content: flex-start; align-items: center; gap: 8px; padding: 8px 12px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); min-width: 0; scroll-padding-inline: 12px; overscroll-behavior-x: contain; }
+ .oc-app > .oc-rail::after { content: ''; position: sticky; right: -12px; order: 9; flex: 0 0 36px; align-self: stretch; margin-left: -44px; background: linear-gradient(90deg, transparent, var(--oc-bg)); pointer-events: none; opacity: 0; transition: opacity .15s ease; }
+ .oc-app > .oc-rail[data-fade="end"]::after, .oc-app > .oc-rail[data-fade="both"]::after { opacity: 1; }
+ .oc-rail-icon, .oc-rail-menu { display: inline-flex; align-items: center; gap: 8px; width: auto; height: 44px; padding: 0 14px 0 6px; border: 1px solid transparent; border-radius: 22px; background: var(--oc-panel-2); order: 2; transition: background-color .18s ease, border-color .18s ease, transform .12s ease; }
+ .oc-rail-icon:active, .oc-rail-menu:active { transform: scale(.97); }
+ .oc-rail-icon::before { content: none; }
+ .oc-rail-icon:focus-visible, .oc-rail-menu:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
+ .oc-rail-icon:focus-visible .oc-rail-tile { outline: 0; }
+ .oc-rail-tile, .oc-rail-icon:hover .oc-rail-tile, .oc-rail-icon:focus-visible .oc-rail-tile, .oc-rail-icon.is-active .oc-rail-tile { width: 32px; height: 32px; border-radius: 50%; font-size: .76rem; }
+ .oc-rail-icon:active .oc-rail-tile { transform: none; }
+ .oc-rail-home .oc-rail-tile, .oc-rail-global .oc-rail-tile, .oc-rail-vibe .oc-rail-tile, .oc-rail-add .oc-rail-tile, .oc-rail-explore .oc-rail-tile { background: color-mix(in srgb, var(--oc-text) 9%, transparent); }
+ .oc-rail-tile .oc-i { width: 18px; height: 18px; }
+ .oc-rail-label { position: static; width: auto; height: auto; max-width: 15ch; clip: auto; text-overflow: ellipsis; font-size: .9rem; font-weight: 650; color: var(--oc-muted); }
+ .oc-rail-icon.is-active { background: color-mix(in srgb, var(--oc-accent) 20%, var(--oc-panel-2)); border-color: color-mix(in srgb, var(--oc-accent) 55%, transparent); }
+ .oc-rail-icon.is-active .oc-rail-tile { background: var(--oc-accent); color: var(--oc-on-accent); }
+ .oc-rail-server.is-active { background: color-mix(in srgb, hsl(var(--oc-hue, 215) 60% 50%) 24%, var(--oc-panel-2)); border-color: hsl(var(--oc-hue, 215) 55% 55% / .7); }
+ .oc-rail-server .oc-rail-tile, .oc-rail-server.is-active .oc-rail-tile, .oc-rail-server:hover .oc-rail-tile { background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; }
+ .oc-rail-icon.is-active .oc-rail-label { color: var(--oc-text); }
+ .oc-rail-more { display: grid; place-items: center; margin: 0 -6px 0 -4px; color: var(--oc-muted); }
+ .oc-rail-more .oc-i { width: 16px; height: 16px; transform: rotate(90deg); }
+ .oc-rail-dot { position: static; margin: 0 -6px 0 -2px; box-shadow: none; }
+ /* The list button stays at the strip's start while the places scroll under it. */
+ .oc-rail-menu { order: 0; padding: 0 14px 0 12px; position: sticky; left: 0; z-index: 1; box-shadow: 0 0 0 8px var(--oc-bg), 14px 0 10px -2px var(--oc-bg); }
+ .oc-rail-menu .oc-i { width: 20px; height: 20px; }
+ .oc-rail-menu .oc-rail-label { color: var(--oc-text); }
+ .oc-app[data-oc-panel="side"] .oc-rail-menu { background: var(--oc-accent); color: var(--oc-on-accent); }
+ .oc-app[data-oc-panel="side"] .oc-rail-menu .oc-rail-label { color: inherit; }
+ .oc-rail-divider { order: 1; width: 1px; height: 24px; flex: 0 0 1px; }
+ .oc-rail-tip { display: none; }
.oc-app > .oc-side, .oc-app > .oc-col-main, .oc-app > .oc-col-members { 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 > .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="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="members"] > .oc-col-members { display: block; border-left: 0; }
@@ -132,6 +178,13 @@
@@ -132,6 +178,13 @@
.oc-side-list .oc-row > a, .oc-side-list .oc-row > span { min-height: 44px; }
.oc-side-list .oc-row > a, .oc-side-list .oc-row > span { min-height: 44px; }
}
}
+/* Home, Vibe and Servers are the bottom bar's on a phone. That bar is drawn by a script, so the strip leaves them out
+ wherever scripts run, from the first paint (a strip that waited to see the bar showed three chips and took them
+ away). Without scripts there is no bottom bar and the strip keeps all of them. */
+@media (max-width: 760px) and (scripting: enabled) {
+ .oc-rail .oc-rail-home, .oc-rail .oc-rail-vibe, .oc-rail .oc-rail-explore { display: none; }
+}
+
/* A friend row is a button that opens the conversation; the form posts the username. */
/* 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-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 { 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; }
@@ -254,3 +307,67 @@ fieldset.oc-role-set { min-width: 0; padding: 0 0 14px; }
@@ -254,3 +307,67 @@ fieldset.oc-role-set { min-width: 0; padding: 0 0 14px; }
.oc-app .ovt-main { min-height: 0; height: 100%; }
.oc-app .ovt-main { min-height: 0; height: 100%; }
.oc-app .ovt-scroll { overflow-y: auto; min-height: 0; }
.oc-app .ovt-scroll { overflow-y: auto; min-height: 0; }
.oc-app .ovt-head, .oc-app .ovt-dock { position: static; }
.oc-app .ovt-head, .oc-app .ovt-dock { position: static; }
+
+/* ── A server's sheet: what it is, your rank in it, and what you may do with it. Hold a server (or right-click it),
+ tap the open server's chip again, or the three dots on its card. A card in the middle on a computer; on a phone it
+ rises from the bottom edge. ── */
+.oc-ssheet { width: min(380px, calc(100vw - 24px)); max-height: min(560px, calc(100dvh - 24px)); margin: auto; padding: 0; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); color: var(--oc-text); box-shadow: 0 24px 60px rgba(0, 0, 0, .5); overflow-y: auto; }
+.oc-ssheet:focus { outline: 0; }
+.oc-ssheet::backdrop { background: rgba(4, 8, 16, .58); }
+.oc-ssheet[open] { animation: oc-ssheet-in .22s cubic-bezier(.2, .9, .3, 1.12); }
+@keyframes oc-ssheet-in { from { opacity: 0; transform: translateY(calc(14px * var(--ov-move, 1))) scale(.98); } }
+.oc-ssheet [hidden] { display: none !important; }
+.oc-ssheet-head { display: flex; align-items: center; gap: 12px; padding: 16px 10px 14px 16px; }
+.oc-ssheet-head .oc-srv-icon { width: 48px; height: 48px; border-radius: 15px; font-size: 1rem; }
+.oc-ssheet-id { flex: 1; min-width: 0; }
+.oc-ssheet-id h2 { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0; font-size: 1.1rem; line-height: 1.25; overflow-wrap: anywhere; }
+.oc-ssheet-id h2 .oc-badge { margin: 0; }
+.oc-ssheet-id p { margin: 3px 0 0; font-size: .88rem; }
+.oc-ssheet-close { flex: none; align-self: flex-start; }
+.oc-ssheet-list { display: grid; gap: 2px; padding: 6px 8px 10px; border-top: 1px solid var(--oc-line); }
+.oc-ssheet-list form { margin: 0; }
+.oc-ssheet .oc-pop-item { min-height: 48px; border: 0; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; }
+.oc-ssheet .oc-pop-item .oc-i { width: 20px; height: 20px; color: var(--oc-muted); }
+.oc-ssheet .oc-pop-item b { display: block; font-weight: 600; }
+.oc-ssheet .oc-pop-item small { display: block; font-size: .8rem; font-weight: 400; color: var(--oc-muted); }
+.oc-ssheet-danger > summary { list-style: none; color: var(--oc-red); }
+.oc-ssheet-danger > summary::-webkit-details-marker { display: none; }
+.oc-ssheet .oc-ssheet-danger > summary .oc-i { color: currentColor; }
+.oc-ssheet-confirm { margin: 2px 0 4px; padding: 12px; border-radius: 12px; background: color-mix(in srgb, var(--oc-red-fill) 11%, transparent); }
+.oc-ssheet-confirm p { margin: 0 0 10px; font-size: .92rem; }
+.oc-ssheet-confirm .oc-ssheet-go { display: flex; gap: 8px; margin: 0; }
+.oc-ssheet-go button { flex: 1; min-height: 44px; }
+@media (max-width: 760px) {
+ .oc-ssheet { width: 100%; max-width: none; max-height: 86dvh; margin: auto 0 0; border-width: 1px 0 0; border-radius: 22px 22px 0 0; padding-bottom: env(safe-area-inset-bottom); }
+ .oc-ssheet[open] { animation: oc-ssheet-up .26s cubic-bezier(.2, .9, .3, 1.06); }
+ .oc-ssheet::before { content: ''; display: block; width: 40px; height: 4px; margin: 8px auto -4px; border-radius: 4px; background: var(--oc-line); }
+}
+@keyframes oc-ssheet-up { from { opacity: .6; transform: translateY(calc(100% * var(--ov-move, 1))); } }
+
+/* A server's card where you are in it: your rank in words, Open, and the three dots for its sheet. */
+.oc-card-yours { color: var(--oc-text); font-weight: 700; }
+.oc-card > .oc-card-go { display: flex; gap: 8px; margin: 6px 14px 0; }
+.oc-card-go > .oc-button { flex: 1; text-align: center; }
+.oc-card-go > .oc-card-more { flex: none; border: 1px solid var(--oc-line); border-radius: 10px; color: var(--oc-text); }
+
+/* ── Activity: what is waiting for you, by kind; nothing waiting is said as that ── */
+.oc-act-sec { margin: 0 0 18px; }
+.oc-act-row { display: flex; align-items: center; gap: 10px; padding: 6px 8px; }
+.oc-act-who { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
+.oc-act-row button { min-height: 40px; flex: none; }
+.oc-act-servers, .oc-act-chans { list-style: none; margin: 0; padding: 0; }
+.oc-act-servers { display: grid; gap: 10px; }
+.oc-act-server { padding: 6px; border: 1px solid var(--oc-line); border-radius: 14px; background: var(--oc-panel); }
+.oc-act-server-head { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 4px 8px; border-radius: 10px; color: var(--oc-text); text-decoration: none; }
+.oc-act-server-head:hover { background: var(--oc-panel-2); }
+.oc-act-server-head strong { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-act-chans { display: grid; gap: 1px; }
+.oc-act-server .oc-act-chans { padding-left: 42px; }
+.oc-act-clear { display: grid; justify-items: center; gap: 6px; max-width: 420px; margin: 9vh auto 0; text-align: center; }
+.oc-act-clear-mark { display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; background: color-mix(in srgb, var(--oc-ok-fill) 16%, transparent); color: var(--oc-ok); animation: oc-act-pop .42s cubic-bezier(.2, .9, .3, 1.4) both; }
+.oc-act-clear-mark .oc-i { width: 30px; height: 30px; stroke-width: 2.4; }
+.oc-act-clear h2 { margin: 8px 0 0; font-size: 1.2rem; }
+.oc-act-clear p { margin: 0; }
+.oc-act-clear .oc-act-clear-go { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 12px; }
+.oc-act-clear-go .oc-button { min-height: 44px; display: inline-flex; align-items: center; }
+@keyframes oc-act-pop { from { opacity: 0; transform: scale(calc(1 - .4 * var(--ov-move, 1))); } }
@@ -36,6 +36,8 @@
@@ -36,6 +36,8 @@
function panel(name) {
function panel(name) {
app.setAttribute('data-oc-panel', name);
app.setAttribute('data-oc-panel', name);
app.classList.toggle('is-members', name === 'members');
app.classList.toggle('is-members', name === 'members');
+ var listButton = app.querySelector('.oc-rail-menu');
+ if (listButton) listButton.setAttribute('aria-expanded', name === 'side' ? 'true' : 'false');
document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {
document.querySelectorAll('[data-oc-panel-toggle]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-oc-panel-toggle') === name ? 'true' : 'false');
b.setAttribute('aria-pressed', b.getAttribute('data-oc-panel-toggle') === name ? 'true' : 'false');
});
});
@@ -88,6 +90,153 @@
@@ -88,6 +90,153 @@
}
}
});
});
+ // ── The rail: on a computer one label follows the tile under the pointer (the rail scrolls, so a label inside it
+ // would be cut off); on a phone the strip fades at the edge that has more, and starts with the open place in view ──
+ var isPhone = function () { return !!(window.matchMedia && window.matchMedia('(max-width: 760px)').matches); };
+ var railEl = app.querySelector('.oc-rail');
+ if (railEl) {
+ var tip = null;
+ var showTip = function (el) {
+ var label = el.querySelector('.oc-rail-label');
+ if (!label || isPhone()) return;
+ if (!tip) { tip = document.createElement('span'); tip.className = 'oc-rail-tip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip); }
+ var box = el.getBoundingClientRect();
+ tip.textContent = label.textContent;
+ tip.style.left = Math.round(box.right + 4) + 'px';
+ tip.style.top = Math.round(box.top + box.height / 2) + 'px';
+ tip.classList.add('is-on');
+ };
+ var hideTip = function () { if (tip) tip.classList.remove('is-on'); };
+ var placeOf = function (e) { return e.target && e.target.closest ? e.target.closest('.oc-rail-icon') : null; };
+ railEl.addEventListener('pointerover', function (e) { var el = placeOf(e); if (el && e.pointerType !== 'touch') showTip(el); });
+ railEl.addEventListener('pointerout', function (e) { var el = placeOf(e); if (el && !el.contains(e.relatedTarget)) hideTip(); });
+ railEl.addEventListener('focusin', function (e) { var el = placeOf(e); if (el && el.matches(':focus-visible')) showTip(el); });
+ railEl.addEventListener('focusout', hideTip);
+ var fade = function () {
+ hideTip();
+ if (!isPhone()) { railEl.removeAttribute('data-fade'); return; }
+ var max = railEl.scrollWidth - railEl.clientWidth, x = railEl.scrollLeft;
+ var start = x > 4, end = x < max - 4;
+ if (start || end) railEl.setAttribute('data-fade', start && end ? 'both' : start ? 'start' : 'end');
+ else railEl.removeAttribute('data-fade');
+ };
+ railEl.addEventListener('scroll', fade, { passive: true });
+ window.addEventListener('resize', fade);
+ var here = railEl.querySelector('.oc-rail-icon.is-active');
+ if (here && isPhone()) { // only as far as it takes to show the open place whole, past the list button
+ var box = here.getBoundingClientRect(), rbox = railEl.getBoundingClientRect();
+ var listButton = railEl.querySelector('.oc-rail-menu');
+ var lead = listButton ? listButton.getBoundingClientRect().right + 16 : rbox.left + 12;
+ if (box.right > rbox.right - 12) railEl.scrollLeft += box.right - (rbox.right - 12);
+ box = here.getBoundingClientRect();
+ if (box.left < lead) railEl.scrollLeft -= lead - box.left;
+ }
+ fade();
+ }
+
+ // ── A server's sheet: what it is, your rank in it, what you may do with it. It opens when a server is held (a
+ // phone) or right-clicked (a computer), from the open server's chip on a phone, and from the three dots on a
+ // server's card. The page carries one template (shell.js SERVER_SHEET); the facts are on the element. ──
+ var RANK = { owner: 'You own this server', admin: 'You are an admin here', mod: 'You are a moderator here', member: 'You are a member' };
+ var sheetAt = 0;
+ function lettersOf(name) {
+ return String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(function (w) { return w.charAt(0); }).join('').toUpperCase() || '?';
+ }
+ function serverSheet(el) {
+ var tpl = document.getElementById('oc-srv-sheet');
+ if (!tpl || !tpl.content || !window.HTMLDialogElement || !el.getAttribute('data-oc-server')) return false;
+ var open = document.querySelector('dialog.oc-ssheet');
+ if (open) open.remove();
+ var dlg = tpl.content.firstElementChild.cloneNode(true);
+ var part = function (k) { return dlg.querySelector('[data-s="' + k + '"]'); };
+ var slug = el.getAttribute('data-oc-server'), name = el.getAttribute('data-oc-name') || slug;
+ var role = el.getAttribute('data-oc-role') || '', members = Number(el.getAttribute('data-oc-members')) || 0;
+ var official = el.hasAttribute('data-oc-official'), closed = el.hasAttribute('data-oc-private');
+ var base = '/s/' + encodeURIComponent(slug);
+ var staff = role === 'owner' || role === 'admin';
+ var mark = part('mark');
+ mark.style.setProperty('--oc-hue', el.getAttribute('data-oc-hue') || '215');
+ mark.textContent = lettersOf(name);
+ part('name').textContent = name;
+ part('official').hidden = !official;
+ part('private').hidden = !closed;
+ part('sub').textContent = (RANK[role] || 'You are not in this server') + ' · ' + members + (members === 1 ? ' member' : ' members');
+ // The sheet carries only what the person may do somewhere (shell.js serverSheet): a part may not be there.
+ var show = function (k, on, to) {
+ var n = part(k);
+ if (!n) return null;
+ n.hidden = !on;
+ if (on && to) n.setAttribute(n.tagName === 'FORM' ? 'action' : 'href', to);
+ return n;
+ };
+ show('open', !(el.classList.contains('oc-rail-icon') && el.classList.contains('is-active')), base);
+ show('invite', staff || (role === 'mod' && !closed), base + '/settings#oc-invites');
+ var settings = show('settings', staff || role === 'mod', base + '/settings');
+ if (settings && role === 'mod') { settings.querySelector('b').textContent = 'Members and invites'; settings.querySelector('small').textContent = 'Who is in it, and who is banned'; }
+ show('leave', !!role && role !== 'owner', base + '/leave');
+ var del = show('delete', role === 'owner' && !official);
+ if (del) {
+ del.querySelector('form').setAttribute('action', base + '/delete');
+ part('delete-line').textContent = 'Delete ' + name + ' and all its channels? Their messages go with it, and it cannot be brought back.';
+ part('keep').addEventListener('click', function () { del.open = false; });
+ }
+ part('close').addEventListener('click', function () { dlg.close(); });
+ // A tap on the dimmed page closes it; the tap that is still ending the hold that opened it does not.
+ dlg.addEventListener('click', function (e) { if (e.target === dlg && Date.now() - sheetAt > 500) dlg.close(); });
+ dlg.addEventListener('close', function () { dlg.remove(); });
+ document.body.appendChild(dlg);
+ dlg.setAttribute('tabindex', '-1');
+ dlg.showModal();
+ dlg.focus(); // the sheet itself, not its Close button: nothing in it is picked on the person's behalf
+ sheetAt = Date.now();
+ return true;
+ }
+ var serverOf = function (e) { return e.target && e.target.closest ? e.target.closest('[data-oc-server]') : null; };
+ document.addEventListener('contextmenu', function (e) {
+ var el = serverOf(e);
+ if (!el) return;
+ if (Date.now() - sheetAt < 600 || serverSheet(el)) e.preventDefault();
+ });
+ // Where a hold raises no context menu (iOS), the hold itself opens the sheet.
+ var holdTimer = 0, holdX = 0, holdY = 0, held = false;
+ var dropHold = function () { if (holdTimer) { clearTimeout(holdTimer); holdTimer = 0; } };
+ document.addEventListener('pointerdown', function (e) {
+ held = false;
+ var el = e.pointerType === 'touch' ? serverOf(e) : null;
+ if (!el) return;
+ holdX = e.clientX; holdY = e.clientY;
+ dropHold();
+ holdTimer = setTimeout(function () { holdTimer = 0; if (Date.now() - sheetAt > 600 && serverSheet(el)) held = true; }, 480);
+ }, { passive: true });
+ document.addEventListener('pointermove', function (e) { if (holdTimer && (Math.abs(e.clientX - holdX) > 10 || Math.abs(e.clientY - holdY) > 10)) dropHold(); }, { passive: true });
+ document.addEventListener('pointerup', dropHold, { passive: true });
+ document.addEventListener('pointercancel', dropHold, { passive: true });
+ document.addEventListener('click', function (e) {
+ if (held) { held = false; e.preventDefault(); e.stopPropagation(); return; }
+ var el = e.target && e.target.closest ? e.target.closest('.oc-card-more[data-oc-server], .oc-rail-server.is-active[data-oc-server]') : null;
+ if (!el || e.ctrlKey || e.metaKey || e.shiftKey) return;
+ if (el.classList.contains('oc-rail-server') && !isPhone()) return; // a computer: the open server's tile is a link like the rest
+ if (serverSheet(el)) e.preventDefault();
+ }, true);
+
+ // ── The composer: Send is lit while there is something to send; Add opens what the place takes (its pictures) ──
+ var composeForm = document.getElementById('oc-compose'), composeInput = document.getElementById('oc-input');
+ if (composeForm && composeInput) {
+ var syncSend = function () { composeForm.classList.toggle('is-empty', !composeInput.value.trim()); };
+ composeInput.addEventListener('input', syncSend);
+ syncSend();
+ var addButton = composeForm.querySelector('[data-oc-attach]'), addPanel = document.getElementById('oc-add-panel');
+ if (addButton && addPanel) {
+ addButton.addEventListener('click', function () { addPanel.open = !addPanel.open; });
+ addPanel.addEventListener('toggle', function () {
+ addButton.setAttribute('aria-expanded', addPanel.open ? 'true' : 'false');
+ addButton.setAttribute('aria-label', addPanel.open ? 'Close' : 'Add a picture');
+ addButton.title = addPanel.open ? 'Close' : 'Add a picture';
+ if (addPanel.open) { var first = addPanel.querySelector('button, input:not([type=hidden])'); if (first) first.focus({ preventScroll: true }); }
+ });
+ }
+ }
+
// ── The shared user popout (openvibe-shared/user-card.js): every name and avatar opens it ──
// ── The shared user popout (openvibe-shared/user-card.js): every name and avatar opens it ──
function token() {
function token() {
var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || '');
@@ -530,14 +530,40 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -530,14 +530,40 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-js .oc-msg.oc-sel .oc-del button { visibility: visible; }
.oc-js .oc-msg.oc-sel .oc-del button { visibility: visible; }
.oc-msg.oc-sel { background: var(--oc-panel-2); }
.oc-msg.oc-sel { background: var(--oc-panel-2); }
}
}
-.oc-dock { display: flex; align-items: flex-end; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
-.oc-dock > .oc-compose { flex: 1 1 0; min-width: 0; }
-.oc-dock > .oc-pic-compose { margin: 0; order: 0; }
+/* ── The composer (shell.js composer): one box. The text is in it, Add is on its left where the place takes
+ pictures, Send on its right; what is being added waits in the tray above the text. The box takes the focus ring,
+ not the text inside it. Send is lit while there is something to send (chat-shell.js .is-empty). The picture panel
+ opens above the box; without the script its own button stays and Add is not shown. ── */
+.oc-dock { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
+.oc-dock > .oc-compose { display: block; min-width: 0; }
+.oc-compose-box { border: 1px solid var(--oc-line); border-radius: 26px; background: var(--oc-bg); transition: border-color .15s ease, box-shadow .15s ease; }
+.oc-compose-box:focus-within { border-color: color-mix(in srgb, var(--oc-accent) 65%, var(--oc-line)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--oc-accent) 16%, transparent); }
+.oc-compose-row { display: flex; align-items: flex-end; gap: 2px; padding: 5px; }
+.oc-compose-box textarea { flex: 1; min-width: 0; min-height: 40px; max-height: 40vh; margin: 0; padding: 9px 8px; border: 0; border-radius: 14px; background: transparent; line-height: 1.4; resize: none; field-sizing: content; }
+.oc-compose-box textarea:focus-visible { outline: 0; }
+.oc-compose-row > textarea:first-child { padding-left: 14px; }
+.oc-compose-add { display: none; place-items: center; width: 40px; height: 40px; flex: none; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--oc-muted); cursor: pointer; transition: background-color .15s ease, color .15s ease, transform .22s cubic-bezier(.2, .9, .3, 1.2); }
+.oc-js .oc-compose-add { display: grid; }
+.oc-compose-add:hover { background: var(--oc-panel-2); color: var(--oc-text); }
+.oc-compose-add[aria-expanded="true"] { transform: rotate(45deg); background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-accent); }
+.oc-compose-add .oc-i { width: 22px; height: 22px; }
+.oc-compose-box .oc-send { width: 40px; height: 40px; border: 0; background: var(--oc-accent); color: var(--oc-on-accent); transition: background-color .15s ease, color .15s ease, transform .2s cubic-bezier(.2, .9, .3, 1.3); }
+.oc-compose.is-empty .oc-compose-box .oc-send { background: var(--oc-panel-2); color: var(--oc-muted); transform: scale(.9); }
+.oc-compose-box .oc-send:active { transform: scale(.92); }
+.oc-compose-tray { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 10px 2px; }
+.oc-compose-tray[hidden] { display: none; }
+.oc-dock > .oc-pic-compose { order: -1; margin: 0; }
.oc-dock > .oc-pic-compose > summary { list-style: none; width: 44px; height: 44px; }
.oc-dock > .oc-pic-compose > summary { list-style: none; width: 44px; height: 44px; }
.oc-dock > .oc-pic-compose > summary::-webkit-details-marker { display: none; }
.oc-dock > .oc-pic-compose > summary::-webkit-details-marker { display: none; }
-.oc-dock > .oc-pic-compose[open] { order: 2; flex: 1 1 100%; }
-.oc-dock > .oc-pic-compose[open] > summary { color: var(--oc-accent); }
-.oc-compose textarea { min-height: 44px; max-height: 40vh; border-radius: 22px; padding: 10px 16px; resize: none; field-sizing: content; }
+.oc-js .oc-dock > .oc-pic-compose > summary, .oc-js .oc-dock > .oc-pic-compose:not([open]) { display: none; }
+.oc-dock > .oc-pic-compose[open] { padding: 14px; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); animation: oc-add-rise .2s ease; }
+.oc-dock > .oc-pic-compose .oc-pic-form { margin: 0; max-width: none; }
+@keyframes oc-add-rise { from { opacity: 0; transform: translateY(calc(8px * var(--ov-move, 1))); } }
+@media (max-width: 760px) {
+ .oc-compose-row { padding: 3px; }
+ .oc-compose-add, .oc-compose-box .oc-send { width: 44px; height: 44px; }
+ .oc-compose-box textarea { min-height: 44px; padding-top: 11px; padding-bottom: 11px; }
+}
.oc-send { display: inline-grid; place-items: center; width: 44px; height: 44px; padding: 0; border-radius: 50%; flex: none; }
.oc-send { display: inline-grid; place-items: center; width: 44px; height: 44px; padding: 0; border-radius: 50%; flex: none; }
.oc-send .oc-i { width: 20px; height: 20px; stroke-width: 2.4; }
.oc-send .oc-i { width: 20px; height: 20px; stroke-width: 2.4; }
/* The conversation takes the screen's height (header, notes and composer around it), not a fixed box. */
/* The conversation takes the screen's height (header, notes and composer around it), not a fixed box. */
@@ -283,7 +283,7 @@
@@ -283,7 +283,7 @@
if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScript
if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScript
e.preventDefault();
e.preventDefault();
ws.send(JSON.stringify({ type: 'chat', message: text }));
ws.send(JSON.stringify({ type: 'chat', message: text }));
- input.value = '';
+ input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
input.focus();
});
});
}
}
@@ -343,7 +343,7 @@
@@ -343,7 +343,7 @@
.then(function (x) {
.then(function (x) {
if (!x.ok) { showError((x.d && x.d.error) || 'Could not send'); return; }
if (!x.ok) { showError((x.d && x.d.error) || 'Could not send'); return; }
if (x.d && x.d.message) { latest = Math.max(latest, Number(x.d.message.id) || 0); append(dmItem(x.d.message), x.d.message.id); }
if (x.d && x.d.message) { latest = Math.max(latest, Number(x.d.message.id) || 0); append(dmItem(x.d.message), x.d.message.id); }
- input.value = ''; input.focus();
+ input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();
})
})
.catch(function () { form.submit(); })
.catch(function () { form.submit(); })
.then(function () { if (btn) btn.disabled = false; });
.then(function () { if (btn) btn.disabled = false; });
@@ -477,7 +477,7 @@
@@ -477,7 +477,7 @@
if (!ws || ws.readyState !== 1) return;
if (!ws || ws.readyState !== 1) return;
e.preventDefault();
e.preventDefault();
ws.send(JSON.stringify({ type: 'room_message', message: text }));
ws.send(JSON.stringify({ type: 'room_message', message: text }));
- input.value = ''; input.focus();
+ input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();
});
});
}
}
if (feed) feed.addEventListener('submit', function (e) {
if (feed) feed.addEventListener('submit', function (e) {
@@ -32,6 +32,8 @@ const ICONS = {
@@ -32,6 +32,8 @@ const ICONS = {
retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),
retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),
pencil: svg('<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>'),
pencil: svg('<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>'),
plus: svg('<path d="M12 5v14M5 12h14"/>'),
plus: svg('<path d="M12 5v14M5 12h14"/>'),
+ close: svg('<path d="M6 6l12 12M18 6 6 18"/>'),
+ enter: svg('<path d="M5 12h13M13 6l6 6-6 6"/>'),
phone: svg('<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>'),
phone: svg('<path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/>'),
ban: svg('<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>'),
ban: svg('<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>'),
key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),
key: svg('<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/>'),
@@ -109,7 +109,7 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
@@ -109,7 +109,7 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: 'Inbox', path: '/inbox', active: 'inbox', robots: 'noindex, nofollow', shell: true, footer: 'none',
actor, title: 'Inbox', path: '/inbox', active: 'inbox', robots: 'noindex, nofollow', shell: true, footer: 'none',
body: shell.shell({
body: shell.shell({
- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
side: shell.sideInbox({ inboxes: mine, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),
side: shell.sideInbox({ inboxes: mine, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),
sideLabel: 'Inbox',
sideLabel: 'Inbox',
main: `<h1>Inbox</h1>
main: `<h1>Inbox</h1>
@@ -168,7 +168,7 @@ ${list}
@@ -168,7 +168,7 @@ ${list}
const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
const mineServers = (await require('../rooms/servers').list(actor.user)).mine;
const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
const body = `<div class="ibk" id="ib" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">${shell.shell({
const body = `<div class="ibk" id="ib" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">${shell.shell({
- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
side: conversationsRail({ inbox, list, filter, current, me, nameOf, waiting: counts.waiting }),
side: conversationsRail({ inbox, list, filter, current, me, nameOf, waiting: counts.waiting }),
sideLabel: 'Conversations',
sideLabel: 'Conversations',
main: pane,
main: pane,
@@ -271,7 +271,7 @@ function createWebRoutes({ config }) {
@@ -271,7 +271,7 @@ function createWebRoutes({ config }) {
}
}
} catch { /* the names stay plain */ }
} catch { /* the names stay plain */ }
const composer = actor.kind === 'user'
const composer = actor.kind === 'user'
- ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<div class="oc-dock"><form class="oc-compose" method="post" action="/send" id="oc-compose"><label for="oc-input" class="oc-sr">Message to everyone</label><textarea id="oc-input" name="message" maxlength="500" rows="1" required placeholder="Message everyone on OpenVibe"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`)
+ ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : shell.composer({ action: '/send', label: 'Message to everyone', placeholder: 'Message everyone on OpenVibe', max: 500 }))
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const signedIn = actor.kind === 'user';
const signedIn = actor.kind === 'user';
@@ -314,7 +314,7 @@ function createWebRoutes({ config }) {
@@ -314,7 +314,7 @@ function createWebRoutes({ config }) {
const contacts = await require('../chat/contacts').of(me.id);
const contacts = await require('../chat/contacts').of(me.id);
const mineServers = (await require('../rooms/servers').list(me)).mine;
const mineServers = (await require('../rooms/servers').list(me)).mine;
return shell.shell({
return shell.shell({
- rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: shell.railServers(mineServers) }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
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',
sideLabel: 'Home',
main: `<header class="oc-chan-head"><h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>
main: `<header class="oc-chan-head"><h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>
@@ -372,6 +372,63 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
@@ -372,6 +372,63 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
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>`
return `<li class="oc-row${unread ? ' oc-unread' : ''}"><a href="${esc(href)}">${ui.initials(title, personPresence, { avatarUrl })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(title)}</strong>${badge}${t.text ? `<time datetime="${t.iso}">${esc(t.text)}</time>` : ''}</span>`
+ `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;
+ `<span class="oc-row-sub">${esc(preview)}${unread ? `<span class="oc-dot" aria-label="${unread} new">${unread > 9 ? '9+' : unread}</span>` : ''}</span></span></a></li>`;
};
};
+ // ── Activity (the bottom bar's fourth place): what is waiting for you, in one page. People who asked to be your
+ // friend, conversations with something new, and the channels and rooms with unread messages, each a row that opens
+ // the place. Nothing waiting is said as that, with the two places most people go next. ──
+ router.get('/activity', async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const me = actor.user;
+ const serversApi = require('../rooms/servers');
+ const convs = await dm.getConversations(me.id);
+ const participants = await Promise.all(convs.map((c) => dm.getParticipants(c.id)));
+ await presence.attachTo(me.subject_id, participants.flat());
+ const dms = convs.map((c, i) => {
+ const others = participants[i].filter((p) => p.id !== me.id);
+ return {
+ id: c.id, title: c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you',
+ 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, presence: others.length === 1 ? others[0].presence : null,
+ avatarUrl: others.length === 1 ? others[0].avatar_url : '',
+ };
+ });
+ const unread = dms.reduce((n, d) => n + d.unread, 0);
+ const mineServers = (await serversApi.list(me)).mine;
+ const contacts = await require('../chat/contacts').of(me.id);
+ // The channels with something new, by server (a dozen servers at most: the rest are on the Servers page).
+ const busy = mineServers.filter((sv) => sv.unread).slice(0, 12);
+ const busyChannels = await Promise.all(busy.map(async (sv) => {
+ try { return (await serversApi.channels(await serversApi.bySlug(sv.slug), me)).filter((ch) => ch.unread); } catch { return []; }
+ }));
+ const myRooms = (await require('../rooms/rooms').list(me)).mine.filter((r) => r.unread);
+ const requests = contacts.requests || [];
+ const requestRows = requests.map((r) => `<li class="oc-row oc-act-row"><span class="oc-act-who">${ui.initials(r.display_name || r.username || '?', null, { avatarUrl: r.avatar_url })}<span class="oc-row-main"><span class="oc-row-top"><strong>${esc(r.display_name || r.username || '?')}</strong></span><span class="oc-row-sub">@${esc(r.username || '')} asked to be your friend</span></span></span><form class="oc-inline" method="post" action="/messages/contacts/${Number(r.id)}/accept"><button type="submit">Accept</button></form></li>`);
+ const convRows = dms.filter((d) => d.unread).map((d) => convRow({ href: `/messages/${d.id}`, title: d.title, preview: d.preview, at: d.at, unread: d.unread, personPresence: d.presence, avatarUrl: d.avatarUrl }));
+ const serverBlocks = busy.map((sv, i) => `<li class="oc-act-server"><a class="oc-act-server-head" href="/s/${esc(sv.slug)}">${ui.serverMark(sv)}<strong>${esc(sv.name)}</strong></a>
+<ul class="oc-act-chans">${busyChannels[i].map((ch) => `<li><a class="oc-chan is-unread" href="/s/${esc(sv.slug)}/${esc(ch.slug)}">${ch.kind === 'voice' ? IC.voice : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(ch.name)}</span><span class="oc-chan-dot" aria-label="${ch.unread} unread">${ch.unread > 99 ? '99+' : ch.unread}</span></a></li>`).join('')}</ul></li>`);
+ const roomRows = myRooms.map((r) => `<li><a class="oc-chan is-unread" href="/r/${esc(r.slug)}"><span class="oc-chan-hash" aria-hidden="true">#</span><span class="oc-chan-name">${esc(r.name)}</span><span class="oc-chan-dot" aria-label="${r.unread} unread">${r.unread > 99 ? '99+' : r.unread}</span></a></li>`);
+ const section = (id, title, n, inner) => (n ? `<section class="oc-act-sec" aria-labelledby="${id}"><h2 class="oc-list-head" id="${id}">${title} <span class="oc-muted">${n}</span></h2>${inner}</section>` : '');
+ const waiting = requests.length + convRows.length + busy.length + roomRows.length;
+ const body = waiting
+ ? [
+ section('oc-act-requests', 'Friend requests', requests.length, `<ul class="oc-list">${requestRows.join('')}</ul>`),
+ section('oc-act-convs', 'New messages', convRows.length, `<ul class="oc-list">${convRows.join('\n')}</ul>`),
+ section('oc-act-servers', 'In your servers', busy.length, `<ul class="oc-act-servers">${serverBlocks.join('')}</ul>`),
+ section('oc-act-rooms', 'In your rooms', roomRows.length, `<ul class="oc-act-chans oc-act-rooms">${roomRows.join('')}</ul>`),
+ ].join('\n')
+ : `<section class="oc-act-clear"><span class="oc-act-clear-mark" aria-hidden="true">${IC.check}</span><h2>You are all caught up</h2><p class="oc-muted">New messages, friend requests and unread channels show up here.</p><p class="oc-act-clear-go"><a class="oc-button" href="/messages">Open your messages</a><a class="oc-button oc-button--quiet" href="/servers">Find a server</a></p></section>`;
+ await page(req, res, 200, {
+ actor, shell: true, title: 'Activity', path: '/activity', active: 'activity', robots: 'noindex, nofollow', footer: 'none',
+ body: shell.shell({
+ rail: shell.rail({ dmUnread: unread, servers: shell.railServers(mineServers), list: 'Chats' }),
+ 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: 'Activity', sub: waiting ? 'What is waiting for you' : 'Nothing is waiting for you' })}\n${body}`,
+ mainLabel: 'Activity',
+ page: true,
+ }),
+ });
+ });
+
router.get('/messages', async (req, res) => {
router.get('/messages', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const me = actor.user;
const me = actor.user;
@@ -413,7 +470,7 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
@@ -413,7 +470,7 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
actor, shell: true, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
actor, shell: true, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
page: { view: 'inbox', unread, me: me.id },
page: { view: 'inbox', unread, me: me.id },
body: shell.shell({
body: shell.shell({
- rail: shell.rail({ home: true, dmUnread: unread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ home: true, dmUnread: unread, servers: shell.railServers(mineServers) }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
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',
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}
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}
@@ -501,7 +558,7 @@ ${blockedList}`,
@@ -501,7 +558,7 @@ ${blockedList}`,
const liveHref = otherPresence && (otherPresence.channel_url || otherPresence.url);
const liveHref = otherPresence && (otherPresence.channel_url || otherPresence.url);
const subLine = other ? (ui.presenceLine(otherPresence, { liveHref }) || `@${esc(other.username || '')}`) : `${others.length + 1} people`;
const subLine = other ? (ui.presenceLine(otherPresence, { liveHref }) || `@${esc(other.username || '')}`) : `${others.length + 1} people`;
const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar', avatarUrl: other.avatar_url }) : '';
const avatar = other ? ui.initials(other.display_name || other.username || '?', otherPresence, { large: true, id: 'oc-dm-avatar', avatarUrl: other.avatar_url }) : '';
- let composer = `<div class="oc-dock"><form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Write a message"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></form></div>`;
+ let composer = shell.composer({ action: `/messages/${id}`, label: 'Reply', placeholder: 'Write a message' });
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
if (iBlocked) composer = `${notice('error', `You blocked ${other.display_name || other.username}. Neither of you can message the other.`)}<form class="oc-inline" method="post" action="/messages/unblock/${Number(other.id)}"><input type="hidden" name="back" value="/messages/${id}"><button type="submit">Unblock ${otherName}</button></form>`;
else if (closed) composer = notice('error', 'You cannot message this person.');
else if (closed) composer = notice('error', 'You cannot message this person.');
// Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.
// Blocking is in the conversation's "more" menu: one tap on an icon in the header should never block someone.
@@ -529,7 +586,7 @@ ${blockedList}`,
@@ -529,7 +586,7 @@ ${blockedList}`,
actor, shell: true, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'none',
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 },
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({
body: shell.shell({
- rail: shell.rail({ home: true, dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ home: true, dmUnread, servers: shell.railServers(mineServers) }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
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',
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}
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}
@@ -645,7 +702,7 @@ ${composer}`,
@@ -645,7 +702,7 @@ ${composer}`,
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', shell: true, footer: 'none',
actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', shell: true, footer: 'none',
body: shell.shell({
body: shell.shell({
- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
side: shell.sideSettings({ items: sections, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),
side: shell.sideSettings({ items: sections, you: { name: actor.user.display_name || actor.user.username, status: 'Online', avatarUrl: actor.user.avatar_url || '' } }),
sideLabel: 'Settings',
sideLabel: 'Settings',
main: `${ui.head({ title: 'Settings', sub: 'Saved to your account: here, in Live chat and on every device' })}${saved}${error}
main: `${ui.head({ title: 'Settings', sub: 'Saved to your account: here, in Live chat and on every device' })}${saved}${error}
@@ -787,7 +844,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -787,7 +844,7 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');
const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');
/** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */
/** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */
- const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary class="oc-icon-btn" aria-label="Add a picture" title="Add a picture">${ICONS.image}</summary>
+ const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose" id="oc-add-panel"><summary class="oc-icon-btn" aria-label="Add a picture" title="Add a picture">${ICONS.image}</summary>
<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" data-room="${esc(room.slug)}" data-picture-upload-form>
<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" data-room="${esc(room.slug)}" data-picture-upload-form>
<input type="hidden" name="back" value="${esc(back)}">
<input type="hidden" name="back" value="${esc(back)}">
<div class="oc-field oc-field--wide"><span>Picture</span>
<div class="oc-field oc-field--wide"><span>Picture</span>
@@ -896,7 +953,7 @@ ${create}
@@ -896,7 +953,7 @@ ${create}
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', shell: true, footer: 'none',
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow', shell: true, footer: 'none',
description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
body: shell.shell({
body: shell.shell({
- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
side: sideRooms,
side: sideRooms,
sideLabel: 'Rooms',
sideLabel: 'Rooms',
main: content,
main: content,
@@ -972,7 +1029,7 @@ ${people}
@@ -972,7 +1029,7 @@ ${people}
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
let composer;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
- else if (can.post) composer = `<div class="oc-dock">${room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`)}<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="${room.kind === 'system' ? 'Post an announcement' : 'Write a message'}"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${ICONS.send}</button></form></div>`;
+ else if (can.post) composer = shell.composer({ action: `/r/${room.slug}`, label: `Message to ${room.name}`, placeholder: room.kind === 'system' ? 'Post an announcement' : 'Write a message', add: room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`) });
else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
@@ -1009,7 +1066,7 @@ ${people}
@@ -1009,7 +1066,7 @@ ${people}
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: shell.shell({
body: shell.shell({
- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
side: shell.sideRooms({
side: shell.sideRooms({
mine: listed.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
mine: listed.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
public: listed.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
public: listed.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
@@ -1165,7 +1222,7 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
@@ -1165,7 +1222,7 @@ ${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}</details>` : '';
const listedRooms = await rooms.list(actor.user);
const listedRooms = await rooms.list(actor.user);
const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
const dmUnread = await require('../chat/dm').getTotalUnread(actor.user.id);
const roomShell = (main) => shell.shell({
const roomShell = (main) => shell.shell({
- rail: shell.rail({ dmUnread, servers: mineServers.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread })) }),
+ rail: shell.rail({ dmUnread, servers: shell.railServers(mineServers) }),
side: shell.sideRooms({
side: shell.sideRooms({
mine: listedRooms.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
mine: listedRooms.mine.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
public: listedRooms.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
public: listedRooms.public.map((r) => ({ slug: r.slug, name: r.name, unread: r.unread, private: r.visibility === 'private', current: r.slug === room.slug })),
@@ -24,6 +24,7 @@ const ctx = require('../live-context');
@@ -24,6 +24,7 @@ const ctx = require('../live-context');
const LIVE = 'https://openvibe.live';
const LIVE = 'https://openvibe.live';
const RANK_TITLE = { owner: 'Owner', admin: 'Admins', mod: 'Moderators', member: 'Members' };
const RANK_TITLE = { owner: 'Owner', admin: 'Admins', mod: 'Moderators', member: 'Members' };
const ROLE_NAME = { owner: 'owner', admin: 'admin', mod: 'moderator', member: 'member', blocked: 'banned' };
const ROLE_NAME = { owner: 'owner', admin: 'admin', mod: 'moderator', member: 'member', blocked: 'banned' };
+const YOURS = { owner: 'You own it', admin: 'You are an admin', mod: 'You moderate it', member: 'You are in it' };
const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', member: 'a member' };
const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', member: 'a member' };
const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';
const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" 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"/></svg>';
const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" 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"/></svg>';
@@ -84,7 +85,7 @@ function mountServerPages(router, h) {
@@ -84,7 +85,7 @@ function mountServerPages(router, h) {
const card = (sv) => {
const card = (sv) => {
const isMember = yours.has(sv.slug);
const isMember = yours.has(sv.slug);
const join = isMember
const join = isMember
- ? `<a class="oc-button oc-button--quiet" href="/s/${esc(sv.slug)}">Open</a>`
+ ? `<p class="oc-card-go"><a class="oc-button oc-button--quiet" href="/s/${esc(sv.slug)}">Open</a><a class="oc-icon-btn oc-card-more" href="/s/${esc(sv.slug)}" aria-label="More about ${esc(sv.name)}" title="More"${shell.serverData(shell.railServers([sv])[0])}>${IC.more}</a></p>`
: (me ? `<form method="post" action="/s/${esc(sv.slug)}/join"><button type="submit">Join</button></form>`
: (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>`);
: `<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">
return `<article class="oc-card oc-server-card">
@@ -92,7 +93,7 @@ function mountServerPages(router, h) {
@@ -92,7 +93,7 @@ function mountServerPages(router, h) {
<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(sv.slug)}" aria-hidden="true">${esc(initials(sv.name))}</span>
<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>
<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-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>
+<p class="oc-muted oc-card-meta">${isMember && YOURS[sv.role] ? `<span class="oc-card-yours">${YOURS[sv.role]}</span> · ` : ''}${Number(sv.members) || 0} member${Number(sv.members) === 1 ? '' : 's'}${online[sv.slug] ? ` · ${online[sv.slug]} online` : ''}</p>
${join}</article>`;
${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>`;
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>`;
@@ -132,7 +133,7 @@ ${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"
@@ -132,7 +133,7 @@ ${mine.length ? `<ul class="oc-side-list">${mine.map((sv) => `<li class="oc-row"
actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
actor, shell: true, title: 'Explore servers', path: '/servers', active: 'servers', robots: 'index, follow', footer: 'none', upload: require('../rooms/pictures').enabled(), scripts: require('../rooms/pictures').enabled() ? ['picture-upload.js'] : [],
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
body: shell.shell({
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 })) }),
+ rail: shell.rail({ explore: true, dmUnread: me ? await require('../chat/dm').getTotalUnread(me.id) : 0, servers: shell.railServers(mine) }),
side, sideLabel: 'Your servers',
side, sideLabel: 'Your servers',
main: `${ui.head({ title: 'Explore servers', sub: `${listed.length} server${listed.length === 1 ? '' : 's'}` })}
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>
<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>
@@ -216,7 +217,7 @@ ${sheet}`,
@@ -216,7 +217,7 @@ ${sheet}`,
const replyForm = me && post && data.viewer && data.viewer.can_reply ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/replies"><label>Reply <textarea name="body" maxlength="40000" rows="4" required></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Reply</button></form>` : '';
const replyForm = me && post && data.viewer && data.viewer.can_reply ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/replies"><label>Reply <textarea name="body" maxlength="40000" rows="4" required></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Reply</button></form>` : '';
const modForm = me && post && data.viewer && data.viewer.can_moderate ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/state"><label><input type="checkbox" name="pinned" value="true"${post.pinned ? ' checked' : ''}> Pinned</label><label><input type="checkbox" name="locked" value="true"${post.locked ? ' checked' : ''}> Locked</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save moderation</button></form>` : '';
const modForm = me && post && data.viewer && data.viewer.can_moderate ? `<form class="oc-form" method="post" action="/s/${esc(server.slug)}/${esc(pick.slug)}/forum/${esc(post.slug)}/state"><label><input type="checkbox" name="pinned" value="true"${post.pinned ? ' checked' : ''}> Pinned</label><label><input type="checkbox" name="locked" value="true"${post.locked ? ' checked' : ''}> Locked</label><button class="ovk-btn ovk-btn--quiet" type="submit">Save moderation</button></form>` : '';
const shellBody = shell.shell({
const shellBody = shell.shell({
- rail: shell.rail({ servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })), dmUnread }),
+ rail: shell.rail({ servers: shell.railServers(mine, server.slug), dmUnread }),
side: shell.sideServer({ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,
side: shell.sideServer({ server: { ...server, official: server.slug === servers.OFFICIAL_SLUG }, can, me: !!me,
categories: [{ name: 'Forum channels', channels: chans.filter((c) => c.kind === 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) },
categories: [{ name: 'Forum channels', channels: chans.filter((c) => c.kind === 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) },
{ name: 'Other channels', channels: chans.filter((c) => c.kind !== 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) }], occupants: {},
{ name: 'Other channels', channels: chans.filter((c) => c.kind !== 'forum').map((c) => ({ kind: c.kind, name: c.name, slug: c.slug, href: `/s/${server.slug}/${c.slug}` })) }], occupants: {},
@@ -256,7 +257,7 @@ ${sheet}`,
@@ -256,7 +257,7 @@ ${sheet}`,
let composer;
let composer;
const back = `/s/${server.slug}/${pick.slug}`;
const back = `/s/${server.slug}/${pick.slug}`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
- else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>${pictureComposer ? pictureComposer(room, back) : ''}`;
+ else if (rcan.post) composer = shell.composer({ action: back, label: `Message ${isVoice ? '' : '#'}${room.name}`, placeholder: `Message ${isVoice ? '' : '#'}${room.name}`, add: pictureComposer ? pictureComposer(room, back) : '' });
else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
else composer = notice('error', 'You cannot write here right now.');
else composer = notice('error', 'You cannot write here right now.');
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
@@ -299,7 +300,7 @@ ${sheet}`,
@@ -299,7 +300,7 @@ ${sheet}`,
},
},
body: shell.shell({
body: shell.shell({
rail: shell.rail({
rail: shell.rail({
- servers: mine.map((s) => ({ slug: s.slug, name: s.name, unread: s.unread, active: s.slug === server.slug })),
+ servers: shell.railServers(mine, server.slug),
dmUnread,
dmUnread,
}),
}),
side: shell.sideServer({
side: shell.sideServer({
@@ -379,7 +380,7 @@ ${composer}`,
@@ -379,7 +380,7 @@ ${composer}`,
async function serverRail(me, activeSlug) {
async function serverRail(me, activeSlug) {
const mine = me ? (await servers.list(me)).mine : [];
const mine = me ? (await servers.list(me)).mine : [];
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
- return shell.rail({ dmUnread, servers: mine.map((sv) => ({ slug: sv.slug, name: sv.name, unread: sv.unread, active: sv.slug === activeSlug })) });
+ return shell.rail({ dmUnread, servers: shell.railServers(mine, activeSlug) });
}
}
router.get('/s/:slug/welcome', async (req, res) => {
router.get('/s/:slug/welcome', async (req, res) => {
@@ -28,32 +28,69 @@ const { ICONS: IC } = require('./icons');
@@ -28,32 +28,69 @@ const { ICONS: IC } = require('./icons');
/** The five places of the phone's bottom bar: Shared's app bar draws them (appbar.js). */
/** The five places of the phone's bottom bar: Shared's app bar draws them (appbar.js). */
const APP_BAR = [
const APP_BAR = [
{ label: 'Home', href: '/messages', icon: 'fa-comment-dots', page: 'messages' },
{ label: 'Home', href: '/messages', icon: 'fa-comment-dots', page: 'messages' },
- { label: 'Servers', href: '/servers', icon: 'fa-globe', page: 'servers' },
+ { label: 'Servers', href: '/servers', icon: 'fa-people-group', page: 'servers' }, // not the globe: that is Global chat
{ label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles', page: 'vibe' },
{ label: 'Vibe', href: '/vibe', icon: 'fa-wand-magic-sparkles', page: 'vibe' },
- { label: 'Activity', href: '/notifications', icon: 'fa-bell', page: 'notifications' },
+ { label: 'Activity', href: '/activity', icon: 'fa-bell', page: 'activity' },
{ label: 'You', href: '/settings', icon: 'fa-user', page: 'settings' },
{ 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. */
/** 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>`;
const iconBtn = (href, label, svg, extra = '') => `<a class="oc-icon-btn" href="${esc(href)}" aria-label="${esc(label)}" title="${esc(label)}"${extra}>${svg}</a>`;
+/** What the rail and a server's sheet need of each server (servers.list(me).mine). */
+const railServers = (mine, active) => (mine || []).map((s) => ({
+ slug: s.slug, name: s.name, unread: s.unread, role: s.role || '', members: Number(s.members) || 0,
+ private: s.visibility === 'private', official: !!s.official, active: !!active && s.slug === active,
+}));
+
+/** A server's facts on the element that opens its sheet (chat-shell.js reads them: hold, right-click, or the open one). */
+const serverData = (s) => ` data-oc-server="${esc(s.slug)}" data-oc-name="${esc(s.name)}" data-oc-role="${esc(s.role || '')}" data-oc-members="${Number(s.members) || 0}" data-oc-hue="${ui.hueOf(s.slug)}"${s.private ? ' data-oc-private' : ''}${s.official ? ' data-oc-official' : ''}`;
+
+/**
+ * A server's sheet, once a page: what it is, your rank in it, and what you may do with it (open, invite, settings,
+ * leave, delete). chat-shell.js fills a copy from the element that was held; without the script a server's own page
+ * has the same actions in its side column. It carries only what the person may do in at least one of their servers:
+ * someone who runs none is not shown a word about settings.
+ */
+function serverSheet(servers) {
+ const runs = servers.some((s) => ['owner', 'admin', 'mod'].includes(s.role));
+ const owns = servers.some((s) => s.role === 'owner' && !s.official);
+ const leaves = servers.some((s) => s.role && s.role !== 'owner');
+ return `<template id="oc-srv-sheet"><dialog class="oc-ssheet" aria-labelledby="oc-ssheet-name">
+<header class="oc-ssheet-head"><span class="oc-srv-icon oc-srv-icon--row" data-s="mark" aria-hidden="true"></span><div class="oc-ssheet-id"><h2 id="oc-ssheet-name"><span data-s="name"></span><span class="oc-badge oc-badge-official" data-s="official">official</span><span class="oc-badge" data-s="private">private</span></h2><p class="oc-muted" data-s="sub"></p></div><button type="button" class="oc-icon-btn oc-ssheet-close" data-s="close" aria-label="Close" title="Close">${IC.close}</button></header>
+<nav class="oc-ssheet-list" aria-label="This server">
+<a class="oc-pop-item" data-s="open">${IC.enter}<span>Open</span></a>
+${runs ? `<a class="oc-pop-item" data-s="invite">${IC.members}<span>Invite people</span></a>
+<a class="oc-pop-item" data-s="settings">${IC.gear}<span><b>Server settings</b><small>Its name, channels, roles and members</small></span></a>` : ''}
+${leaves ? `<form method="post" data-s="leave"><button type="submit" class="oc-pop-item">${IC.leave}<span>Leave this server</span></button></form>` : ''}
+${owns ? `<details class="oc-ssheet-danger" data-s="delete"><summary class="oc-pop-item">${IC.trash}<span>Delete this server</span></summary>
+<form method="post" class="oc-ssheet-confirm"><input type="hidden" name="confirm" value="1"><p data-s="delete-line"></p><p class="oc-ssheet-go"><button type="submit" class="oc-danger">Delete it</button><button type="button" class="oc-button--quiet" data-s="keep">Keep it</button></p></form></details>` : ''}
+</nav></dialog></template>`;
+}
+
/**
/**
- * The rail: Home, Global chat and Vibe, your servers, then Add a server and Explore.
- * o: { servers: [{ slug, name, unread, active }], dmUnread, home, global, vibe, explore }
+ * The rail: the list button (a phone), Home, Global chat and Vibe, your servers, then New and Explore. Every place
+ * carries its name: beside the tile on a phone, where the rail is a strip of chips, and as a label that follows the
+ * pointer's tile on a computer (chat-shell.js). On a phone Home, Vibe and Explore are the bottom bar's, so the strip
+ * leaves them out while that bar is there (chat-shell.css).
+ * o: { servers: railServers(mine, activeSlug), dmUnread, home, global, vibe, explore, list }
*/
*/
function rail(o = {}) {
function rail(o = {}) {
const servers = o.servers || [];
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>`;
+ const list = o.list || (o.home || o.global ? 'Chats' : servers.some((s) => s.active) ? 'Channels' : o.explore ? 'Yours' : 'Browse');
+ const dot = (n) => (n ? `<span class="oc-rail-dot" aria-label="${n} unread">${n > 99 ? '99+' : n}</span>` : '');
+ const place = (cls, href, name, inner, on, extra = '') => `<a class="oc-rail-icon ${cls}${on ? ' is-active' : ''}" href="${href}"${on ? ' aria-current="page"' : ''}><span class="oc-rail-tile" aria-hidden="true">${inner}</span><span class="oc-rail-label">${esc(name)}</span>${extra}</a>`;
+ const server = (s) => `<a class="oc-rail-icon oc-rail-server${s.active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" style="--oc-hue:${ui.hueOf(s.slug)}"${s.active ? ' aria-current="page"' : ''}${serverData(s)}><span class="oc-rail-tile" aria-hidden="true">${esc(ui.serverLetters(s.name))}</span><span class="oc-rail-label">${esc(s.name)}</span>${s.active ? `<span class="oc-rail-more" aria-hidden="true">${IC.chevron}</span>` : ''}${dot(s.unread)}</a>`;
return `<nav class="oc-rail" aria-label="Chat destinations">
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-global${o.global ? ' is-active' : ''}" href="/" title="Global chat"${o.global ? ' aria-current="page"' : ''}>${IC.globe}<span class="oc-sr">Global chat</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>
+<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-expanded="false" title="Show the list">${IC.menu}<span class="oc-rail-label">${esc(list)}</span></button>
+${place('oc-rail-home', '/messages', 'Home', IC.chat, o.home, dot(o.dmUnread))}
+${place('oc-rail-global', '/', 'Global', IC.globe, o.global)}
+${place('oc-rail-vibe', '/vibe', 'Vibe', IC.spark, o.vibe)}
<span class="oc-rail-divider" role="separator" aria-hidden="true"></span>
<span class="oc-rail-divider" role="separator" aria-hidden="true"></span>
${servers.map(server).join('')}
${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>`;
+${place('oc-rail-add', '/servers#oc-new-server', 'New', IC.plus, false)}
+${place('oc-rail-explore', '/servers', 'Explore', IC.search, o.explore)}
+${servers.length ? serverSheet(servers) : ''}</nav>`;
}
}
/** One person in a list: their mark, their name and a muted line under it. */
/** One person in a list: their mark, their name and a muted line under it. */
@@ -184,6 +221,20 @@ ${inboxes.length ? `<ul class="oc-side-list">${inboxes.map(row).join('')}</ul>`
@@ -184,6 +221,20 @@ ${inboxes.length ? `<ul class="oc-side-list">${inboxes.map(row).join('')}</ul>`
${youBar(o.you || {})}`;
${youBar(o.you || {})}`;
}
}
+/**
+ * The composer, the same wherever a person writes (Global chat, a conversation, a room, a channel): one box with the
+ * text in it, Add on its left where the place takes pictures, Send on its right. What is being added waits in the
+ * tray above the text. It posts as a plain form; chat.js sends it over the socket's API instead, and chat-shell.js
+ * grows the box with the text and lights Send when there is something to send.
+ * o: { action, label, placeholder, max, add: the panel Add opens (pictureComposer's), hint }
+ */
+function composer(o = {}) {
+ const add = o.add || '';
+ return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose"><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>
+<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div>
+<div class="oc-compose-row">${add ? `<button type="button" class="oc-compose-add" data-oc-attach aria-expanded="false" aria-controls="oc-add-panel" aria-label="Add a picture" title="Add a picture">${IC.plus}</button>` : ''}<textarea id="oc-input" name="message" maxlength="${Number(o.max) || 2000}" rows="1" required placeholder="${esc(o.placeholder || 'Write a message')}"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${IC.send}</button></div></div></form>${add}</div>`;
+}
+
/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */
/** The you-bar: your mark, your status, mute, deafen and settings (the foot of the side column). */
function youBar(o = {}) {
function youBar(o = {}) {
if (!o.name) return '';
if (!o.name) return '';
@@ -231,4 +282,4 @@ ${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersL
@@ -231,4 +282,4 @@ ${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersL
</div>`;
</div>`;
}
}
-module.exports = { APP_BAR, rail, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };
+module.exports = { APP_BAR, rail, railServers, serverData, composer, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };
@@ -195,8 +195,8 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<cha
@@ -195,8 +195,8 @@ t('the site: directory, a server page, settings by rank, an invite page, /r/<cha
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /class="oc-app"/, 'the app shell');
assert.match(r.text, /class="oc-app"/, 'the app shell');
assert.match(r.text, /aria-label="Chat destinations"/);
assert.match(r.text, /aria-label="Chat destinations"/);
- assert.match(r.text, /title="Home"/);
- assert.match(r.text, /title="Vibe"/);
+ assert.match(r.text, /<span class="oc-rail-label">Home<\/span>/);
+ assert.match(r.text, /<span class="oc-rail-label">Vibe<\/span>/);
assert.match(r.text, /class="oc-col oc-col-members"/, 'the members column');
assert.match(r.text, /class="oc-col oc-col-members"/, 'the members column');
assert.match(r.text, /aria-current="page"[^>]*>.*general/s);
assert.match(r.text, /aria-current="page"[^>]*>.*general/s);
assert.match(r.text, /Voice channels/);
assert.match(r.text, /Voice channels/);
@@ -43,8 +43,8 @@ t('the shell: the rail, the side column, the members column and the bottom bar',
@@ -43,8 +43,8 @@ t('the shell: the rail, the side column, the members column and the bottom bar',
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /class="oc-app"/, 'the shell');
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, /<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, /<span class="oc-rail-label">Home<\/span>/);
+ assert.match(r.text, /<span class="oc-rail-label">Vibe<\/span>/);
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-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-rail-icon oc-rail-explore"/, 'Explore');
assert.match(r.text, /class="oc-side"/, 'the side column');
assert.match(r.text, /class="oc-side"/, 'the side column');
@@ -58,8 +58,40 @@ t('the shell: the rail, the side column, the members column and the bottom bar',
@@ -58,8 +58,40 @@ t('the shell: the rail, the side column, the members column and the bottom bar',
assert.match(r.text, /"label":"Home","href":"\/messages"/);
assert.match(r.text, /"label":"Home","href":"\/messages"/);
assert.match(r.text, /"label":"Servers","href":"\/servers"/);
assert.match(r.text, /"label":"Servers","href":"\/servers"/);
assert.match(r.text, /"label":"Vibe","href":"\/vibe"/);
assert.match(r.text, /"label":"Vibe","href":"\/vibe"/);
- assert.match(r.text, /"label":"Activity","href":"\/notifications"/);
+ assert.match(r.text, /"label":"Activity","href":"\/activity"/, 'Activity is a page that exists');
assert.match(r.text, /"label":"You","href":"\/settings"/);
assert.match(r.text, /"label":"You","href":"\/settings"/);
+ assert.match(r.text, /"label":"Servers","href":"\/servers","icon":"fa-people-group"/, 'Servers is not the globe: that is Global chat');
+ // Every place on the rail carries its name; a server carries what its sheet says (hold it, or right-click it).
+ assert.match(r.text, /class="oc-rail-icon oc-rail-server is-active" href="\/s\/saturday-raiders"[^>]* data-oc-server="saturday-raiders" data-oc-name="Saturday Raiders" data-oc-role="owner" data-oc-members="2"/, 'the open server, with your rank in it');
+ assert.match(r.text, /<span class="oc-rail-label">Saturday Raiders<\/span>/, 'its name, not its initials alone');
+ assert.match(r.text, /<button type="button" class="oc-rail-menu" data-oc-panel="side" aria-expanded="false"[^>]*>.*<span class="oc-rail-label">Channels<\/span><\/button>/, 'the list button says what it lists');
+ assert.match(r.text, /<template id="oc-srv-sheet"><dialog class="oc-ssheet"/, "a server's sheet, once a page");
+ assert.ok(/data-s="settings"/.test(r.text) && /data-s="delete"/.test(r.text) && !/data-s="leave"/.test(r.text), 'the owner: settings and delete, and no leave (an owner cannot leave)');
+ const asBob = await page(`/s/${slug}`, bob);
+ assert.ok(/data-s="leave"/.test(asBob.text) && !/data-s="settings"/.test(asBob.text) && !/data-s="delete"/.test(asBob.text), 'a member: leave, and not a word about settings or deleting');
+ // One composer: a box, Send on its right; no picture store in this run, so no Add.
+ assert.match(r.text, /<form class="oc-compose" method="post" action="\/s\/saturday-raiders\/general" id="oc-compose">/);
+ assert.match(r.text, /<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden><\/div>/);
+ assert.match(r.text, /<textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="Message #general"><\/textarea><button type="submit" class="oc-send" aria-label="Send"/);
+});
+
+t('Activity: what is waiting for you, and nothing waiting said as that', async () => {
+ let r = await page('/activity', bob);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<h1[^>]*>Activity<\/h1>/);
+ assert.match(r.text, /You are all caught up/, 'nothing waiting is said as that');
+ assert.match(r.text, /href="\/messages">Open your messages<\/a>/);
+ assert.ok(!/href="\/notifications"/.test(r.text), 'nothing links to the page that never was');
+ // Dee writes in the server Bob joined: it is waiting for Bob, under the server, by channel.
+ const sent = await fetch(`${h.base}/s/saturday-raiders/general`, { method: 'POST', redirect: 'manual', headers: { cookie: `ov_token=${dee.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: 'message=raid+at+nine' });
+ assert.strictEqual(sent.status, 303);
+ r = await page('/activity', bob);
+ assert.match(r.text, /In your servers <span class="oc-muted">1<\/span>/);
+ assert.match(r.text, /class="oc-act-server-head" href="\/s\/saturday-raiders"/);
+ assert.match(r.text, /<a class="oc-chan is-unread" href="\/s\/saturday-raiders\/general">/);
+ assert.ok(!/You are all caught up/.test(r.text));
+ const out = await page('/activity', null);
+ assert.ok(out.status !== 404 && /Sign in/.test(out.text) && !/In your servers/.test(out.text), 'signed out: the way to sign in, and nothing of anyone');
});
});
t('Home: the DMs and the people you talk to down the side', async () => {
t('Home: the DMs and the people you talk to down the side', async () => {
@@ -102,6 +134,8 @@ t('Explore: the chips, the cards and the Start sheet', async () => {
@@ -102,6 +134,8 @@ t('Explore: the chips, the cards and the Start sheet', async () => {
assert.match(r.text, /class="oc-chips"/);
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`);
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, /oc-server-card/, 'the servers as cards');
+ assert.match(r.text, /<span class="oc-card-yours">You own it<\/span> · 2 members/, 'your rank in a server you are in');
+ assert.match(r.text, /class="oc-icon-btn oc-card-more" href="\/s\/saturday-raiders" aria-label="More about Saturday Raiders"/, 'and the way to its sheet');
assert.match(r.text, /id="oc-new-server"/, 'the Start sheet');
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']]) {
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, new RegExp(`value="${id}"`), `the ${name} template`);