Marking a room unread, and showing that it is

The window half of #194.

`markedUnread` is kept separate from the unread count because it outlives
it: a room marked this way stays marked after its messages have been seen,
which is the whole point - it is a note to come back, not a tally. Reading
the room clears both, so the gesture that undoes it is one somebody already
has rather than a second entry to go and find.

Shown as a dot rather than a number, since there is no number to show. It
sits in the same slot as the count and is sized to match, so the column does
not shift when a marked room gains a real unread.

The entry is in the shared menu builder, so the conversation's nameplate and
its row in the list both carry it - and it is absent on every service but
Matrix, which is the only one with an account-level place to keep such a
mark. An entry that silently did nothing on IRC would be worse than none.

Set optimistically as well as sent: the answer comes back through sync
rather than as a reply, so waiting for it would leave the row unchanged for
a whole sync cycle after somebody pressed the thing.

Closes #194.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 09:46:58 -04:00
co-authored by Claude Opus 5
parent 76e6e7d09f
commit ab250d1968
6 changed files with 95 additions and 11 deletions
+1 -1
Submodule nobilis updated: e0ac756c39...03e8bc84ef
+12
View File
@@ -5406,6 +5406,18 @@ video.lightbox-media {
min-width: 0;
}
/* A room somebody deliberately left unread, with nothing new in it. Sized and
placed like the count it stands in for, so the column does not shift when a
marked room gains a real unread. */
.unread-dot {
width: 9px;
height: 9px;
margin: 0 4px;
border-radius: 999px;
background: var(--primary);
flex: none;
}
/* --- Exporting a conversation ----------------------------------------- */
.export-choice {
+17 -7
View File
@@ -313,6 +313,7 @@ export function BufferList(): JSX.Element {
poppedOut={popouts.open.includes(b.id)}
onPopOut={() => store.popOut(b.id)}
onDock={() => store.dock(b.id)}
onMarkUnread={() => void store.markUnread(b.id)}
// Only where there is somewhere to drop it. A list with no headings of
// your own has nothing a channel could be filed under, and a drag that
// can only ever be refused is worse than one the row does not offer.
@@ -719,6 +720,8 @@ interface BufferRowProps {
poppedOut: boolean
onPopOut: () => void
onDock: () => void
/** Matrix only: leave it unread on purpose. */
onMarkUnread?: () => void
draggable: boolean
/** Being carried right now. */
lifted: boolean
@@ -757,6 +760,7 @@ function BufferRow({
poppedOut,
onPopOut,
onDock,
onMarkUnread,
draggable,
lifted,
filed,
@@ -829,7 +833,9 @@ function BufferRow({
onHangUp,
onFile,
onPopOut,
onDock
onDock,
onMarkUnread: account?.service === 'matrix' ? onMarkUnread : undefined,
markedUnread: buffer.markedUnread
})
return (
@@ -870,13 +876,17 @@ function BufferRow({
arrive is worse than either on its own. */}
{live ? (
<span className="live-badge">LIVE</span>
) : buffer.unread > 0 && !muted ? (
<span className={classes('unread-badge', buffer.highlight && 'highlight')}>
{buffer.unread > 99 ? '99+' : buffer.unread}
</span>
) : (
buffer.unread > 0 &&
!muted && (
<span className={classes('unread-badge', buffer.highlight && 'highlight')}>
{buffer.unread > 99 ? '99+' : buffer.unread}
</span>
)
// Left unread on purpose, with nothing new in it. A dot rather than
// a count, because there is no number to show - the room was read
// and then deliberately put back, and what it is saying is "come
// back to this", not "there are four of something".
buffer.markedUnread &&
!muted && <span className="unread-dot" aria-label="Marked as unread" />
)}
</button>
{menu && <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={close} />}
@@ -91,7 +91,9 @@ export function ConversationMenu({ buffer }: { buffer: BufferEntry }): JSX.Eleme
onHangUp: () => void store.leaveVoice(buffer.accountId),
onFile: (categoryId) => setAssignment(buffer.id, categoryId),
onPopOut: () => store.popOut(buffer.id),
onDock: () => store.dock(buffer.id)
onDock: () => store.dock(buffer.id),
onMarkUnread: account?.service === 'matrix' ? () => void store.markUnread(buffer.id) : undefined,
markedUnread: buffer.markedUnread
}),
{ separator: true },
// Last, and on its own. It is the one entry here that starts something
+18 -1
View File
@@ -44,6 +44,14 @@ export interface BufferMenuInput {
onFile: (categoryId: string) => void
onPopOut: () => void
onDock: () => void
/**
* Leave it unread on purpose. Absent where the service has nowhere to keep
* such a mark - only Matrix has account data for it, and an entry that
* silently did nothing anywhere else would be worse than no entry.
*/
onMarkUnread?: () => void
/** Already marked, so the entry has nothing left to offer. */
markedUnread?: boolean
}
/** What leaving is called, which depends on what is being left. */
@@ -81,7 +89,9 @@ export function bufferMenuEntries({
onHangUp,
onFile,
onPopOut,
onDock
onDock,
onMarkUnread,
markedUnread
}: BufferMenuInput): MenuEntry[] {
return [
...(canCall
@@ -151,6 +161,13 @@ export function bufferMenuEntries({
}
] as MenuEntry[])
: []),
// Above pin and mute because it is about this conversation now, where
// those two are about it always. Offered only where it can be kept, and
// not offered again once it is set - reading the room is what takes it
// off, which is the gesture somebody already has.
...(onMarkUnread && !markedUnread
? ([{ label: 'Mark as unread', icon: 'mark_chat_unread', onClick: onMarkUnread }] as MenuEntry[])
: []),
{ label: pinned ? 'Unpin' : 'Pin', icon: 'push_pin', onClick: onTogglePin },
// A mute made on the account itself is not this window's to undo, and an
// "Unmute" that quietly did nothing would be worse than no entry at all -
+44 -1
View File
@@ -46,6 +46,16 @@ import type { PopoutState } from '../../../shared/ipc'
export interface BufferEntry extends WireBuffer {
unread: number
highlight: boolean
/**
* Left unread on purpose, rather than merely not read yet.
*
* Separate from `unread` because it outlives the count: a room marked this
* way stays marked after its messages have been seen, which is the whole
* point - it is a note to come back, not a tally. Matrix only, since it is
* the one service here with an account-level place to keep it, which is what
* lets the mark follow somebody to their phone.
*/
markedUnread?: boolean
}
/** A wire Message plus local optimistic-send bookkeeping. */
@@ -1488,6 +1498,27 @@ export class ChatStore {
}
}
/**
* Leaves a room unread on purpose.
*
* Set optimistically as well as sent, because the answer arrives back
* through sync rather than as a reply - waiting for it would leave the row
* looking unchanged for a whole sync cycle after somebody pressed the thing.
*/
async markUnread(bufferId: string): Promise<void> {
this.set({
buffers: this.state.buffers.map((b) => (b.id === bufferId ? { ...b, markedUnread: true } : b))
})
try {
await window.moho.rpc('setMatrixMarkedUnread', { bufferId, unread: true })
} catch (e) {
this.set({
buffers: this.state.buffers.map((b) => (b.id === bufferId ? { ...b, markedUnread: false } : b))
})
this.toast('error', `Couldn't mark that unread: ${(e as Error).message}`)
}
}
/** Puts an export down between pages, or picks it back up. */
async pauseTransfer(id: string, paused: boolean): Promise<void> {
try {
@@ -2347,7 +2378,19 @@ export class ChatStore {
case 'bufferRead':
this.set({
buffers: this.state.buffers.map((b) =>
b.id === data.bufferId ? { ...b, unread: 0, highlight: false } : b
// Reading a room is how a deliberate mark is taken off, so the
// flag clears with the count. Anything else would need a second
// gesture to undo the first, and nobody would find it.
b.id === data.bufferId ? { ...b, unread: 0, highlight: false, markedUnread: false } : b
)
})
break
// Somebody left this room unread, here or on another client.
case 'markedUnread':
this.set({
buffers: this.state.buffers.map((b) =>
b.id === data.bufferId ? { ...b, markedUnread: !!data.unread } : b
)
})
break