openvibers/openvibe.chat Commits

Chat on a phone, in two levels: the list, then the conversation (#478)

OP OpenVibers committed 4 days ago · e7cc979a39 · 5 files, +151 −65

The owner, with Chat beside Discord: "it's a little overwhelming having both the bottom and the side and top tabs …
think of a way better system". A conversation on a phone had three bars round it (the strip of places across the top,
the head, the bottom bar) and its messages were capped at 62% of the screen.

- The list level: the rail of places down the left (Home, Global chat, Vibe, your servers, New, Explore), the list
  for the place beside it, the app's bar at the bottom.
- A conversation: the whole screen under the site's bar. Its head opens with the way back to the list, then the name
  over one line about it, then its tools; the messages take all the room down to the composer at the bottom edge. No
  strip, no bottom bar.
- The open place pressed again in the list goes back into its conversation without a reload. Home opens on the list.
- Messages are grouped by who said them: a run from one person within five minutes shows their mark, name and time
  once; a line says where the day changes. New and older messages are grouped as they arrive.
- The rail's list button and the strip's fade are gone (shell.js, chat-shell.js, chat-shell.css).
- test/shell.test.js: the markup and the phone rules.

Changes

ddiff --git a/public/web/chat-shell.css b/public/web/chat-shell.css
iindex 25196c5..08fa578 100644
---- a/public/web/chat-shell.css
++++ b/public/web/chat-shell.css
@@@ -14,7 +14,7 @@
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, .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 { 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; }
@@@ -42,7 +42,6 @@
.oc-rail-tip.is-on { opacity: 1; transform: translate(0, -50%); }
/* 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); }
@@@ -129,43 +128,48 @@
@media (max-width: 760px) {
/* The shell takes what the navbar and the app bar leave, so a channel fills the screen and its composer sits at the
bottom, above the app bar (the body keeps --ovnav-appbar-h of padding for it). */
-- .oc-app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
-- height: calc(100dvh - var(--ovnav-h, 56px) - var(--ovnav-appbar-h, 0px)); min-height: 360px; }
++ .oc-app { 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); }
-- /* 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); }
++ /* Two levels (11-10; the strip of places across the top is gone). The list level: the rail of places down the left,
++ upright, and beside it the list for the place chosen, with the app's bar at the bottom. A conversation: the whole
++ screen under the site's bar, the way back first in its head, the composer at the bottom edge; no rail, no bottom
++ bar. A page that is not a conversation (Explore, Activity, settings) keeps the rail beside it. */
++ .oc-app { grid-template-columns: 68px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
++ .oc-app:not(:has(> .oc-rail)) { grid-template-columns: minmax(0, 1fr); }
++ .oc-app > .oc-rail { flex-direction: column; align-items: center; justify-content: flex-start; gap: 10px; padding: 12px 0 16px; overflow: hidden auto; scrollbar-width: none; border: 0; border-right: 1px solid var(--oc-line); min-width: 0; background: color-mix(in srgb, var(--oc-bg) 70%, #000); }
++ .oc-app > .oc-rail::-webkit-scrollbar { display: none; }
++ .oc-rail-icon { position: relative; display: grid; place-items: center; flex: none; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 0; background: none; }
.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-tile, .oc-rail-icon:hover .oc-rail-tile, .oc-rail-icon:focus-visible .oc-rail-tile { width: 48px; height: 48px; border-radius: 50%; font-size: .9rem; transition: border-radius .2s ease, background-color .2s ease, transform .12s ease; }
++ .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: 2px solid var(--oc-accent); outline-offset: 2px; border-radius: 16px; }
++ .oc-rail-tile .oc-i { width: 22px; height: 22px; }
++ .oc-rail-label, .oc-rail-more { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
++ .oc-rail-icon.is-active::after { content: ""; position: absolute; left: -10px; top: 50%; width: 4px; height: 30px; margin-top: -15px; border-radius: 0 4px 4px 0; background: var(--oc-text); }
++ .oc-rail-dot { position: absolute; right: -2px; bottom: -2px; margin: 0; box-shadow: 0 0 0 3px var(--oc-bg); }
++ .oc-rail-divider { width: 28px; height: 2px; flex: 0 0 2px; border-radius: 2px; }
.oc-rail-tip { display: none; }
++ /* A conversation (the middle column has a composer) takes the whole width and the bottom bar's place. */
++ .oc-app:has(> .oc-col-main > :is(.oc-dock, .oc-compose)):not([data-oc-panel="side"]) { grid-template-columns: minmax(0, 1fr); }
++ .oc-app:has(> .oc-col-main > :is(.oc-dock, .oc-compose)):not([data-oc-panel="side"]) > .oc-rail { display: none; }
++ html:has(.oc-app:not([data-oc-panel="side"]) > .oc-col-main > :is(.oc-dock, .oc-compose)) { --ovnav-appbar-h: 0px !important; }
++ html:has(.oc-app:not([data-oc-panel="side"]) > .oc-col-main > :is(.oc-dock, .oc-compose)) .ovnav-appbar { display: none !important; }
++ /* The messages take all the room between the head and the composer (chat.css capped them at 62% of the screen, for
++ the page that had three bars round it). */
++ .oc-app > .oc-col-main > .oc-feed { flex: 1 1 0; min-height: 0; max-height: none; }
++ .oc-app > .oc-col-main > .oc-dock { padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); }
++ /* Its head: the way back, the name over one line about it, the tools. One row. */
++ .oc-col-main > .oc-chan-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: auto auto; align-items: center; gap: 0 4px; min-height: 52px; padding: 4px 6px 4px 2px; }
++ .oc-chan-head > .oc-tolist { grid-column: 1; grid-row: 1 / 3; }
++ .oc-chan-head > .oc-chan-title { grid-column: 2; grid-row: 1; align-self: end; margin: 0; font-size: 1.04rem; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++ .oc-chan-head > .oc-chan-topic { grid-column: 2; grid-row: 2; align-self: start; font-size: .76rem; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++ .oc-chan-head > .oc-chan-title:not(:has(~ .oc-chan-topic)) { grid-row: 1 / 3; align-self: center; }
++ .oc-chan-head > .oc-chan-tools { grid-column: 3; grid-row: 1 / 3; display: inline-flex; align-items: center; gap: 0; margin: 0; }
++ .oc-chan-head .oc-live { font-size: 0; margin-right: 4px; }
++ .oc-chan-head .oc-live::first-letter { font-size: .7rem; }
++ .oc-app .oc-tolist { display: inline-grid; }
.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-members { display: none; }
.oc-app[data-oc-panel="side"] > .oc-side { display: flex; border-right: 0; }
@@@ -181,9 +185,6 @@
/* 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%; }
@@@ -371,3 +372,27 @@ fieldset.oc-role-set { min-width: 0; padding: 0 0 14px; }
.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))); } }
++
++/* The way to the list is a phone's; on a computer the list is beside the conversation. */
++.oc-tolist { display: none; }
++
++/* ── Messages, grouped by who said them ────────────────────────────────────────────────────────────────────────────
++ A run of messages from one person within a few minutes is one group: their mark, their name, where it was sent from
++ and the time once; the next lines are just the words (their time shows on a press). A line says when the day changes. */
++.oc-feed.is-grouped { padding: 4px 0 10px; }
++.oc-feed.is-grouped > .oc-msg { position: relative; display: block; margin: 0; padding: 1px 14px 1px 62px; line-height: 1.42; border: 0; }
++.oc-feed.is-grouped > .oc-msg.is-first { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; margin-top: 14px; }
++.oc-feed.is-grouped > .oc-msg.is-first::before { content: attr(data-initial); position: absolute; left: 14px; top: 1px; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; font-size: .92rem; font-weight: 800; color: var(--oc-text); background: color-mix(in srgb, var(--nc, var(--oc-accent)) 34%, var(--oc-panel-2)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--nc, var(--oc-accent)) 50%, transparent); }
++.oc-feed.is-grouped > .oc-msg.is-first > .oc-name { order: 1; font-weight: 750; }
++.oc-feed.is-grouped > .oc-msg.is-first > .oc-where { order: 2; font-size: .74rem; padding: 1px 7px; border-radius: 999px; color: var(--oc-muted); background: color-mix(in srgb, var(--oc-text) 7%, transparent); text-decoration: none; }
++.oc-feed.is-grouped > .oc-msg.is-first > .oc-time { order: 3; font-size: .72rem; color: var(--oc-muted); }
++.oc-feed.is-grouped > .oc-msg.is-first > .oc-text { order: 4; flex: 1 0 100%; }
++.oc-feed.is-grouped > .oc-msg.is-cont > :is(.oc-name, .oc-where) { display: none; }
++.oc-feed.is-grouped > .oc-msg.is-cont > .oc-time { position: absolute; left: 8px; top: 4px; width: 46px; text-align: right; font-size: .66rem; color: var(--oc-muted); opacity: 0; transition: opacity .12s ease; }
++.oc-feed.is-grouped > .oc-msg.is-cont:is(:hover, :active, :focus-within) > .oc-time { opacity: 1; }
++.oc-feed.is-grouped > .oc-msg:is(:hover, :active) { background: color-mix(in srgb, var(--oc-text) 3.5%, transparent); }
++.oc-feed.is-grouped > .oc-msg .oc-text { overflow-wrap: anywhere; }
++.oc-feed.is-grouped > .oc-day { display: flex; align-items: center; gap: 12px; margin: 18px 14px 4px; font-size: .74rem; font-weight: 700; color: var(--oc-muted); list-style: none; }
++.oc-feed.is-grouped > .oc-day::before, .oc-feed.is-grouped > .oc-day::after { content: ""; flex: 1; height: 1px; background: var(--oc-line); }
++.oc-feed.is-grouped > .oc-msg.is-first > .oc-time { display: inline; margin: 0; }
++.oc-feed.is-grouped > .oc-msg.is-cont > .oc-time { display: block; margin: 0; }
ddiff --git a/public/web/chat-shell.js b/public/web/chat-shell.js
iindex 133ffe0..ead5c6b 100644
---- a/public/web/chat-shell.js
++++ b/public/web/chat-shell.js
@@@ -36,8 +36,6 @@
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');
});
@@@ -112,26 +110,69 @@
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', hideTip, { passive: true });
++ }
++
++ // ── A phone's two levels: the list (the rail and the list beside it), then the conversation ──
++ var talk = app.querySelector(':scope > .oc-col-main > .oc-dock, :scope > .oc-col-main > .oc-compose');
++ var sideCol = app.querySelector(':scope > .oc-side');
++ var phone = function () { return window.matchMedia && window.matchMedia('(max-width: 760px)').matches; };
++ // The place that is open, pressed again from the list: back into its conversation, with no reload.
++ document.addEventListener('click', function (e) {
++ var here = e.target.closest && e.target.closest('.oc-rail-icon.is-active');
++ if (!here || !phone() || !talk || app.getAttribute('data-oc-panel') !== 'side' || e.metaKey || e.ctrlKey) return;
++ e.preventDefault();
++ panel('main');
++ });
++ // A page with a list beside it and no conversation of its own (your conversations) opens on the list.
++ if (phone() && sideCol && !talk && app.querySelector('.oc-rail-home.is-active') && !/[?&]panel=main\b/.test(location.search)) panel('side');
++ // A page head with no way back of its own gets the way to the list.
++ var pageHead = app.querySelector(':scope > .oc-col-main > .oc-page-head, :scope > .oc-col-main > .oc-page > .oc-page-head');
++ if (pageHead && sideCol && !pageHead.querySelector('.oc-back, .oc-tolist')) {
++ var lb = document.createElement('button');
++ lb.type = 'button'; lb.className = 'oc-icon-btn oc-tolist'; lb.setAttribute('data-oc-panel', 'side'); lb.setAttribute('aria-label', 'All chats'); lb.title = 'All chats';
++ lb.innerHTML = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14.5 5.500 8 12l6.500 6.500" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
++ pageHead.insertBefore(lb, pageHead.firstChild);
++ }
++
++ // ── Messages grouped by who said them: a run from one person within five minutes shows their mark, name and the
++ // time once; a line says where the day changes. New and older messages are grouped as they arrive. ──
++ var feed = app.querySelector('.oc-feed');
++ if (feed && feed.querySelector(':scope > .oc-msg > .oc-name')) {
++ var dayOf = function (d) { return d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate(); };
++ var dayName = function (d) {
++ var now = new Date(), y = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
++ if (dayOf(d) === dayOf(now)) return 'Today';
++ if (dayOf(d) === dayOf(y)) return 'Yesterday';
++ return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
};
-- 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();
++ var grouping = false;
++ var group = function () {
++ if (grouping) return;
++ grouping = true;
++ feed.classList.add('is-grouped');
++ Array.prototype.forEach.call(feed.querySelectorAll(':scope > .oc-day'), function (n) { n.remove(); });
++ var prev = null;
++ Array.prototype.forEach.call(feed.querySelectorAll(':scope > .oc-msg'), function (li) {
++ var name = li.querySelector(':scope > .oc-name'), where = li.querySelector(':scope > .oc-where'), time = li.querySelector(':scope > .oc-time');
++ var who = (name ? name.textContent.trim() : '') + '|' + (where ? where.textContent.trim() : '');
++ var at = time && time.dateTime ? new Date(time.dateTime) : null;
++ var sameDay = !!(prev && at && prev.at && dayOf(prev.at) === dayOf(at));
++ if (at && !sameDay) { var d = document.createElement('li'); d.className = 'oc-day'; d.setAttribute('role', 'separator'); d.textContent = dayName(at); feed.insertBefore(d, li); }
++ var cont = !!(prev && sameDay && prev.who === who && name && at - prev.at < 5 * 60000);
++ li.classList.toggle('is-cont', cont); li.classList.toggle('is-first', !cont);
++ if (!cont && name) { li.setAttribute('data-initial', (name.textContent.trim().charAt(0) || '?').toUpperCase()); var nc = name.style.getPropertyValue('--nc'); if (nc) li.style.setProperty('--nc', nc); }
++ if (time && at) time.title = at.toLocaleString();
++ prev = { who: who, at: at };
++ });
++ grouping = false;
++ };
++ group();
++ if (window.MutationObserver) new MutationObserver(function (list) {
++ if (grouping) return;
++ var touched = list.some(function (m) { return Array.prototype.some.call(m.addedNodes, function (n) { return n.nodeType === 1 && n.classList.contains('oc-msg'); }) || Array.prototype.some.call(m.removedNodes, function (n) { return n.nodeType === 1 && n.classList.contains('oc-msg'); }); });
++ if (touched) group();
++ }).observe(feed, { childList: true });
}
// ── 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
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex d4e0c30..1461ef8 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -318,7 +318,7 @@ function createWebRoutes({ config }) {
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>
++ main: `<header class="oc-chan-head">${shell.toList}<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>
${error}
${feedList}
${composer}`,
ddiff --git a/server/web/shell.js b/server/web/shell.js
iindex 945ef9a..5e0344f 100644
---- a/server/web/shell.js
++++ b/server/web/shell.js
@@@ -77,12 +77,10 @@ ${owns ? `<details class="oc-ssheet-danger" data-s="delete"><summary class="oc-p
*/
function rail(o = {}) {
const servers = o.servers || [];
-- 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-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)}
@@@ -257,10 +255,16 @@ ${groups.map((g) => `<section class="oc-members-sec"><h3 class="oc-side-group">$
* The channel header: the channel's name and topic, and the buttons that work (the people, the server's menu).
* o: { name, kind, topic, href, members, active }
*/
++/**
++ * The way from a conversation to the list (a phone's two levels: the list, then the conversation). It is the first
++ * thing in a conversation's head; on a computer the list is beside the conversation and this is not drawn.
++ */
++const toList = `<button type="button" class="oc-icon-btn oc-tolist" data-oc-panel="side" aria-label="All chats" title="All chats">${IC.back}</button>`;
++
function channelHeader(o = {}) {
const kind = o.kind === 'voice' ? IC.voice : '<span class="oc-chan-hash" aria-hidden="true">#</span>';
return `<header class="oc-chan-head">
--<h2 id="oc-chan-h" class="oc-chan-title">${kind}${esc(o.name || '')}</h2>
++${toList}<h2 id="oc-chan-h" class="oc-chan-title">${kind}${esc(o.name || '')}</h2>
${o.topic ? `<span class="oc-muted oc-chan-topic">${esc(o.topic)}</span>` : ''}
<span class="oc-chan-tools">
<button type="button" class="oc-icon-btn oc-members-toggle" data-oc-panel="members" aria-label="Show the people" title="People" aria-pressed="false">${IC.members}</button>
@@@ -282,4 +286,4 @@ ${o.members ? `<aside class="oc-col oc-col-members" aria-label="${esc(o.membersL
</div>`;
}
--module.exports = { APP_BAR, rail, railServers, serverData, composer, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };
++module.exports = { APP_BAR, toList, rail, railServers, serverData, composer, sideHome, sideServer, sideRooms, sideSettings, sideInbox, membersColumn, channelHeader, youBar, shell, personRow };
ddiff --git a/test/shell.test.js b/test/shell.test.js
iindex a3c8f77..ed41aca 100644
---- a/test/shell.test.js
++++ b/test/shell.test.js
@@@ -64,7 +64,23 @@ t('the shell: the rail, the side column, the members column and the bottom bar',
// 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');
++ // A phone's two levels (11-10): the rail has no list button; the way to the list is first in a conversation's head.
++ assert.ok(!/oc-rail-menu/.test(r.text), 'no list button on the rail');
++ assert.match(r.text, /<header class="oc-chan-head">\s*<button type="button" class="oc-icon-btn oc-tolist" data-oc-panel="side" aria-label="All chats"/, 'a conversation opens with the way back to the list');
++ {
++ const read = (f) => require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', f), 'utf8');
++ const css = read('chat-shell.css'); const js = read('chat-shell.js');
++ const phone = css.slice(css.indexOf('@media (max-width: 760px)'));
++ assert.ok(!/data-fade|oc-rail-menu/.test(css + js), 'the strip of places across the top, and its list button, are gone');
++ assert.match(phone, /\.oc-app \{ grid-template-columns: 68px minmax\(0, 1fr\);/, 'the list level: the rail down the left, the list beside it');
++ assert.match(phone, /\.oc-app:has\(> \.oc-col-main > :is\(\.oc-dock, \.oc-compose\)\):not\(\[data-oc-panel="side"\]\) > \.oc-rail \{ display: none; \}/, 'a conversation has no rail');
++ assert.ok(phone.includes('html:has(.oc-app:not([data-oc-panel="side"]) > .oc-col-main > :is(.oc-dock, .oc-compose)) .ovnav-appbar { display: none !important; }'), 'and no bottom bar');
++ assert.match(phone, /\.oc-app > \.oc-col-main > \.oc-feed \{ flex: 1 1 0; min-height: 0; max-height: none; \}/, 'its messages take the room down to the composer');
++ assert.match(phone, /\.oc-app \.oc-tolist \{ display: inline-grid; \}/, 'the way to the list shows on a phone');
++ assert.ok(css.indexOf('\n.oc-tolist { display: none; }') > 0, 'and not on a computer, where the list is beside the conversation');
++ assert.match(js, /closest\('\.oc-rail-icon\.is-active'\)[\s\S]{0,400}panel\('main'\)/, 'the open place pressed again goes back into its conversation');
++ assert.match(js, /classList\.toggle\('is-cont', cont\)/, 'messages are grouped by who said them');
++ }
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);