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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user