Merge branch 'development'

This commit is contained in:
Salastil committed 2026-10-09 15:40:06 -04:00
commit f9e4fbf1c4
8 files changed
+114 -37

No files matched your search

+2
View File
@@ -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))
+1 -1
Submodule nobilis updated: f392d70c8d...58dfa5894d.
+3 -3
View File
@@ -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,
+27 -17
View File
@@ -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 && (
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
{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 && (
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
{mentions > 99 ? '99+' : mentions}
</span>
)}
{badge && !(highlight && unread > 0) && (
{badge && mentions === 0 && (
<span className={`rail-service${highlight ? ' highlight' : ''}`}>
{badge.colour && badge.mark ? (
<img src={badge.mark} alt="" draggable={false} />
@@ -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<string, { unread: number; highlight: boolean }>()
const bump = (key: string, b: BufferEntry): void => {
const t = totals.get(key) || { unread: 0, highlight: false }
const totals = new Map<string, { unread: number; highlight: boolean; mentions: number }>()
// `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) && (
<span className="rail-service">
<Icon name="folder" size={11} />
</span>
)}
{waiting && highlight && (
<span className="rail-count highlight" title={`${unread} unread, with a mention`}>
{unread > 99 ? '99+' : unread}
{!open && mentions > 0 && (
<span className="rail-count highlight" title={`${mentions} mention${mentions === 1 ? '' : 's'}`}>
{mentions > 99 ? '99+' : mentions}
</span>
)}
</button>
+11 -5
View File
@@ -81,16 +81,22 @@ describe('folders', () => {
describe('what a muted place still says', () => {
const buf = (extra: Partial<BufferEntry>): 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', () => {
+4 -4
View File
@@ -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)
}
+31
View File
@@ -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<string, unknown>, before: Record<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>
}
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])
})
})
+35 -7
View File
@@ -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) {