From 8072b2496bfa06d1b4eb2eb24a43ffb76a9e7fd4 Mon Sep 17 00:00:00 2001 From: Salastil Date: Thu, 8 Oct 2026 14:35:55 -0400 Subject: [PATCH] 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 --- scripts/ui-shots.mjs | 44 ++++++++++ src/renderer/src/app.css | 46 +++++++++++ src/renderer/src/components/ServerRail.tsx | 96 +++++++++++++++++++--- src/renderer/src/components/TitleBar.tsx | 4 +- src/renderer/src/lib/railtip.ts | 24 ++++++ 5 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/lib/railtip.ts diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index 2211004..5e95d11 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -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', diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 6593ed9..17fbea9 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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%; diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 9a95d72..ff42bdb 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -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 && ( + + {unread > 99 ? '99+' : unread} + + )} + {badge && !(highlight && unread > 0) && ( {badge.colour && badge.mark ? ( @@ -325,7 +338,8 @@ function DmTile(props: { buffer: BufferEntry; active: boolean; onSelect: () => v {folderMenu && ( @@ -744,6 +781,10 @@ function FolderTile(props: { customIcons: Record 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 ( ) } @@ -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(null) + useEffect(() => { + const on = (e: Event): void => setTip((e as CustomEvent).detail) + window.addEventListener(RAIL_TIP, on) + return () => window.removeEventListener(RAIL_TIP, on) + }, []) + if (!tip?.rect) return null + return createPortal( +
+ {tip.text} +
, + document.body + ) +} diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index 2782752..8f75b96 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -61,11 +61,11 @@ export function TitleBar({ title }: { title?: string } = {}): JSX.Element {
void window.moho.window.minimize()} /> void window.moho.window.toggleMaximize().then(setMaximized)} /> - void window.moho.window.close()} /> + void window.moho.window.close()} />
) diff --git a/src/renderer/src/lib/railtip.ts b/src/renderer/src/lib/railtip.ts new file mode 100644 index 0000000..64f1029 --- /dev/null +++ b/src/renderer/src/lib/railtip.ts @@ -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(RAIL_TIP, { detail: { text, rect: { top: r.top, right: r.right, height: r.height } } })) +} + +export function hideRailTip(): void { + window.dispatchEvent(new CustomEvent(RAIL_TIP, { detail: { text: '', rect: null } })) +}