Give Sneedchat its brand mark, and drop rail entries nothing can reach

The account entry is the home for buffers no backend grouped, which is how
IRC, Sneedchat and Matrix reach the rail at all. Discord never uses it -
every channel belongs to a guild and every DM to the DM entry - so it drew
a tile that opened an empty pane.

Emptiness alone is not grounds for hiding one: IRC and Sneedchat have no
buffers either until they finish connecting, and Sneedchat's Tor bootstrap
makes that several seconds. So an entry survives if something is under it,
or if its account has no buffers anywhere yet, and only one whose buffers
all live elsewhere is dropped. The rail and the channel pane share that
rule rather than each applying its own - if they disagreed the selected
group could be one with no tile, leaving a pane with no way out of it.

Sneedchat replaces its vpn_lock placeholder with the real mark. The
supplied artwork was a screenshot with the background baked in, which
would have masked to a solid square, so the background is keyed out to
alpha - keeping the eye as a hole so the silhouette still reads. Since
serviceIcon's artwork is no longer always an SVG, its field is renamed to
`mark`, and a `colour` flag marks artwork worth showing unflattened: the
rail has room and already sits beside full-colour guild icons, while the
same file still masks to a monochrome glyph in the buffer rows.
This commit is contained in:
Salastil committed 2026-08-22 17:52:38 -04:00
1 parent 6a285a8807
commit decd239750
7 files changed
+86 -14

No files matched your search

+10
View File
@@ -1735,3 +1735,13 @@ video.lightbox-media {
min-width: 0;
font-weight: 600;
}
/* A brand mark kept in its own colours. Unlike a guild icon it is a logo
rather than a photo, so it is fitted with breathing room instead of cropped
to fill the tile. */
.rail-mark {
width: 74%;
height: 74%;
object-fit: contain;
display: block;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -38,7 +38,7 @@ export function AccountsPanel(): JSX.Element {
className={`service-chip${adding === service ? ' active' : ''}`}
onClick={() => setAdding(adding === service ? null : service)}
>
{icon.svg ? <MaskIcon src={icon.svg} size={16} /> : <Icon name={icon.glyph!} size={16} />}
{icon.mark ? <MaskIcon src={icon.mark} size={16} /> : <Icon name={icon.glyph!} size={16} />}
{serviceLabel(service)}
</button>
)
@@ -72,8 +72,8 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
<div className="account-card-head">
{account.avatarUrl ? (
<img className="account-avatar" src={resolveMediaUrl(account.avatarUrl)} alt="" />
) : icon.svg ? (
<MaskIcon src={icon.svg} size={20} />
) : icon.mark ? (
<MaskIcon src={icon.mark} size={20} />
) : (
<Icon name={icon.glyph!} size={20} />
)}
+5 -3
View File
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { Icon, IconButton, MaskIcon } from './Icon'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { useChat, useIdSetPref, usePref, useStore } from '../state/hooks'
import { visibleGroups } from '../lib/groups'
import type { BufferEntry } from '../state/store'
import {
bufferDisplayName,
@@ -38,7 +39,8 @@ export function BufferList(): JSX.Element {
// A stale selection (a guild that went away, a first run) resolves to the
// first entry rather than an empty pane.
const activeGroup = groups.find((g) => g.id === activeGroupId) || groups[0]
const shownGroups = useMemo(() => visibleGroups(groups, visible), [groups, visible])
const activeGroup = shownGroups.find((g) => g.id === activeGroupId) || shownGroups[0]
const groupAccount = accounts.find((a) => a.id === activeGroup?.accountId)
// Server buffer first, then most recent activity - the ordering nobilis's
@@ -243,8 +245,8 @@ function BufferRow({
// name as text in front of every entry.
const service = serviceIcon(account?.service ?? '')
const leading = showServiceIcon ? (
service.svg ? (
<MaskIcon src={service.svg} size={15} />
service.mark ? (
<MaskIcon src={service.mark} size={15} />
) : (
<Icon name={service.glyph!} size={15} />
)
+12 -4
View File
@@ -1,6 +1,7 @@
import { Icon, MaskIcon } from './Icon'
import { useChat, useStore } from '../state/hooks'
import { nickColor, resolveMediaUrl, serviceIcon } from '../lib/util'
import { visibleGroups } from '../lib/groups'
import type { BufferGroup } from '../../../shared/wire'
import type { BufferEntry } from '../state/store'
@@ -41,8 +42,13 @@ function RailTile({ group, active, unread, highlight, onSelect }: TileProps): JS
content = <img className="rail-icon" src={resolveMediaUrl(group.iconUrl)} alt="" />
} else if (group.kind === 'dms') {
content = <Icon name="forum" size={22} />
} else if (group.kind === 'account' && service.mark && service.colour) {
// Artwork with colour worth keeping, shown as-is rather than flattened to
// a silhouette - it sits beside full-colour guild icons here, and this is
// the one place with room for it.
content = <img className="rail-mark" src={service.mark} alt="" />
} else if (group.kind === 'account') {
content = service.svg ? <MaskIcon src={service.svg} size={22} /> : <Icon name={service.glyph!} size={22} />
content = service.mark ? <MaskIcon src={service.mark} size={22} /> : <Icon name={service.glyph!} size={22} />
} else {
content = (
<span className="rail-initials" style={{ color: nickColor(group.name) }}>
@@ -82,8 +88,6 @@ export function ServerRail(): JSX.Element | null {
const activeGroupId = useChat((s) => s.activeGroupId)
const buffers = useChat((s) => s.buffers)
if (groups.length <= 1) return null
// Unread rolls up from the buffers under each entry, so a guild whose
// channels are all collapsed away still shows it has something waiting.
const totals = new Map<string, { unread: number; highlight: boolean }>()
@@ -95,9 +99,13 @@ export function ServerRail(): JSX.Element | null {
totals.set(b.groupId, t)
}
const visible = visibleGroups(groups, buffers as BufferEntry[])
if (visible.length <= 1) return null
return (
<nav className="server-rail" aria-label="Servers">
{groups.map((g) => {
{visible.map((g) => {
const t = totals.get(g.id)
return (
<RailTile
+33
View File
@@ -0,0 +1,33 @@
import type { BufferGroup } from '../../../shared/wire'
import type { BufferEntry } from '../state/store'
/**
* The rail entries worth drawing.
*
* An account entry is the home for buffers no backend grouped, which is how
* IRC, Sneedchat and Matrix reach the rail at all. Discord never puts anything
* there - every channel belongs to a guild and every DM to the DM entry - so
* its account entry would be a tile that opens an empty pane.
*
* Emptiness alone is not enough to hide one: IRC and Sneedchat have no buffers
* either until they finish connecting, and Sneedchat's Tor bootstrap makes
* that several seconds. So an account with nothing anywhere yet keeps its
* tile, and only one whose buffers all live elsewhere loses it.
*
* Shared by the rail and the channel pane deliberately - if they disagreed,
* the selected group could be one with no tile, leaving a pane the user cannot
* navigate away from.
*/
export function visibleGroups(groups: BufferGroup[], buffers: BufferEntry[]): BufferGroup[] {
const inGroup = new Map<string, number>()
const inAccount = new Map<string, number>()
for (const b of buffers) {
inAccount.set(b.accountId, (inAccount.get(b.accountId) ?? 0) + 1)
if (b.groupId) inGroup.set(b.groupId, (inGroup.get(b.groupId) ?? 0) + 1)
}
return groups.filter((g) => {
if (g.kind !== 'account') return true
if ((inGroup.get(g.id) ?? 0) > 0) return true
return (inAccount.get(g.accountId) ?? 0) === 0
})
}
+23 -4
View File
@@ -1,4 +1,5 @@
import discordSvg from '../assets/discord.svg'
import sneedchatMark from '../assets/sneedchat.png'
import matrixSvg from '../assets/matrix.svg'
/**
@@ -26,12 +27,30 @@ export function guildOf(name: string): string {
* XMPP (which has no widely recognised mark), and "vpn_lock" evokes the
* Tor-only transport Sneedchat runs over.
*/
export function serviceIcon(service: string): { svg?: string; glyph?: string } {
/**
* A service's brand mark.
*
* `mark` is artwork whose *alpha* is the silhouette, so it works as a CSS mask
* regardless of being an SVG or a PNG - which is what lets these sit quietly in
* a list, painted in the surrounding text colour like any other icon.
*
* `colour` says the artwork's own colours are worth keeping where there is
* room for them - the rail, where it sits beside full-colour guild icons -
* while the same file still masks down to a monochrome glyph in the compact
* places.
*/
export interface ServiceIcon {
mark?: string
colour?: boolean
glyph?: string
}
export function serviceIcon(service: string): ServiceIcon {
switch (service) {
case 'discord':
return { svg: discordSvg }
return { mark: discordSvg }
case 'matrix':
return { svg: matrixSvg }
return { mark: matrixSvg }
case 'irc':
return { glyph: 'tag' }
case 'slack':
@@ -40,7 +59,7 @@ export function serviceIcon(service: string): { svg?: string; glyph?: string } {
case 'xmpp':
return { glyph: 'forum' }
case 'sockchat':
return { glyph: 'vpn_lock' }
return { mark: sneedchatMark, colour: true }
default:
return { glyph: 'chat' }
}