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