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 } })) +}