Draw a bot's notice as a notice

m.notice exists so a client can hold it back from the conversation, and
every other msgtype fell through protocol::message_body into a plain
message. In a room with an active bridge that made automated traffic
indistinguishable from people talking (#215).

A notice is still a chat line - it has a sender, it can be replied to
and redacted - so it keeps its place in the timeline and takes its own
kind rather than the grey system stream: muted body, a rail down the
edge that survives grouping, and the word "notice" on the byline, since
a toned-down body can be mistaken for an unlucky one.

It also stops announcing itself while the account's own
.m.rule.suppress_notices override says so, which is the state every
homeserver ships. Read from the real rules rather than assumed: somebody
who turned it off meant to.

Two places called this "chat" literally and greyed out every Matrix
message in an HTML export; both now ask isChatKind.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-13 13:07:25 -04:00
co-authored by Claude Opus 5
parent 80d53ed9b3
commit 3980f8eefa
5 changed files with 75 additions and 4 deletions
+1 -1
Submodule nobilis updated: 276bbb0ca6...7d2a04a740
+34
View File
@@ -5877,6 +5877,40 @@ video.lightbox-media {
font: inherit;
}
/* --- Notices ---------------------------------------------------------- */
/* Matrix's m.notice: an announcement rather than a sentence. What bots and
bridges send, and what a homeserver's own notices arrive as.
Held back rather than hidden. The body takes the muted ink and the name
keeps its colour, so a bridge relaying a hundred build results reads as a
hundred announcements while the people talking still stand out - which is
the whole reason the msgtype exists. The rule sits on the body alone so a
notice that mentions you can still be highlighted over the top of it. */
.message-row.notice .message-body {
color: var(--text-muted);
}
/* The rail rather than a tag on the byline, because a run of notices is
grouped and only the first of a run has a byline to hang anything on -
which is exactly the case a bridge produces. */
.message-row.notice {
box-shadow: inset 2px 0 0 var(--border);
}
.notice-tag {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: var(--spacing-xs);
padding: 0 6px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-muted);
font-style: normal;
text-transform: lowercase;
}
/* --- Whispers --------------------------------------------------------- */
/* Said privately rather than to the room. Highlighted like a mention, because
@@ -42,6 +42,7 @@ import {
formatTime,
hasDirectMessages,
isChatKind,
isNotice,
isReward,
isWhisper,
nickColor,
@@ -343,6 +344,11 @@ function MessageRowBody({
// Said to you rather than to the room. Drawn differently on purpose: the
// whole risk with a private message is reading it as a public one.
const whispered = isWhisper(message.kind)
// An announcement rather than a sentence. Kept in the conversation and
// drawn a shade back from it - the point of m.notice is that a bridge
// relaying a hundred build results should not read like a hundred people
// talking.
const notice = isNotice(message.kind)
const reward = isReward(message.kind)
// Both of the modes that draw an avatar column. Bubbles is otherwise
// nothing like comfy, but it wants the same picture beside the same first
@@ -609,6 +615,7 @@ function MessageRowBody({
'message-row',
message.isHighlight && 'highlight',
whispered && 'whisper',
notice && 'notice',
message.pending && 'pending',
message.failed && 'failed',
isSystem && 'system',
@@ -695,6 +702,18 @@ function MessageRowBody({
</span>
)}
{/* Named as well as toned down. The muted body says "less
important", which a long or unlucky message can say by accident;
the word says what it actually is. Only where there is a byline
to put it on - a run of notices is grouped, and the rail down
the edge is what carries it there. */}
{notice && !grouped && (
<span className="notice-tag small">
<Icon name="smart_toy" size={11} />
<span>notice</span>
</span>
)}
{/* Said in words as well as in styling. Italics and a tinted edge
say "not like the others", which is not the same as saying what
it is - and a private message being mistaken for a public one is
+6 -2
View File
@@ -1,5 +1,5 @@
import { escapeHtml, extractMedia, formatMessage } from './format'
import { fileNameOf } from './util'
import { fileNameOf, isChatKind } from './util'
import type { Message } from '../../../shared/wire'
/**
@@ -203,7 +203,11 @@ async function renderMessage(
cards += `<blockquote>${bits.join('<br>')}${link}</blockquote>`
}
const system = m.kind && m.kind !== 'chat' ? ' system' : ''
// The same question the live view asks, rather than a second answer to it:
// "chat" is what IRC, Discord and Sneedchat call a message and "message" is
// what Matrix calls one, so a literal comparison greyed out every Matrix
// line in an export as though nobody had said it.
const system = isChatKind(m.kind) ? '' : ' system'
const who = m.isAction ? `* ${m.from}` : m.from
const edited = m.edited ? ' <span class="dim">(edited)</span>' : ''
return (
+15 -1
View File
@@ -120,7 +120,21 @@ export function serviceLabel(service: string): string {
* grey system line with no author or avatar.
*/
export function isChatKind(kind: string | undefined): boolean {
return kind === 'chat' || kind === 'message' || kind === 'whisper'
return kind === 'chat' || kind === 'message' || kind === 'whisper' || kind === 'notice'
}
/**
* An announcement rather than a sentence: Matrix's `m.notice`, which is what
* bots and bridges send and what a homeserver's own notices arrive as.
*
* Still a chat line - it has a sender, it can be replied to and redacted, and
* it belongs in the conversation rather than in the grey system stream. Only
* its weight changes, which is the entire reason the msgtype exists: in a
* room with an active bridge, automated traffic drawn like people talking
* makes the people impossible to find.
*/
export function isNotice(kind: string | undefined): boolean {
return kind === 'notice'
}
/**