diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6b6904a..532492a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -58,6 +58,11 @@ keeps running, so connections and unread tracking survive.
- **Matrix security** - device verification by emoji or QR, cross-signing, server-side key
backup, key export and import, and signing other sessions out, all under the account's own
row in the Accounts pane. A session verified here is verified in Element too.
+- **Service interruptions** - a conversation that is not receiving is drawn in italics, with a
+ spinner while something is trying, and says why in a banner when opened: its account is
+ reconnecting, an IRC channel refused the join, a Kick channel is still being looked up. A
+ Sneedchat room that cannot connect is diagnosed - Tor, the forum, or only the chat - by
+ checking the route, then the forum's front page, then concluding it is the chat.
- **Calls and streams** - a call stage like Discord's, which can sit in the corner or pop out
into a window of its own, with tiles per person, volume per person and for the whole call on
their right-click menu, and the camera and screen buttons in one row. A Kick stream is drawn
diff --git a/nobilis b/nobilis
index 2732d08..779682c 160000
--- a/nobilis
+++ b/nobilis
@@ -1 +1 @@
-Subproject commit 2732d0868577342a9048dc077631f741d7954ed7
+Subproject commit 779682c88ff35dc3c4028589bd898120db7c15f9
diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css
index 2d9bdc9..a46de2a 100644
--- a/src/renderer/src/app.css
+++ b/src/renderer/src/app.css
@@ -8198,3 +8198,47 @@ video.lightbox-media {
flex-wrap: wrap;
justify-content: flex-end;
}
+
+/* A conversation that is not receiving - its account is not connected, or
+ its own connection is down. Italic, and a little quieter, so it reads as
+ "not here yet" without disappearing from where it lives in the list. */
+.buffer-row.unlinked .buffer-name {
+ font-style: italic;
+ opacity: 0.7;
+}
+
+/* The same, said at the top of the conversation itself. */
+.service-banner {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: var(--spacing-xs) var(--spacing-s);
+ padding: var(--spacing-xs) var(--spacing-m);
+ border-bottom: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
+ background: color-mix(in srgb, var(--warning) 14%, transparent);
+ font-size: var(--font-size-small);
+}
+
+.service-banner .spinner,
+.service-banner .icon {
+ align-self: center;
+ color: var(--warning);
+}
+
+.service-banner.stopped {
+ border-bottom-color: color-mix(in srgb, var(--error) 45%, transparent);
+ background: color-mix(in srgb, var(--error) 12%, transparent);
+}
+
+.service-banner.stopped .icon {
+ color: var(--error);
+}
+
+.service-banner-title {
+ font-weight: 600;
+}
+
+.service-banner-detail {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx
index e24676f..5f60b05 100644
--- a/src/renderer/src/components/BufferList.tsx
+++ b/src/renderer/src/components/BufferList.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
+import { bufferLink } from '../lib/bufferlink'
import { Icon, IconButton, MaskIcon } from './Icon'
import { ContextMenu, useContextMenu } from './ContextMenu'
import { bufferMenuEntries } from '../lib/buffermenu'
@@ -876,6 +877,10 @@ function BufferRow({
// than the map, so a viewer count ticking over in one channel does not
// re-render every row in the list.
const live = useChat((s) => s.kickStreams[buffer.id]?.live ?? false)
+ // Whether this conversation is receiving - see lib/bufferlink. Not
+ // connected reads in italics, with a spinner while something is trying.
+ const accountDetail = useChat((s) => s.connectionDetail[buffer.accountId])
+ const link = bufferLink(buffer, account, accountDetail)
// Whether this channel's stream is the one in the window. Selected down to
// a boolean for the same reason as `live` above.
const watched = useChat((s) => s.watching?.bufferId === buffer.id)
@@ -955,7 +960,8 @@ function BufferRow({
'buffer-row',
active && 'active',
buffer.highlight && 'highlight',
- lifted && 'lifted'
+ lifted && 'lifted',
+ link && 'unlinked'
)}
onClick={onSelect}
onContextMenu={open}
@@ -967,7 +973,7 @@ function BufferRow({
onDragStart()
}}
onDragEnd={onDragEnd}
- title={draggable ? `${buffer.name} — drag onto a heading to file it` : buffer.name}
+ title={link ? `${buffer.name} — ${link.detail}` : draggable ? `${buffer.name} — drag onto a heading to file it` : buffer.name}
>
{leading}
{bufferDisplayName(buffer.name)}
@@ -977,6 +983,13 @@ function BufferRow({
saying what is in it. A moving thing rather than a static mark,
because what it says is "wait", not "note". */}
{buffer.syncing && }
+ {link &&
+ !buffer.syncing &&
+ (link.retrying ? (
+
+ ) : (
+