Not-connected conversations in italics, with a spinner and a banner

A conversation whose account is not connected, or whose own link is
down, is drawn in italics in the channel list - with a spinner while
something is retrying, a broken-link mark when nothing is - and opening
it shows a Service interruption banner saying why: the account's own
progress, an IRC refusal, or Sneedchat's diagnosis of Tor, the forum or
the chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-02 17:39:42 -04:00
co-authored by Claude Opus 5.5
parent 6fcc83a9b3
commit 26f84b475b
8 changed files with 152 additions and 3 deletions
+5
View File
@@ -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
+1 -1
Submodule nobilis updated: 2732d08685...779682c88f
+44
View File
@@ -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;
}
+15 -2
View File
@@ -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}
<span className="ellipsis buffer-name">{bufferDisplayName(buffer.name)}</span>
@@ -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 && <span className="spinner" aria-label="Synchronising" />}
{link &&
!buffer.syncing &&
(link.retrying ? (
<span className="spinner" aria-label="Connecting" />
) : (
<Icon name="link_off" size={13} className="buffer-muted-icon" />
))}
{poppedOut && <Icon name="open_in_new" size={13} className="buffer-muted-icon" />}
{muted && <Icon name="notifications_off" size={13} className="buffer-muted-icon" />}
{/* One badge, and being on air wins it: a live channel says LIVE,
@@ -6,6 +6,7 @@ import { PinnedBar } from './PinnedBar'
import { CallView } from './CallView'
import { CallStage, RoomCallBar } from './CallStage'
import { StreamStage } from './StreamStage'
import { ServiceBanner } from './ServiceBanner'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
@@ -644,6 +645,9 @@ export function MessageList(): JSX.Element {
return (
<div className="messagelist">
{/* First, because it explains everything under it: a conversation that
is not receiving looks like a quiet one otherwise. */}
<ServiceBanner bufferId={bufferId} />
{/* Above the log and outside the scroller. Who is talking is only
useful while it can be seen, and a panel that scrolled away with
the backlog would be gone the moment anybody read anything. */}
@@ -0,0 +1,28 @@
import { Icon } from './Icon'
import { useChat } from '../state/hooks'
import { bufferLink } from '../lib/bufferlink'
/**
* Said at the top of a conversation that is not receiving: what is wrong, and
* whether anything is being done about it.
*
* Otherwise a room that has lost its connection looks exactly like a quiet
* one, and the first sign anything is wrong is a message that will not send.
*/
export function ServiceBanner({ bufferId }: { bufferId: string }): JSX.Element | null {
const buffer = useChat((s) => s.buffers.find((b) => b.id === bufferId))
const account = useChat((s) => s.accounts.find((a) => a.id === buffer?.accountId))
const detail = useChat((s) => (buffer ? s.connectionDetail[buffer.accountId] : undefined))
if (!buffer) return null
const link = bufferLink(buffer, account, detail)
if (!link) return null
return (
<div className={`service-banner${link.retrying ? '' : ' stopped'}`} role="status">
{link.retrying ? <span className="spinner" aria-hidden="true" /> : <Icon name="link_off" size={16} />}
<span className="service-banner-title">Service interruption</span>
{/* The daemon's own words, which start in lower case when they are a
library's ("an io error occurred"). */}
<span className="service-banner-detail">{link.detail.charAt(0).toUpperCase() + link.detail.slice(1)}</span>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { Account, Buffer } from '../../../shared/wire'
/**
* Whether a conversation is actually receiving, and if not, why.
*
* Two places can say no. The account: every conversation under an account
* that is not connected is not either. And the conversation itself, where it
* has a connection of its own or a join of its own to fail - a Sneedchat room,
* an IRC channel that refused us, a Kick channel Kick has not answered for -
* which the daemon reports as the buffer's `link`.
*/
export interface LinkState {
/** Something is trying: drawn with a spinner. */
retrying: boolean
/** What to say in the banner. */
detail: string
/** Which part is down, where the daemon knows: tor, site, chat, refused. */
cause?: string
}
/** Causes the daemon keeps retrying on its own. */
const RETRIED = new Set(['tor', 'site', 'chat'])
export function bufferLink(buffer: Buffer, account: Account | undefined, accountDetail?: string): LinkState | null {
if (account && account.state !== 'connected') {
switch (account.state) {
case 'connecting':
return { retrying: true, detail: accountDetail || `Connecting to ${account.displayName}…` }
case 'auth_failed':
return { retrying: false, detail: `${account.displayName} needs signing in again - see Accounts.`, cause: 'refused' }
default:
return { retrying: false, detail: `${account.displayName} is disconnected.` }
}
}
const link = buffer.link
if (!link) return null
if (link.state === 'connecting') return { retrying: true, detail: link.detail || 'Connecting…', cause: link.cause }
return {
retrying: RETRIED.has(link.cause ?? ''),
detail: link.detail || 'Not connected.',
cause: link.cause
}
}
+12
View File
@@ -148,6 +148,18 @@ export interface Buffer {
* a room with nothing in it.
*/
syncing?: boolean
/**
* Set while this conversation's own connection is not up though its
* account's is - a Sneedchat room that cannot connect, an IRC channel that
* refused the join, a Kick channel still being looked up. See
* lib/bufferlink for how it combines with the account's state.
*/
link?: {
state: 'connecting' | 'down'
detail?: string
/** tor | site | chat | refused */
cause?: string
}
}
/**