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 {