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