diff --git a/CHANGELOG.md b/CHANGELOG.md index efeebe8..e46bb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ Each section has two parts: - Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a)) - Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a)) - A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c)) +- Server rail tiles now count the mentions waiting in them rather than the messages, and a muted room counts for nothing, mentions included ([moho `0dc048c`](https://github.com/Moho-Chat/moho/commit/0dc048c)) +- Fixed a room unmuted on a Matrix account still being drawn as muted; a mute or unmute the server did not take is now reported with its reason, and Matrix requests to a server that does not answer give up ([moho `95f1784`](https://github.com/Moho-Chat/moho/commit/95f1784), [nobilis `d1bbcaf`](https://github.com/Moho-Chat/nobilis/commit/d1bbcaf)) - Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e)) - Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c)) diff --git a/nobilis b/nobilis index f392d70..58dfa58 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit f392d70c8d0175c3d206a4c0565d5d955cb2c906 +Subproject commit 58dfa5894d861976d075cbae39c261b7031cb342 diff --git a/scripts/ui-shots.mjs b/scripts/ui-shots.mjs index db7d4c9..6ac005b 100644 --- a/scripts/ui-shots.mjs +++ b/scripts/ui-shots.mjs @@ -94,14 +94,14 @@ const DISCORD = `(() => { const ch = (id, name, category, extra = {}) => ({ id: acc + '|' + id, accountId: acc, kind: 'channel', name: 'Button Eye Crochet/#' + name, lastActivityTs: now, groupId: g1, category, position: Number(id.replace(/\\D/g, '')) || 0, - unread: 0, highlight: false, ...extra + unread: 0, highlight: false, mentions: 0, ...extra }) const buffers = [ ch('c1', '📜-rules', undefined), ch('c2', '📢-announcements', undefined, { unread: 2 }), ch('c3', '🤠-general', 'TEXT CHANNELS'), ch('c4', '📹-clips', 'TEXT CHANNELS', { unread: 5 }), - ch('c5', '📷-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true }), + ch('c5', '📷-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true, mentions: 1 }), ch('c6', '💩-shitpost', 'TEXT CHANNELS', { unread: 9 }), ch('c7', '🎨-crafts', 'TEXT CHANNELS'), ch('c8', '🐈-cats', 'ANIMALS', { unread: 3 }), @@ -136,7 +136,7 @@ const DISCORD = `(() => { { id: g2, accountId: acc, service: 'discord', kind: 'guild', name: 'Moho Dev', position: 1 } ], buffers: [...s.buffers.filter((b) => b.accountId !== acc), ...buffers, - { id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 2, highlight: true }], + { id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 33, highlight: true, mentions: 2 }], messagesByBuffer: { ...s.messagesByBuffer, [general]: messages }, presenceByBuffer: { ...s.presenceByBuffer, [general]: members([['Gaunt King', 'online'], ['Clarence', 'idle'], ['Wren', 'dnd'], ['Salastil', 'online'], ['Old Friend', 'offline'], ['Quiet One', 'offline']]) }, activeGroupId: g1, diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 2bfe816..4f1e405 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -60,6 +60,8 @@ interface TileProps { active: boolean unread: number highlight: boolean + /** How many unread messages mention this account: the number the tile shows. */ + mentions: number draggable: boolean dropTarget: boolean /** Being dragged right now: it leaves a gap where it was. */ @@ -179,7 +181,7 @@ function isMerge(e: React.DragEvent): boolean { } function RailTile(props: TileProps): JSX.Element { - const { group, active, unread, highlight, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props + const { group, active, unread, highlight, mentions, draggable, dropTarget, customIcon, muted, lifted, mergeTarget } = props const { menu, open, close } = useContextMenu() const store = useStore() // What is waiting inside this server, for "Mark as read". @@ -299,15 +301,17 @@ function RailTile(props: TileProps): JSX.Element { onClose={close} /> )} - {/* A mention waiting in here says how many are waiting, in the corner - the service mark sits in - the mark gives way, since a count is what - somebody scanning this column came to see. */} - {highlight && unread > 0 && ( - - {unread > 99 ? '99+' : unread} + {/* Mentions say how many are waiting, in the corner the service mark sits + in - the mark gives way, since a count is what somebody scanning this + column came to see. Only mentions: how many messages there are in a + busy channel is not what this column is for, and the pill beside the + tile already says there is something to read. */} + {mentions > 0 && ( + + {mentions > 99 ? '99+' : mentions} )} - {badge && !(highlight && unread > 0) && ( + {badge && mentions === 0 && ( {badge.colour && badge.mark ? ( @@ -419,11 +423,14 @@ export function ServerRail(): JSX.Element | null { // Unread rolls up from the buffers under each entry, so a guild whose // channels are all collapsed away still shows it has something waiting. - const totals = new Map() - const bump = (key: string, b: BufferEntry): void => { - const t = totals.get(key) || { unread: 0, highlight: false } + const totals = new Map() + // `direct` is a page of conversations addressed to you, where every unread + // message is one that is for you and so counts as a mention. + const bump = (key: string, b: BufferEntry, direct = false): void => { + const t = totals.get(key) || { unread: 0, highlight: false, mentions: 0 } t.unread += b.unread t.highlight = t.highlight || b.highlight + t.mentions += direct ? b.unread : b.mentions totals.set(key, t) } const all = buffers as BufferEntry[] @@ -434,7 +441,7 @@ export function ServerRail(): JSX.Element | null { // A buffer can count on more than one tile: where it lives, and on any // aggregate page that also lists it. Both are places the user would look. if (b.groupId) bump(b.groupId, b) - if (isDirectMessage(b)) bump(DM_GROUP_ID, b) + if (isDirectMessage(b)) bump(DM_GROUP_ID, b, true) if (pinned.includes(b.id)) bump(PINNED_GROUP_ID, b) } @@ -552,6 +559,7 @@ export function ServerRail(): JSX.Element | null { onBrowse={g.kind === 'space' ? () => setBrowsing(g) : undefined} unread={t?.unread ?? 0} highlight={t?.highlight ?? false} + mentions={t?.mentions ?? 0} // Direct messages and pinned lead the rail by definition, so // there is nowhere for them to be dragged to. draggable={!isFixedEntry(g)} @@ -599,6 +607,7 @@ export function ServerRail(): JSX.Element | null { open={open} unread={entry.members.reduce((n, m) => n + (totals.get(m.id)?.unread ?? 0), 0)} highlight={entry.members.some((m) => totals.get(m.id)?.highlight)} + mentions={entry.members.reduce((n, m) => n + (totals.get(m.id)?.mentions ?? 0), 0)} onToggle={() => toggleFolder(entry.id)} lifted={dragging === `${FOLDER_DRAG_PREFIX}${entry.id}`} onDragStart={() => beginDrag(`${FOLDER_DRAG_PREFIX}${entry.id}`)} @@ -788,6 +797,7 @@ function FolderTile(props: { /** What is waiting in the servers inside, so a closed folder can say so. */ unread: number highlight: boolean + mentions: number onToggle: () => void onContextMenu: (e: React.MouseEvent) => void onDragStart: () => void @@ -795,7 +805,7 @@ function FolderTile(props: { onDrop: () => void onDragEnd: () => void }): JSX.Element { - const { folder, members, customIcons, dropTarget, lifted, open, unread, highlight } = props + const { folder, members, customIcons, dropTarget, lifted, open, unread, mentions } = props // Open, its servers show their own; closed, what is inside would be // invisible without this. const waiting = !open && unread > 0 @@ -862,14 +872,14 @@ function FolderTile(props: { {/* Once the face is a grid of other people's icons, nothing says it is a folder any more - so it takes the same corner badge a guild uses to name its service. An empty folder is already unmistakably one. */} - {members.length > 0 && !(waiting && highlight) && ( + {members.length > 0 && !(!open && mentions > 0) && ( )} - {waiting && highlight && ( - - {unread > 99 ? '99+' : unread} + {!open && mentions > 0 && ( + + {mentions > 99 ? '99+' : mentions} )} diff --git a/src/renderer/src/lib/groups.test.ts b/src/renderer/src/lib/groups.test.ts index c4c66da..c8e5160 100644 --- a/src/renderer/src/lib/groups.test.ts +++ b/src/renderer/src/lib/groups.test.ts @@ -81,16 +81,22 @@ describe('folders', () => { describe('what a muted place still says', () => { const buf = (extra: Partial): BufferEntry => - ({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 3, highlight: false, ...extra }) as BufferEntry + ({ id: 'a|#x', accountId: 'a', kind: 'channel', name: '#x', lastActivityTs: 0, groupId: 'g', unread: 3, highlight: false, mentions: 0, ...extra }) as BufferEntry - it('counts a quiet muted channel for nothing', () => { + it('counts a muted channel for nothing', () => { expect(countsTowardRail(buf({}), ['a|#x'], [])).toBe(false) expect(countsTowardRail(buf({}), [], [], ['g'])).toBe(false) }) - it('still counts it once somebody has used your name', () => { - expect(countsTowardRail(buf({ highlight: true }), ['a|#x'], [])).toBe(true) - expect(countsTowardRail(buf({ highlight: true }), [], [], ['g'])).toBe(true) + it('counts it for nothing even once somebody has used your name', () => { + expect(countsTowardRail(buf({ highlight: true, mentions: 2 }), ['a|#x'], [])).toBe(false) + expect(countsTowardRail(buf({ highlight: true, mentions: 2 }), [], [], ['g'])).toBe(false) + // A mute made on the account itself is the same. + expect(countsTowardRail(buf({ highlight: true, mentions: 2, serverMuted: true }), [], [])).toBe(false) + }) + + it('counts what is not muted', () => { + expect(countsTowardRail(buf({ highlight: true, mentions: 2 }), [], [])).toBe(true) }) it('never counts what has no row to click through to', () => { diff --git a/src/renderer/src/lib/groups.ts b/src/renderer/src/lib/groups.ts index 19a13c2..afe007a 100644 --- a/src/renderer/src/lib/groups.ts +++ b/src/renderer/src/lib/groups.ts @@ -263,9 +263,9 @@ export function isMutedBuffer( /** * The buffers whose unread should reach a rail tile: what the pane below it * would actually list. A hidden buffer has no row to click through to, and a - * muted one deliberately does not ask for attention - except to say that - * somebody used its owner's name, which muting a place has never meant not - * wanting to hear (the clients this one is measured against all keep that). + * muted one is muted - it does not count, mentions included. Muting a place is + * the one way to say "not now", and a tile that goes on counting what was + * muted says the setting does nothing. */ export function countsTowardRail( buffer: BufferEntry, @@ -273,7 +273,7 @@ export function countsTowardRail( hidden: string[], mutedGroups: string[] = [] ): boolean { - return !hidden.includes(buffer.id) && (buffer.highlight || !isMutedBuffer(buffer, muted, mutedGroups)) + return !hidden.includes(buffer.id) && !isMutedBuffer(buffer, muted, mutedGroups) } diff --git a/src/renderer/src/state/store.test.ts b/src/renderer/src/state/store.test.ts new file mode 100644 index 0000000..53401cb --- /dev/null +++ b/src/renderer/src/state/store.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { ChatStore } from './store' + +/** The store, with a conversation already in its list, and the daemon's update for it applied. */ +function after(update: Record, before: Record = {}): Record { + const store = new ChatStore() as unknown as { + state: { buffers: unknown[] } + handleBufferListChange: (data: unknown) => void + } + const base = { id: 'm|!r', accountId: 'm', kind: 'channel', name: '!r', lastActivityTs: 0, unread: 0, highlight: false, mentions: 0 } + store.state = { ...store.state, buffers: [{ ...base, ...before }] } + // What the daemon sends carries no unread tally: that is this window's own. + store.handleBufferListChange({ id: base.id, accountId: base.accountId, kind: base.kind, name: base.name, lastActivityTs: 0, ...update }) + return store.state.buffers[0] as Record +} + +describe('an update to a conversation the window already has', () => { + it('takes a mute off when the daemon leaves it out', () => { + // The daemon omits a flag that is off, so absent has to mean cleared: a + // room unmuted on the account stayed drawn as muted, with an Unmute that + // had nothing left to do. + expect(after({}, { serverMuted: true }).serverMuted).toBe(false) + expect(after({ serverMuted: true }, {}).serverMuted).toBe(true) + }) + + it('clears the other flags the same way, and keeps the unread count it holds', () => { + const b = after({}, { favourite: true, lowPriority: true, readOnly: 'No permission', forum: true, unread: 4, mentions: 2, highlight: true }) + expect([b.favourite, b.lowPriority, b.readOnly, b.forum]).toEqual([false, false, undefined, false]) + expect([b.unread, b.mentions, b.highlight]).toEqual([4, 2, true]) + }) +}) diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index cdcf703..f20509c 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -72,6 +72,12 @@ export interface ForumPageCache { export interface BufferEntry extends WireBuffer { unread: number highlight: boolean + /** + * How many of the unread messages mention this account. The count the rail + * shows: a tile that says 33 for thirty-three messages in a busy channel is + * noise, where one that says 2 for two people asking for you is the point. + */ + mentions: number /** * Left unread on purpose, rather than merely not read yet. * @@ -2191,6 +2197,7 @@ export class ChatStore { const buffers = wire.map((b) => ({ unread: existing.get(b.id)?.unread ?? 0, highlight: existing.get(b.id)?.highlight ?? false, + mentions: existing.get(b.id)?.mentions ?? 0, ...b })) this.set({ buffers }) @@ -3238,7 +3245,7 @@ export class ChatStore { // 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 + b.id === data.bufferId ? { ...b, unread: 0, highlight: false, mentions: 0, markedUnread: false } : b ) }) break @@ -3650,7 +3657,8 @@ export class ChatStore { remoteId: roomIdOrAlias, syncing: true, unread: 0, - highlight: false + highlight: false, + mentions: 0 } this.set({ buffers: [...this.state.buffers, stand_in] }) try { @@ -3723,13 +3731,33 @@ export class ChatStore { // leaves an optional field out when it is not set, so a plain spread // kept the last value it ever had - a room that reconnected stayed // marked as interrupted, and the banner over it stayed up. + // + // The same for every flag the daemon leaves out when it is off - muted on + // the account, favourite, low priority, read-only, a forum. A merge that + // kept the last value it ever had left a room unmuted on the server still + // drawn as muted here, with an Unmute that had nothing left to do. this.set({ - buffers: buffers.map((b) => (b.id === data.id ? { ...b, ...data, link: data.link, syncing: data.syncing, topic: data.topic } : b)) + buffers: buffers.map((b) => + b.id === data.id + ? { + ...b, + ...data, + link: data.link, + syncing: data.syncing, + topic: data.topic, + serverMuted: data.serverMuted ?? false, + favourite: data.favourite ?? false, + lowPriority: data.lowPriority ?? false, + readOnly: data.readOnly, + forum: data.forum ?? false + } + : b + ) }) this.followPeekJoin(data) return } - this.set({ buffers: [...buffers, { unread: 0, highlight: false, ...data }] }) + this.set({ buffers: [...buffers, { unread: 0, highlight: false, mentions: 0, ...data }] }) // Filed under a rail entry this window has never been told of. An // account's own entry is not announced when the account is made - the // daemon lists one for every account when asked - so the first @@ -3878,7 +3906,7 @@ export class ChatStore { this.set({ buffers: this.state.buffers.map((b) => b.id === bufferId - ? { ...b, unread: b.unread + 1, highlight: b.highlight || !!msg.isHighlight } + ? { ...b, unread: b.unread + 1, highlight: b.highlight || !!msg.isHighlight, mentions: b.mentions + (msg.isHighlight ? 1 : 0) } : b ) }) @@ -3943,7 +3971,7 @@ export class ChatStore { replyingTo: null, editingId: '', buffers: this.state.buffers.map((b) => - b.id === bufferId ? { ...b, unread: 0, highlight: false } : b + b.id === bufferId ? { ...b, unread: 0, highlight: false, mentions: 0 } : b ) } if (dividerTs === undefined && buffer.unread > 0) { @@ -4129,7 +4157,7 @@ export class ChatStore { // here too - the stored timestamp only decides what counts next time. this.set({ buffers: this.state.buffers.map((b) => - bufferIds.includes(b.id) ? { ...b, unread: 0, highlight: false } : b + bufferIds.includes(b.id) ? { ...b, unread: 0, highlight: false, mentions: 0 } : b ) }) } catch (e) {