Server rail: mention counts, folders that say what is waiting and stay open, instant name flyout, pill growth, a + tile; red close button (#285, #291)

A server tile with a mention shows how many are waiting in the corner its service mark sat in. A closed folder gets the pill and the summed count. Which folder is open is remembered. The browser's slow tooltip is replaced by an instant flyout in the app's style, the pill grows on hover, and a dashed + tile at the foot of the list opens Join. The close button goes red on hover and maximise/restore have their own glyphs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
2026-10-08 14:35:55 -04:00
co-authored by Claude Sonnet 5.5
parent 6a82c3261c
commit 8072b2496b
5 changed files with 201 additions and 13 deletions
+44
View File
@@ -504,6 +504,50 @@ const SCENES = [
clip: { x: 56, y: 48, width: 224, height: 36 }
}
]),
{
// The rail: mention counts on server tiles and the "+" tile (#285).
name: 'rail-tiles',
setup: async (p) => { await discord(false)(p) },
clip: { x: 0, y: 44, width: 280, height: 360 }
},
{
name: 'rail-tooltip',
setup: async (p) => {
await discord(false)(p)
const tile = await p.evaluate(`(() => { const el = document.querySelector('[aria-label="Moho Dev"]'); const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } })()`)
await mouse(p, 'mouseMoved', tile.x, tile.y)
await sleep(400)
},
clip: { x: 0, y: 44, width: 280, height: 160 }
},
{
// Both servers filed in a folder, closed: it should still say something is waiting.
name: 'rail-folder-closed',
setup: async (p) => {
await discord(false)(p)
await p.evaluate(`window.moho.prefs.set('railFolders', [{ id: 'f1', name: 'Mine', colour: '', members: ['discord:shots|guild:1', 'discord:shots|guild:2'] }])`)
await sleep(300)
await p.call('Page.reload')
await until(p, 'window.__mohoShots && document.querySelector(".buffer-row")', 'the window after a reload')
await p.call('Emulation.setDeviceMetricsOverride', { ...SIZE, deviceScaleFactor: 1, mobile: false })
await sleep(500)
await p.evaluate(DISCORD)
await sleep(900)
},
clip: { x: 0, y: 44, width: 280, height: 360 }
},
{
// The pointer over the close button (#291): red, and the maximise glyph.
name: 'titlebar-close-hover',
setup: async (p) => {
await openChannel(p, '#general')
const c = await centre(p, '.titlebar-controls button', '')
const close = await p.evaluate(`(() => { const b = [...document.querySelectorAll('.titlebar-controls button')].at(-1); const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } })()`)
await mouse(p, 'mouseMoved', close.x, close.y)
await sleep(300)
},
clip: { x: 1020, y: 0, width: 260, height: 44 }
},
// Motion, recorded rather than photographed.
{
name: 'motion-hover-rows',
+46
View File
@@ -4411,6 +4411,47 @@ video.lightbox-media {
opacity: 1;
}
.rail-tile:hover .rail-pill:not(.active):not(.unread) {
height: 20px;
opacity: 1;
}
.rail-tile:hover .rail-pill.unread {
height: 20px;
}
/* The name flyout: instant, in the app's own style, to the right of the tile. */
.rail-add .rail-face {
background: transparent;
border: 1px dashed var(--outline-strong);
color: var(--success);
}
.rail-add:hover .rail-face {
background: var(--success);
border-color: var(--success);
color: var(--on-accent);
}
.rail-tip {
position: fixed;
z-index: var(--z-menu);
transform: translateY(-50%);
max-width: 260px;
padding: 6px var(--spacing-m);
border-radius: var(--corner-radius-small);
background: var(--surface-container-lowest);
border: 1px solid var(--outline-medium);
box-shadow: var(--shadow-1);
color: var(--surface-text);
font-size: var(--font-size-medium);
font-weight: var(--weight-semibold);
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-pill.unread {
height: 8px;
opacity: 1;
@@ -8844,6 +8885,11 @@ video.lightbox-media {
}
/* The title bar's way in, in the middle of what is otherwise an empty bar. */
.icon-button.titlebar-close:hover {
background: var(--danger);
color: var(--on-accent);
}
.titlebar-search {
position: absolute;
left: 50%;
+85 -11
View File
@@ -1,5 +1,6 @@
import { useMediaUrl } from '../lib/route'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ContextMenu, useContextMenu } from './ContextMenu'
import { railConnecting } from '../lib/bufferlink'
import { Icon, IconButton, MaskIcon } from './Icon'
@@ -7,6 +8,7 @@ import { Avatar } from './Avatar'
import { LeaveConfirm } from './LeaveConfirm'
import { ircNetworkFor } from '../lib/networks'
import { useChat, usePref, useStore } from '../state/hooks'
import { RAIL_TIP, hideRailTip, showRailTip, type RailTipDetail } from '../lib/railtip'
import { bufferDisplayName, classes, nickColor, resolveMediaUrl, serviceIcon } from '../lib/util'
import { SpaceRooms } from './SpaceRooms'
import {
@@ -206,7 +208,10 @@ function RailTile(props: TileProps): JSX.Element {
muted && 'muted',
connecting && 'connecting'
)}
title={connecting ? `${group.name} — connecting…` : group.name}
onMouseEnter={(e) => showRailTip(e.currentTarget, connecting ? `${group.name} — connecting…` : group.name)}
onMouseLeave={hideRailTip}
onFocus={(e) => showRailTip(e.currentTarget, group.name)}
onBlur={hideRailTip}
aria-label={group.name}
aria-current={active}
draggable={draggable}
@@ -293,7 +298,15 @@ function RailTile(props: TileProps): JSX.Element {
onClose={close}
/>
)}
{badge && (
{/* A mention waiting in here says how many are waiting, in the corner
the service mark sits in - the mark gives way, since a count is what
somebody scanning this column came to see. */}
{highlight && unread > 0 && (
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
{unread > 99 ? '99+' : unread}
</span>
)}
{badge && !(highlight && unread > 0) && (
<span className={`rail-service${highlight ? ' highlight' : ''}`}>
{badge.colour && badge.mark ? (
<img src={badge.mark} alt="" draggable={false} />
@@ -325,7 +338,8 @@ function DmTile(props: { buffer: BufferEntry; active: boolean; onSelect: () => v
<button
type="button"
className={classes('rail-tile', 'rail-dm', active && 'active')}
title={`${name} - ${count} unread message${count === 1 ? '' : 's'}`}
onMouseEnter={(e) => showRailTip(e.currentTarget, `${name} - ${count} unread message${count === 1 ? '' : 's'}`)}
onMouseLeave={hideRailTip}
aria-label={`${name}, ${count} unread`}
onClick={onSelect}
>
@@ -360,10 +374,10 @@ export function ServerRail(): JSX.Element | null {
const [customIcons] = usePref<Record<string, string>>('groupIcons', {})
const [mutedGroups, setMutedGroups] = usePref<string[]>('mutedGroups', [])
const [folders, setFolders] = usePref<RailFolder[]>('railFolders', [])
// Which folder is showing its contents, if any. Deliberately not
// remembered: a folder is a place to put things, and it looks the same
// either way - there is no state here worth carrying across a restart.
const [expanded, setExpanded] = useState('')
// Which folder is showing its contents, if any. Remembered: a column that
// forgets which folders were open and shuts them all on every start is one
// you re-arrange each morning.
const [expanded, setExpanded] = usePref<string>('ui.railFolderOpen', '')
const isFolderOpen = (id: string): boolean => expanded === id
const toggleFolder = (id: string): void => setExpanded(expanded === id ? '' : id)
const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; folder: RailFolder } | null>(null)
@@ -581,6 +595,9 @@ export function ServerRail(): JSX.Element | null {
members={entry.members}
customIcons={customIcons}
dropTarget={over === entry.id && dragging !== ''}
open={open}
unread={entry.members.reduce((n, m) => n + (totals.get(m.id)?.unread ?? 0), 0)}
highlight={entry.members.some((m) => totals.get(m.id)?.highlight)}
onToggle={() => toggleFolder(entry.id)}
lifted={dragging === `${FOLDER_DRAG_PREFIX}${entry.id}`}
onDragStart={() => beginDrag(`${FOLDER_DRAG_PREFIX}${entry.id}`)}
@@ -601,6 +618,7 @@ export function ServerRail(): JSX.Element | null {
return (
<nav className="server-rail" aria-label="Servers">
<RailTip />
{/* Only the entries scroll; the cog stays pinned to the foot. The empty
space below them is where something is dropped to put it last, and to
take it back out of a folder on the way - folders themselves are made
@@ -661,6 +679,25 @@ export function ServerRail(): JSX.Element | null {
)}
{rest.map(renderEntry)}
{/* Where a server is added or joined, at the foot of the list as in
Discord, for whichever account is being looked at. */}
<button
type="button"
className="rail-tile rail-add"
aria-label="Add or join a server"
onMouseEnter={(e) => showRailTip(e.currentTarget, 'Add or join a server')}
onMouseLeave={hideRailTip}
onClick={() => {
const account =
groups.find((g) => g.id === activeGroupId)?.accountId || (store.getSnapshot().accounts[0]?.id ?? '')
store.setActivePanel('join', account)
}}
>
<span className="rail-face">
<Icon name="add" size={22} />
</span>
</button>
</div>
{folderMenu && (
@@ -744,6 +781,10 @@ function FolderTile(props: {
customIcons: Record<string, string>
dropTarget: boolean
lifted: boolean
open: boolean
/** What is waiting in the servers inside, so a closed folder can say so. */
unread: number
highlight: boolean
onToggle: () => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: () => void
@@ -751,7 +792,10 @@ function FolderTile(props: {
onDrop: () => void
onDragEnd: () => void
}): JSX.Element {
const { folder, members, customIcons, dropTarget, lifted } = props
const { folder, members, customIcons, dropTarget, lifted, open, unread, highlight } = props
// Open, its servers show their own; closed, what is inside would be
// invisible without this.
const waiting = !open && unread > 0
return (
<button
type="button"
@@ -766,7 +810,10 @@ function FolderTile(props: {
e.dataTransfer.effectAllowed = 'move'
props.onDragStart()
}}
title={`${folder.name} — ${members.length} ${members.length === 1 ? 'server' : 'servers'}`}
onMouseEnter={(e) =>
showRailTip(e.currentTarget, `${folder.name} — ${members.length} ${members.length === 1 ? 'server' : 'servers'}`)
}
onMouseLeave={hideRailTip}
aria-label={folder.name}
onClick={props.onToggle}
onContextMenu={props.onContextMenu}
@@ -780,6 +827,7 @@ function FolderTile(props: {
}}
onDragEnd={props.onDragEnd}
>
<span className={`rail-pill${open ? ' active' : waiting ? ' unread' : ''}`} />
{/* The same face whether or not it is showing its contents: a folder
is a folder, and an icon that changes underfoot is one more thing to
read. Empty it is just a folder; once there is something in it, the
@@ -811,11 +859,16 @@ function FolderTile(props: {
{/* Once the face is a grid of other people's icons, nothing says it is
a folder any more - so it takes the same corner badge a guild uses
to name its service. An empty folder is already unmistakably one. */}
{members.length > 0 && (
{members.length > 0 && !(waiting && highlight) && (
<span className="rail-service">
<Icon name="folder" size={11} />
</span>
)}
{waiting && highlight && (
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
)
}
@@ -962,3 +1015,24 @@ function RailMenu(): JSX.Element {
</>
)
}
/** The flyout every tile in the column points at; one, wherever the pointer is. */
function RailTip(): JSX.Element | null {
const [tip, setTip] = useState<RailTipDetail | null>(null)
useEffect(() => {
const on = (e: Event): void => setTip((e as CustomEvent<RailTipDetail>).detail)
window.addEventListener(RAIL_TIP, on)
return () => window.removeEventListener(RAIL_TIP, on)
}, [])
if (!tip?.rect) return null
return createPortal(
<div
className="rail-tip"
role="tooltip"
style={{ left: tip.rect.right + 8, top: tip.rect.top + tip.rect.height / 2 }}
>
{tip.text}
</div>,
document.body
)
}
+2 -2
View File
@@ -61,11 +61,11 @@ export function TitleBar({ title }: { title?: string } = {}): JSX.Element {
<div className="titlebar-controls">
<IconButton name="remove" title="Minimize" onClick={() => void window.moho.window.minimize()} />
<IconButton
name={maximized ? 'fullscreen_exit' : 'fullscreen'}
name={maximized ? 'filter_none' : 'crop_square'}
title={maximized ? 'Restore' : 'Maximize'}
onClick={() => void window.moho.window.toggleMaximize().then(setMaximized)}
/>
<IconButton name="close" title="Close" onClick={() => void window.moho.window.close()} />
<IconButton name="close" title="Close" className="titlebar-close" onClick={() => void window.moho.window.close()} />
</div>
</div>
)
+24
View File
@@ -0,0 +1,24 @@
/**
* The rail's name flyout, instead of the browser's own tooltip.
*
* A native `title` waits most of a second before it appears and cannot be
* styled; the rail is where somebody moves the pointer down a column of
* unlabelled squares asking which is which, so the answer has to be there as
* the pointer arrives. Tiles say what to show; one flyout draws it.
*/
export const RAIL_TIP = 'moho:rail-tip'
export interface RailTipDetail {
text: string
/** Where the tile is, in window coordinates, or null to hide the flyout. */
rect: { top: number; right: number; height: number } | null
}
export function showRailTip(el: HTMLElement, text: string): void {
const r = el.getBoundingClientRect()
window.dispatchEvent(new CustomEvent<RailTipDetail>(RAIL_TIP, { detail: { text, rect: { top: r.top, right: r.right, height: r.height } } }))
}
export function hideRailTip(): void {
window.dispatchEvent(new CustomEvent<RailTipDetail>(RAIL_TIP, { detail: { text: '', rect: null } }))
}