From 1457cf64abf93a095f5b5e8bdfd69204d2727afc Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 28 Aug 2026 09:20:04 -0400 Subject: [PATCH] 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. --- src/renderer/src/app.css | 99 +++++++++++++++++++ src/renderer/src/components/MessageList.tsx | 10 +- src/renderer/src/components/MessageRow.tsx | 59 +++++++++-- .../src/components/settings/SettingsPanel.tsx | 5 +- src/renderer/src/theme.css | 14 +++ 5 files changed, 174 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index afb8aeb..4b81ff4 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; diff --git a/src/renderer/src/components/MessageList.tsx b/src/renderer/src/components/MessageList.tsx index 58492b9..b7fe8c8 100644 --- a/src/renderer/src/components/MessageList.tsx +++ b/src/renderer/src/components/MessageList.tsx @@ -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('display.relativeTimestamps', false) - const [comfy] = usePref('display.messageMode', 'comfy') + const [comfy] = usePref('display.messageMode', 'comfy') const [mediaAutoplay] = usePref('media.autoplay', true) const [mediaLoop] = usePref('media.loop', true) const [contentSniffing] = usePref('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} diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 4d5382c..c5b747a 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -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} > - - {grouped ? '' : timeLabel} - + {/* 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 && ( + + {grouped ? '' : timeLabel} + + )} {/* 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({ )}
- {!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 && ( {message.isAction ? `* ${message.from}` : message.from} @@ -420,6 +456,15 @@ export function MessageRow({ Failed to send — retry )} + + {/* 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 && ( + + {timeLabel} + + )}
{/* Hover toolbar: quick reactions, add-reaction, reply, more. */} diff --git a/src/renderer/src/components/settings/SettingsPanel.tsx b/src/renderer/src/components/settings/SettingsPanel.tsx index f20fbb4..5513439 100644 --- a/src/renderer/src/components/settings/SettingsPanel.tsx +++ b/src/renderer/src/components/settings/SettingsPanel.tsx @@ -181,11 +181,12 @@ function GeneralSettings(): JSX.Element {