Read receipts you can read, and click
Two complaints about the same corner of the screen. The hover toolbar sat hard against the right edge, which is where Discord puts it because Discord has nothing else there. This does: Matrix draws who has read each line in that same gutter, so the toolbar covered them - a standing fact made unreadable and, now, a control made unclickable. It used to fade the markers to a quarter opacity on hover and call that settled, which is a way of saying the toolbar wins rather than a way of both fitting. They both fit now. The toolbar's offset is the marker cluster's widest form - three overlapped faces and a "+N" - so it clears them exactly and no further, and only on rows that have any: an IRC channel has no read receipts and should not have its toolbar shoved inwards for something that is not there. And the faces answer "who" now. Three overlapped avatars answer "has anybody" and cannot answer the question somebody clicking them has. The names were in a tooltip, which cannot be read at leisure, cannot be scrolled, and vanishes if you move towards it - so clicking opens a list instead, anchored to the faces rather than to the pointer, and clamped inside the window since it opens from the right-hand gutter and grows leftwards. Capped at twelve names. Past that the answer stops being a list of people and becomes "the room has caught up", which "and N others" says better than another twenty rows. Closes #146, #147. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -2812,10 +2812,17 @@ img.smilie {
|
||||
/* Anchored to the row's top-right, overlapping it slightly so it reads as
|
||||
floating above the message rather than reserving layout space (which would
|
||||
make every row jump as the pointer moved down the list). */
|
||||
/* Clear of the read markers, which share this corner.
|
||||
Discord's toolbar sits hard against the right edge because Discord has
|
||||
nothing else there. This does: Matrix draws who has read each line in the
|
||||
same gutter, and a toolbar over the top of them made a standing fact
|
||||
unreadable and a control unclickable. The offset is the width of the widest
|
||||
marker cluster - three overlapped faces and a "+N" - so the two never
|
||||
overlap rather than taking turns. */
|
||||
.hover-toolbar {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: var(--spacing-m);
|
||||
right: calc(var(--spacing-m) + var(--read-gutter, 0px));
|
||||
display: none;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
@@ -2830,6 +2837,13 @@ img.smilie {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Three overlapped 14px faces (14 + 9 + 9) plus a "+N" and its padding. The
|
||||
number is the marker cluster's widest form rather than a guess, so the
|
||||
toolbar clears it exactly and no further. */
|
||||
.message-row.has-readers {
|
||||
--read-gutter: 62px;
|
||||
}
|
||||
|
||||
.message-row:hover .hover-toolbar {
|
||||
display: flex;
|
||||
}
|
||||
@@ -5373,10 +5387,54 @@ video.lightbox-media {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The hover toolbar sits in the same corner. It wins - it is the thing being
|
||||
reached for - and the markers are a standing fact that can wait. */
|
||||
.message-row:hover .read-markers {
|
||||
opacity: 0.25;
|
||||
/* The faces are a control now, not a label - they open the list of who. The
|
||||
button is stripped back to the row it was, since the thing being clicked is
|
||||
the faces themselves. */
|
||||
.read-markers {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.read-markers:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--corner-radius-small);
|
||||
}
|
||||
|
||||
/* Who has read this far, by name. Anchored to the faces and clamped to the
|
||||
window - it opens from the right-hand gutter, so it grows leftwards. */
|
||||
.reader-list {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: var(--spacing-xs);
|
||||
border: 1px solid var(--outline-medium);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-high);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.reader-list-head {
|
||||
padding: 0 var(--spacing-xxs) var(--spacing-xxs);
|
||||
border-bottom: 1px solid var(--outline-light);
|
||||
margin-bottom: var(--spacing-xxs);
|
||||
}
|
||||
|
||||
.reader-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 3px var(--spacing-xxs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- threads ------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
@@ -164,6 +165,14 @@ export function useRowContext(bufferId: string): RowContext {
|
||||
/** How many faces fit before the row starts costing more than it says. */
|
||||
const MAX_READER_FACES = 3
|
||||
|
||||
/**
|
||||
* How many names the list shows before it stops naming people.
|
||||
*
|
||||
* Past this the answer is "the room has caught up", which "and N others" says
|
||||
* better than another twenty rows do.
|
||||
*/
|
||||
const MAX_READER_NAMES = 12
|
||||
|
||||
/**
|
||||
* The faces of everybody who has read this far.
|
||||
*
|
||||
@@ -176,17 +185,108 @@ const MAX_READER_FACES = 3
|
||||
function ReadMarkers({ readers }: { readers: MessageReader[] }): JSX.Element {
|
||||
const names = readers.map((r) => r.nick).join(', ')
|
||||
const shown = readers.slice(0, MAX_READER_FACES)
|
||||
const [at, setAt] = useState<{ x: number; y: number } | null>(null)
|
||||
return (
|
||||
<span className="read-markers" title={`Read by ${names}`} aria-label={`Read by ${names}`}>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="read-markers"
|
||||
title={`Read by ${names}`}
|
||||
aria-label={`Read by ${names}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const box = e.currentTarget.getBoundingClientRect()
|
||||
// Anchored to the faces rather than to the pointer: the list is
|
||||
// about them, and a panel that lands wherever the cursor happened
|
||||
// to be reads as unrelated to what was clicked.
|
||||
setAt({ x: box.right, y: box.top })
|
||||
}}
|
||||
>
|
||||
{shown.map((reader) => (
|
||||
<span key={reader.userId} className="read-marker">
|
||||
<Avatar name={reader.nick} url={reader.avatarUrl} size={14} />
|
||||
</span>
|
||||
))}
|
||||
{readers.length > shown.length && (
|
||||
<span className="read-marker-more small muted">+{readers.length - shown.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{at && <ReaderList readers={readers} at={at} onClose={() => setAt(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who has read this far, by name.
|
||||
*
|
||||
* Three overlapped faces answer "has anybody" and cannot answer "who" - which
|
||||
* is the question somebody clicking them has. A tooltip could carry the names
|
||||
* and did, but a tooltip cannot be read at leisure, cannot be scrolled, and
|
||||
* vanishes if you move towards it.
|
||||
*
|
||||
* Capped, because past a point the answer stops being a list of people and
|
||||
* becomes "the room": in a busy channel everybody catches up, and thirty names
|
||||
* is a wall that says less than "and 27 others" does.
|
||||
*/
|
||||
function ReaderList({
|
||||
readers,
|
||||
at,
|
||||
onClose
|
||||
}: {
|
||||
readers: MessageReader[]
|
||||
at: { x: number; y: number }
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState(at)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = box.current
|
||||
if (!el) return
|
||||
const size = el.getBoundingClientRect()
|
||||
// Nudged back inside the window, the same way the context menu is: this
|
||||
// opens from the right-hand gutter, so left of the anchor is where it
|
||||
// fits, and a message near the bottom would otherwise open off-screen.
|
||||
setPos({
|
||||
x: Math.max(8, Math.min(at.x - size.width, window.innerWidth - size.width - 8)),
|
||||
y: Math.max(8, Math.min(at.y, window.innerHeight - size.height - 8))
|
||||
})
|
||||
}, [at])
|
||||
|
||||
useEffect(() => {
|
||||
const away = (e: MouseEvent): void => {
|
||||
if (!box.current?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const key = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
// Capturing, so a click anywhere closes this before that click does
|
||||
// anything else - including on another message's faces.
|
||||
window.addEventListener('mousedown', away, true)
|
||||
window.addEventListener('keydown', key)
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', away, true)
|
||||
window.removeEventListener('keydown', key)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const shown = readers.slice(0, MAX_READER_NAMES)
|
||||
return createPortal(
|
||||
<div ref={box} className="reader-list" style={{ left: pos.x, top: pos.y }} role="dialog">
|
||||
<div className="reader-list-head small muted">
|
||||
Read by {readers.length === 1 ? '1 person' : `${readers.length} people`}
|
||||
</div>
|
||||
{shown.map((reader) => (
|
||||
<span key={reader.userId} className="read-marker">
|
||||
<Avatar name={reader.nick} url={reader.avatarUrl} size={14} />
|
||||
</span>
|
||||
<div key={reader.userId} className="reader-row" title={reader.userId}>
|
||||
<Avatar name={reader.nick} url={reader.avatarUrl} size={18} />
|
||||
<span className="ellipsis">{reader.nick}</span>
|
||||
</div>
|
||||
))}
|
||||
{readers.length > shown.length && (
|
||||
<span className="read-marker-more small muted">+{readers.length - shown.length}</span>
|
||||
<div className="reader-row small muted">and {readers.length - shown.length} others</div>
|
||||
)}
|
||||
</span>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -506,7 +606,12 @@ function MessageRowBody({
|
||||
comfy && 'comfy',
|
||||
bubbles && 'bubbles',
|
||||
own && 'own',
|
||||
bubbles && lastInRun && 'tail'
|
||||
bubbles && lastInRun && 'tail',
|
||||
// Reserves the gutter the read faces occupy, so the hover toolbar
|
||||
// sits beside them rather than on top of them. Only where there are
|
||||
// any: an IRC channel has no read receipts and should not have the
|
||||
// toolbar shoved inwards for a thing that is not there.
|
||||
(readers?.length ?? 0) > 0 && 'has-readers'
|
||||
)}
|
||||
// Addressable, so a search result can scroll to the message it found.
|
||||
data-msg-id={message.id}
|
||||
|
||||
Reference in New Issue
Block a user