diff --git a/nobilis b/nobilis
index e0ac756..03e8bc8 160000
--- a/nobilis
+++ b/nobilis
@@ -1 +1 @@
-Subproject commit e0ac756c395b708766dfae709da190c2e176ef2a
+Subproject commit 03e8bc84ef2c4a6a9ecdf1cb8214f12675a663f5
diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css
index d8c9a06..bcfbda1 100644
--- a/src/renderer/src/app.css
+++ b/src/renderer/src/app.css
@@ -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 {
diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx
index 3562024..45e9bb5 100644
--- a/src/renderer/src/components/BufferList.tsx
+++ b/src/renderer/src/components/BufferList.tsx
@@ -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 ? (
LIVE
+ ) : buffer.unread > 0 && !muted ? (
+
+ {buffer.unread > 99 ? '99+' : buffer.unread}
+
) : (
- buffer.unread > 0 &&
- !muted && (
-
- {buffer.unread > 99 ? '99+' : buffer.unread}
-
- )
+ // 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 &&
)}
{menu && }
diff --git a/src/renderer/src/components/ConversationMenu.tsx b/src/renderer/src/components/ConversationMenu.tsx
index 601353c..f433066 100644
--- a/src/renderer/src/components/ConversationMenu.tsx
+++ b/src/renderer/src/components/ConversationMenu.tsx
@@ -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
diff --git a/src/renderer/src/lib/buffermenu.ts b/src/renderer/src/lib/buffermenu.ts
index c15e510..efc8c14 100644
--- a/src/renderer/src/lib/buffermenu.ts
+++ b/src/renderer/src/lib/buffermenu.ts
@@ -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 -
diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts
index 8620b6a..6431b14 100644
--- a/src/renderer/src/state/store.ts
+++ b/src/renderer/src/state/store.ts
@@ -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 {
+ 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 {
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