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 ? ( + + ) : ( + + ))} {poppedOut && } {muted && } {/* One badge, and being on air wins it: a live channel says LIVE, diff --git a/src/renderer/src/components/MessageList.tsx b/src/renderer/src/components/MessageList.tsx index d29740f..f37b64b 100644 --- a/src/renderer/src/components/MessageList.tsx +++ b/src/renderer/src/components/MessageList.tsx @@ -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 (
+ {/* First, because it explains everything under it: a conversation that + is not receiving looks like a quiet one otherwise. */} + {/* 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. */} diff --git a/src/renderer/src/components/ServiceBanner.tsx b/src/renderer/src/components/ServiceBanner.tsx new file mode 100644 index 0000000..a29871d --- /dev/null +++ b/src/renderer/src/components/ServiceBanner.tsx @@ -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 ( +
+ {link.retrying ?
+ ) +} diff --git a/src/renderer/src/lib/bufferlink.ts b/src/renderer/src/lib/bufferlink.ts new file mode 100644 index 0000000..90fb6af --- /dev/null +++ b/src/renderer/src/lib/bufferlink.ts @@ -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 + } +} diff --git a/src/shared/wire.ts b/src/shared/wire.ts index e6eaa9f..54978f3 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -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 + } } /**