A third way to read a conversation: bubbles

Classic is an IRC log and Comfy is Discord. Neither is what a one-to-one
conversation looks like on a phone, which is what this adds.

What makes it that shape is not the rounding, it is that a message takes
a side - yours right in blue, theirs left in green. Everything else
follows from having to work on both sides. The 42px time gutter goes,
because a column cannot exist once messages sit on both edges, and the
time moves inside the bubble where the phone puts it anyway. Your own
bubbles lose the name above them: the side already says who said it,
which is the point of having sides.

Only the last bubble of a run gets a tail, done by squaring the one
corner nearest the speaker rather than by drawing anything. That is what
makes four quick messages read as one turn in the conversation instead
of four separate ones.

System lines take no side and get no bubble. A join or a topic change
was not said by anybody, and a bubble would claim it was - so they are
centred, the way a phone shows the same kind of notice.

The green is deeper than the phone's own #34c759, which carries white
text at about 2.3:1. That is fine glanced at on a handset at arm's
length and not fine for a wall of chat read at a desk; this one clears
5:1 and still reads as the same green.
This commit is contained in:
2026-08-28 09:20:04 -04:00
parent e080e5f076
commit 1457cf64ab
5 changed files with 174 additions and 13 deletions
+99
View File
@@ -382,6 +382,105 @@
padding-top: 0;
}
/* --- bubbles ------------------------------------------------------------
The phone-messaging shape. What makes it that shape is not the rounding,
it is that a message takes a side: yours right, theirs left. Everything
else here follows from having to work on both sides. */
.message-row.bubbles {
align-items: flex-end;
gap: var(--spacing-s);
padding: 1px var(--spacing-m);
}
.message-row.bubbles.own {
/* Reversed rather than justified to the end, so the avatar slot, the
bubble and the hover toolbar all mirror together. */
flex-direction: row-reverse;
}
.message-row.bubbles .message-content {
flex: 0 1 auto;
/* Bounded twice: a share of the pane so a bubble never spans the full
width and stops reading as a bubble, and an absolute cap so it does not
become a paragraph of text on a wide window. */
max-width: min(72%, 34rem);
padding: var(--spacing-xs) var(--spacing-s);
border-radius: 18px;
background: var(--bubble-them);
color: var(--bubble-them-text);
}
.message-row.bubbles.own .message-content {
background: var(--bubble-me);
color: var(--bubble-me-text);
}
/* The tail. Squaring the bottom corner on the side the bubble sits against
gives the pointed-at-the-speaker look without drawing anything, and only
the last of a run gets it - which is what makes a run read as one turn in
the conversation rather than as several. */
.message-row.bubbles.tail .message-content {
border-bottom-left-radius: 5px;
}
.message-row.bubbles.tail.own .message-content {
border-bottom-left-radius: 18px;
border-bottom-right-radius: 5px;
}
/* A name above the bubble, not inside it: it labels who is speaking, and
inside it would read as part of what they said. */
.message-row.bubbles .message-from {
display: block;
margin: 0 0 2px;
font-size: 12px;
}
.message-row.bubbles .bubble-time {
display: block;
margin-top: 2px;
font-size: 10px;
text-align: right;
font-variant-numeric: tabular-nums;
/* Against a filled bubble rather than the page, so the muted grey used
everywhere else would be unreadable on one side and invisible on the
other. */
opacity: 0.65;
}
/* A join or a topic change was not said by anybody, so it gets no bubble and
no side - centred, the way a phone shows the same kind of notice. */
.message-row.bubbles.system {
justify-content: center;
padding: var(--spacing-xs) var(--spacing-m);
}
.message-row.bubbles.system .message-content {
max-width: 80%;
background: none;
color: var(--surface-variant-text);
text-align: center;
}
/* Links and quoted replies have to hold up against a filled background,
where the page's own accent and muted colours do not. */
.message-row.bubbles.own .message-content a,
.message-row.bubbles.own .reply-preview .ellipsis {
color: inherit;
text-decoration: underline;
}
.message-row.bubbles .reply-preview {
opacity: 0.75;
}
/* Media sets its own size, and inside a bubble it should reach the rounded
edge rather than sit on a strip of colour. */
.message-row.bubbles .media-row {
margin: var(--spacing-xxs) 0;
}
.message-time {
flex: none;
width: 42px;
+6 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Icon } from './Icon'
import { MessageRow } from './MessageRow'
import { MessageRow, type MessageMode } from './MessageRow'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
@@ -40,7 +40,7 @@ export function MessageList(): JSX.Element {
const dividerTsByBuffer = useChat((s) => s.dividerTsByBuffer)
const jumpTarget = useChat((s) => s.jumpTarget)
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
const [comfy] = usePref<string>('display.messageMode', 'comfy')
const [comfy] = usePref<MessageMode>('display.messageMode', 'comfy')
const [mediaAutoplay] = usePref<boolean>('media.autoplay', true)
const [mediaLoop] = usePref<boolean>('media.loop', true)
const [contentSniffing] = usePref<boolean>('media.contentSniffing', true)
@@ -293,8 +293,10 @@ export function MessageList(): JSX.Element {
bufferId={bufferId}
service={service}
channels={channelMentions}
grouped={comfy === 'comfy' && isGrouped(messages, i)}
comfy={comfy === 'comfy'}
grouped={comfy !== 'classic' && isGrouped(messages, i)}
mode={comfy}
// The newest message is always the end of its own run.
lastInRun={i === messages.length - 1 || !isGrouped(messages, i + 1)}
relativeTimestamps={relativeTimestamps}
mediaAutoplay={mediaAutoplay}
mediaLoop={mediaLoop}
+52 -7
View File
@@ -34,6 +34,8 @@ import {
/** A handful of one-click reactions on the hover toolbar. */
const QUICK_REACTIONS = ['👍', '❤️', '😂', '🔥']
export type MessageMode = 'classic' | 'comfy' | 'bubbles'
interface Props {
message: ChatMessage
bufferId: string
@@ -41,7 +43,18 @@ interface Props {
/** Channel ids this client can resolve, for `<#id>` links in the body. */
channels?: ChannelIndex
grouped: boolean
comfy: boolean
/**
* How the log is drawn. `classic` is one line per message, `comfy` groups by
* author with an avatar, `bubbles` is the phone-messaging shape: sides, not
* columns.
*/
mode: MessageMode
/**
* Whether this is the last message of its run by one author. Bubbles draw
* their tail here and nowhere else, so a run reads as one turn in the
* conversation rather than as several separate ones.
*/
lastInRun: boolean
relativeTimestamps: boolean
mediaAutoplay: boolean
mediaLoop: boolean
@@ -54,7 +67,8 @@ export function MessageRow({
service,
channels,
grouped,
comfy,
mode,
lastInRun,
relativeTimestamps,
mediaAutoplay,
mediaLoop,
@@ -80,6 +94,18 @@ export function MessageRow({
!!message.isOwn && (service === 'discord' || service === 'sockchat' || service === 'matrix')
const canReact = service === 'discord' || service === 'matrix'
const isSystem = !isChatKind(message.kind)
// Both of the modes that draw an avatar column. Bubbles is otherwise
// nothing like comfy, but it wants the same picture beside the same first
// line of a group.
const comfy = mode === 'comfy' || mode === 'bubbles'
const bubbles = mode === 'bubbles'
/**
* Which side a bubble sits on.
*
* A system line takes no side: a join or a topic change was not said by
* anybody, and putting it in a bubble would claim it was.
*/
const own = bubbles && !isSystem && !!message.isOwn
const attachments = message.attachments ?? []
@@ -208,15 +234,23 @@ export function MessageRow({
message.failed && 'failed',
isSystem && 'system',
grouped && 'grouped',
comfy && 'comfy'
comfy && 'comfy',
bubbles && 'bubbles',
own && 'own',
bubbles && lastInRun && 'tail'
)}
// Addressable, so a search result can scroll to the message it found.
data-msg-id={message.id}
onContextMenu={open}
>
<span className="message-time small muted" title={formatFullTime(message.ts)}>
{grouped ? '' : timeLabel}
</span>
{/* Bubbles carry their own time inside, which is the whole point of
the shape - so the gutter that reserves 42px for it on every other
row would be 42px of nothing. */}
{!bubbles && (
<span className="message-time small muted" title={formatFullTime(message.ts)}>
{grouped ? '' : timeLabel}
</span>
)}
{/* The avatar column is reserved on every grouped row, not just the
one that draws an avatar. Rendering this only for the first
@@ -240,7 +274,9 @@ export function MessageRow({
)}
<div className="message-content">
{!grouped && !isSystem && (
{/* Your own bubble is not labelled with your own name - the side it
is on already says it, which is the point of having sides. */}
{!grouped && !isSystem && !own && (
<span className="message-from" style={{ color: nickColor(message.from) }}>
{message.isAction ? `* ${message.from}` : message.from}
</span>
@@ -420,6 +456,15 @@ export function MessageRow({
<Icon name="error" size={13} /> Failed to send — retry
</button>
)}
{/* Inside the bubble, at its foot. On a phone the time is part of
the bubble rather than a column beside it, and a column is
exactly what cannot exist once messages sit on both sides. */}
{bubbles && !isSystem && (
<span className="bubble-time small" title={formatFullTime(message.ts)}>
{timeLabel}
</span>
)}
</div>
{/* Hover toolbar: quick reactions, add-reaction, reply, more. */}
@@ -181,11 +181,12 @@ function GeneralSettings(): JSX.Element {
<SelectionSetting
settingKey="display.messageMode"
label="Display style"
description="Classic: a compact IRC-style log, one line per message. Comfy: grouped by author with an avatar and more breathing room."
description="Classic: a compact IRC-style log, one line per message. Comfy: grouped by author with an avatar and more breathing room. Bubbles: phone-messaging style, yours on the right in blue and theirs on the left in green, with the time inside each bubble."
defaultValue="comfy"
options={[
{ label: 'Classic', value: 'classic' },
{ label: 'Comfy', value: 'comfy' }
{ label: 'Comfy', value: 'comfy' },
{ label: 'Bubbles', value: 'bubbles' }
]}
/>
<ToggleSetting
+14
View File
@@ -39,6 +39,20 @@
--info: #2196f3;
--success: #4caf50;
/*
* Bubble mode. Blue for what you said and green for what they said, which
* is the phone convention the style is named after.
*
* The green is deeper than the phone's own. Theirs is the brighter
* #34c759, which carries white text at about 2.3:1 - fine glanced at on a
* handset held at arm's length, not fine for a wall of chat read at a
* desk. This one clears 5:1 and still reads as the same green.
*/
--bubble-me: #0a84ff;
--bubble-me-text: #ffffff;
--bubble-them: #1f7a3a;
--bubble-them-text: #ffffff;
--spacing-xxs: 2px;
--spacing-xs: 4px;
--spacing-s: 8px;