openvibers/openvibe.chat Commits

The rail names its places, a server has a sheet, one composer, and Activity is a page (#466)

OP OpenVibers committed 4 days ago · 0617e56816 · 11 files, +503 −66

The rail. Every place carries its name. On a phone the strip is chips: the list button with the word it opens
(Channels, Chats), Global, each server with its name in its own colour, and New. The open place is the filled chip,
the list button stays in view while the strip scrolls, and the divider is a hairline (it was drawn as a dot). Home,
Vibe and Explore are the bottom bar's on a phone, so the strip leaves them out wherever scripts run; the bottom bar's
Servers is no longer the globe that means Global chat. On a computer the rail keeps its column: servers wear their
colour, a bar grows beside the tile under the pointer, and the name follows that tile.

A server's sheet. Hold a server (or right-click it), tap the open server's chip again, or the three dots on its
card: what it is, your rank in it, and Open, Invite people, Server settings, Leave or Delete, by what that rank may
do. The page carries one template with only what the person may do in some server of theirs. Explore's cards say
"You own it" or "You are in it" where that is so.

The composer. Global chat, a conversation, a room and a channel had four hand-written forms; they are one
(shell.composer): a box with the text in it, Add on its left where the place takes pictures, Send on its right, lit
while there is something to send. The picture panel opens above the box from Add. The tray for what is being added
is in place and empty (files of any kind are being built on it).

Activity. The bottom bar's fourth place pointed at /notifications, which never existed. /activity is the page:
friend requests, conversations with something new, and the unread channels of each server and room; nothing waiting
is said as that, with the way to messages and servers.

Changes

ddiff --git a/public/web/chat-shell.css b/public/web/chat-shell.css
iindex 0f4ee03..25196c5 100644
---- a/public/web/chat-shell.css
++++ b/public/web/chat-shell.css
@@@ -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))); } }
ddiff --git a/public/web/chat-shell.js b/public/web/chat-shell.js
iindex 869ceea..133ffe0 100644
---- a/public/web/chat-shell.js
++++ b/public/web/chat-shell.js
@@@ -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 || '');
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 18082fe..b9c93ae 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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. */
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 8957ed3..6c7743f 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -283,7 +283,7 @@
if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScript
e.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) {
ddiff --git a/server/web/icons.js b/server/web/icons.js
iindex 15cc85f..6781019 100644
---- a/server/web/icons.js
++++ b/server/web/icons.js
@@@ -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"/>'),
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 06878cd..6d5a116 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -109,7 +109,7 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
await 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,
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 38504be..61a24b4 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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>', 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>`
+ `<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>', html
actor, 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 })),
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex bfbce4c..aa4128b 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -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) => {
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex 8dcce68..19d7956 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -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 };
ddiff --git a/test/servers.test.js b/test/servers.test.js
iindex 1f1cc70..15f80e5 100644
---- a/test/servers.test.js
++++ b/test/servers.test.js
@@@ -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.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/);
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex 2f844f5..a3c8f77 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -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`);