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:
+1
-1
Submodule nobilis updated: e0ac756c39...03e8bc84ef
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user