feat(room): redesign the 1:1 DM timeline as a bubble chat with sticky date pills, grouped timestamps, a tap action rail and read status

This commit is contained in:
heaven 2026-06-06 14:35:15 +03:00
parent b56a47db4d
commit 4b7ad11620
25 changed files with 1388 additions and 394 deletions

View file

@ -478,6 +478,12 @@
"duration_seconds": "{{seconds}} sec" "duration_seconds": "{{seconds}} sec"
}, },
"Room": { "Room": {
"delivery": {
"sending": "Sending…",
"sent": "Sent",
"read": "Read",
"failed": "Not sent"
},
"drag_to_close": "Drag up to close", "drag_to_close": "Drag up to close",
"collapse_avatar": "Collapse avatar", "collapse_avatar": "Collapse avatar",
"expand_avatar": "Open avatar", "expand_avatar": "Open avatar",

View file

@ -484,6 +484,12 @@
"duration_seconds": "{{seconds}} сек" "duration_seconds": "{{seconds}} сек"
}, },
"Room": { "Room": {
"delivery": {
"sending": "Отправляется…",
"sent": "Отправлено",
"read": "Прочитано",
"failed": "Не отправлено"
},
"drag_to_close": "Потянуть вверх чтобы закрыть", "drag_to_close": "Потянуть вверх чтобы закрыть",
"collapse_avatar": "Свернуть аватар", "collapse_avatar": "Свернуть аватар",
"expand_avatar": "Развернуть аватар", "expand_avatar": "Развернуть аватар",

View file

@ -40,7 +40,7 @@ import { testMatrixTo } from '../plugins/matrix-to';
import { IAudioContent, IImageContent, isVoiceMessageContent } from '../../types/matrix/common'; import { IAudioContent, IImageContent, isVoiceMessageContent } from '../../types/matrix/common';
import { logMedia } from './message/attachment/streamMediaDebug'; import { logMedia } from './message/attachment/streamMediaDebug';
// Threads the StreamLayout's mediaMode info from Message.tsx down to the // Threads the bubble/channel layout's mediaMode info from Message.tsx down to the
// image / video rendering branches below. Non-null only for media messages // image / video rendering branches below. Non-null only for media messages
// in the timeline; pin-menu / message-search leave it null and fall back // in the timeline; pin-menu / message-search leave it null and fall back
// to the legacy MImage / MVideo Attachment chrome. // to the legacy MImage / MVideo Attachment chrome.

View file

@ -23,6 +23,16 @@ export const Row = style({
animation: `${fadeIn} 180ms ease`, animation: `${fadeIn} 180ms ease`,
}); });
// Own voice note with the avatar shown: the avatar sits to the RIGHT of the
// bubble and the bubble packs to the right edge. row-reverse flips the
// avatar→bubble order so the JSX order stays unchanged. Applied ONLY where
// VoiceContent still draws its avatar — the card previews (pin menu, message
// search). The timeline (bubble + channel) and the thread drawer all pass
// `hideAvatar`, so this never engages there.
export const RowOwn = style({
flexDirection: 'row-reverse',
});
// Bare avatar — fixed 40px, no background box of its own (the avatar image / // Bare avatar — fixed 40px, no background box of its own (the avatar image /
// fallback fills it). Strip any container fill folds might apply. // fallback fills it). Strip any container fill folds might apply.
export const AvatarSlot = style({ export const AvatarSlot = style({
@ -37,8 +47,20 @@ export const Bubble = style({
alignItems: 'center', alignItems: 'center',
gap: config.space.S300, gap: config.space.S300,
flexGrow: 1, flexGrow: 1,
// Target width via flex-basis (so the waveform has comfortable room in the
// shrink-wrapped 1:1 bubble chat), but `minWidth: 0` lets it shrink below that
// on narrow panes/mobile — a hard floor here overflowed the row and got
// clipped by the panel's overflow:hidden. Compact on native, a bit wider on
// web (≥600px) where there's room.
flexBasis: toRem(232),
minWidth: 0, minWidth: 0,
maxWidth: toRem(331),
'@media': {
'screen and (min-width: 600px)': {
flexBasis: toRem(280),
maxWidth: toRem(400), maxWidth: toRem(400),
},
},
// Fixed height so own and other are pixel-identical regardless of fill. // Fixed height so own and other are pixel-identical regardless of fill.
height: toRem(56), height: toRem(56),
boxSizing: 'border-box', boxSizing: 'border-box',

View file

@ -128,7 +128,13 @@ export function VoiceContent({
const displayTime = playing || currentTime > 0 ? currentTime : duration; const displayTime = playing || currentTime > 0 ? currentTime : duration;
return ( return (
<div className={css.Row}> <div
className={`${css.Row} ${isOwn && !hideAvatar ? css.RowOwn : ''}`}
// The voice card runs its own play/seek interactions (the waveform track
// isn't a button/slider element). Mark the whole card so the bubble-DM
// tap-to-open-rail handler skips it (seeking must not also toggle the rail).
data-no-rail-toggle="true"
>
{!hideAvatar && ( {!hideAvatar && (
<Avatar className={css.AvatarSlot} size="300"> <Avatar className={css.AvatarSlot} size="300">
<UserAvatar <UserAvatar

View file

@ -4,10 +4,10 @@ import classNames from 'classnames';
import * as css from './layout.css'; import * as css from './layout.css';
export const MessageBase = as<'div', css.MessageBaseVariants>( export const MessageBase = as<'div', css.MessageBaseVariants>(
({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => ( ({ className, highlight, selected, collapse, autoCollapse, space, bubble, ...props }, ref) => (
<div <div
className={classNames( className={classNames(
css.MessageBase({ highlight, selected, collapse, autoCollapse, space }), css.MessageBase({ highlight, selected, collapse, autoCollapse, space, bubble }),
className className
)} )}
{...props} {...props}

View file

@ -0,0 +1,209 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
// Bubble layout — the simple 1:1 DM chat (assistant-style, like the AI-bot chat):
// the peer's turn is plain full-width text, the user's turn is a right-aligned
// bubble on the composer surface. No rail / dot / nick / avatar. A muted
// per-message timestamp sits in the bubble corner (own) or after the text (peer),
// and the last own message can carry a read-status line below it.
// Per-message vertical rhythm. Continuation rows (same sender) tuck tighter.
const ROW_GAP = toRem(2);
const SENDER_GAP = config.space.S300;
// Row = column stack {bubble|text, reactions, status}, aligned to the sender's
// side. Full-width so the whole row is a comfortable tap target for the rail.
export const Row = recipe({
base: [
DefaultReset,
{
display: 'flex',
flexDirection: 'column',
gap: ROW_GAP,
minWidth: 0,
width: '100%',
// A first-of-run row gets a little more air above it; continuation rows
// collapse it so a same-sender burst reads as one cluster.
marginTop: SENDER_GAP,
},
],
variants: {
own: {
true: { alignItems: 'flex-end' },
false: { alignItems: 'flex-start' },
},
collapsed: {
true: { marginTop: 0 },
},
},
defaultVariants: { own: false },
});
export type BubbleRowVariants = RecipeVariants<typeof Row>;
// Own bubble — composer-tone surface holding the message body. For a single
// message the enclosing LineWrapOwn caps the bubble + side-time unit at 85%; for
// a grouped (same-minute series) tail the time sits BELOW and the bubble fills
// its (already short, right-aligned) content width.
export const OwnBubble = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
maxWidth: '100%',
minWidth: 0,
padding: `${config.space.S200} ${config.space.S400}`,
borderRadius: config.radii.R400,
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
wordBreak: 'break-word',
});
// Peer turn — plain text capped to a comfortable reading measure (~70ch),
// like the AI-bot chat, so long prose doesn't stretch edge-to-edge on desktop.
export const PeerText = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
maxWidth: toRem(768),
minWidth: 0,
color: color.Surface.OnContainer,
wordBreak: 'break-word',
});
// Media / voice rows drop the bubble chrome — the media shell / voice card draws
// its own visuals. `width: fit-content` so the block shrinks to the media/voice
// card and the parent Row can align it (own right, peer left); `width: 100%`
// here would fill the band and defeat that alignment. The image shell is itself
// `width: fit-content` (StreamMedia.css) and the voice card is bounded by its own
// max-width, so both size correctly inside this shrink-wrap.
export const MediaPlain = style({
display: 'flex',
flexDirection: 'column',
minWidth: 0,
width: 'fit-content',
maxWidth: '100%',
});
// Editing: the body is the composer card itself (wrapped in ChatComposer by the
// caller) — full width, no bubble background of our own.
export const EditContent = style({
width: '100%',
minWidth: 0,
});
// Tap-rail open (or context menu / emoji board open) → the message reads as
// «selected», the same affordance long-press gives. A soft brand ring rather
// than a fill so it works over both the bubble and bare peer text / media.
export const Selected = style({
outline: `${toRem(2)} solid ${color.Primary.Main}`,
outlineOffset: toRem(2),
borderRadius: config.radii.R400,
});
// Horizontal wrapper for a SINGLE text message: body + side timestamp on one
// row. Order is set in JSX: own → [time][bubble] (time on the inner/left side),
// peer → [text][time] (inner/right). `align-items: flex-end` drops the stamp onto
// the bubble's bottom edge. The Row aligns the whole wrap to the message side.
export const LineWrap = style({
display: 'flex',
alignItems: 'flex-end',
gap: toRem(6),
minWidth: 0,
});
// Cap the own message + time unit so a long single bubble doesn't span the band.
export const LineWrapOwn = style({ maxWidth: '85%' });
export const LineWrapPeer = style({ maxWidth: '100%' });
// Muted timestamp base — a fixed label that never shrinks (the body wraps
// first). Placement (beside vs below) comes from the modifier classes.
export const Meta = style({
flexShrink: 0,
fontSize: toRem(11),
lineHeight: toRem(14),
color: color.Surface.OnContainer,
opacity: 0.55,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
});
// Side placement (single text messages): beside the bubble, lifted off the very
// bottom so the stamp reads near the last text line, not the padding edge.
export const MetaSide = style({
paddingBottom: toRem(3),
});
// Below placement (media + grouped same-minute series tail): a small gap under
// the bubble / media card, aligned to the message side by the parent Row.
export const MetaBelow = style({
marginTop: toRem(2),
});
globalStyle(`${Meta} time`, {
display: 'block',
fontSize: toRem(11),
lineHeight: toRem(14),
});
// Read-status text under the last own message (Sending… / Sent / Read / failed).
export const Status = style({
fontSize: toRem(11),
lineHeight: toRem(14),
color: color.Surface.OnContainer,
opacity: 0.6,
whiteSpace: 'nowrap',
});
export const StatusFailed = style({
color: color.Critical.Main,
opacity: 1,
});
// Reactions / thread-summary wrappers — float on the page background, aligned
// to the message side by the parent Row.
export const Slot = style({
maxWidth: '100%',
minWidth: 0,
});
// Rail-less system notice (room name/topic/avatar changes) for the bubble DM:
// a centred, muted one-liner — no rail, no dot, no bubble.
export const Sysline = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
padding: `${toRem(2)} ${config.space.S400}`,
minWidth: 0,
});
export const SyslineIcon = style({
opacity: 0.5,
flexShrink: 0,
});
export const SyslineBody = style({
fontSize: toRem(12),
color: color.Surface.OnContainer,
opacity: 0.55,
fontStyle: 'italic',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
minWidth: 0,
});
export const SyslineTime = style({
flexShrink: 0,
fontSize: toRem(11),
color: color.Surface.OnContainer,
opacity: 0.45,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
});
globalStyle(`${SyslineTime} time`, {
display: 'block',
fontSize: toRem(11),
});

View file

@ -0,0 +1,119 @@
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { Icon, IconSrc, as } from 'folds';
import * as css from './Bubble.css';
export type BubbleLayoutProps = {
// Muted per-message timestamp node (e.g. <Time compact />).
time?: ReactNode;
// Own → right bubble; peer → left plain text.
isOwn?: boolean;
// Same-sender continuation row — collapse the top gap so a burst clusters.
collapsed?: boolean;
// Tap-rail open / context menu open → draw the «selected» ring.
selected?: boolean;
// Image/video/voice rows: drop the bubble chrome, the media/voice child owns
// its visuals.
mediaMode?: boolean;
// The timestamp goes BELOW the bubble (rather than on the inner side) — set for
// media AND for the tail of a same-minute series (grouped messages). A single
// text message keeps its timestamp on the side.
timeBelow?: boolean;
// While editing, the body IS a composer card — drop the bubble chrome so it
// reads as one box, and skip the timestamp.
editing?: boolean;
// Reactions chip-row, floats under the message on the page background.
reactions?: ReactNode;
threadSummary?: ReactNode;
// Read/delivery status line — rendered by the caller only for the last own
// message (Sending… / Sent / Read / failed).
readStatus?: ReactNode;
};
// Bubble layout leaf — the simple 1:1 DM look (peer = plain text, own = right
// bubble), matching the AI-bot chat. The surrounding hover/tap menu, reactions
// and reply chrome are supplied by <Message> (the tap-rail floats at the tap
// point, positioned by <Message>); this only arranges the body, the timestamp,
// reactions and the read-status line.
export const BubbleLayout = as<'div', BubbleLayoutProps>(
(
{
className,
time,
isOwn,
collapsed,
selected,
mediaMode,
timeBelow,
editing,
reactions,
threadSummary,
readStatus,
children,
...props
},
ref
) => {
let contentClass = css.PeerText;
if (mediaMode) contentClass = css.MediaPlain;
else if (isOwn) contentClass = css.OwnBubble;
const contentCell = (
<div className={classNames(contentClass, selected && css.Selected)}>{children}</div>
);
let body: ReactNode;
if (editing) {
// The child is already a composer card; render it full width, no chrome.
body = <div className={css.EditContent}>{children}</div>;
} else if (mediaMode || timeBelow) {
// Media, and the tail of a same-minute series: timestamp BELOW the content,
// aligned to the message side by the Row (own → right, peer → left).
body = (
<>
{contentCell}
{time && <span className={classNames(css.Meta, css.MetaBelow)}>{time}</span>}
</>
);
} else {
// Single text message: timestamp on the inner side (own → left, peer → right).
body = (
<div className={classNames(css.LineWrap, isOwn ? css.LineWrapOwn : css.LineWrapPeer)}>
{isOwn && time && <span className={classNames(css.Meta, css.MetaSide)}>{time}</span>}
{contentCell}
{!isOwn && time && <span className={classNames(css.Meta, css.MetaSide)}>{time}</span>}
</div>
);
}
return (
<div
className={classNames(css.Row({ own: !!isOwn, collapsed: !!collapsed }), className)}
{...props}
ref={ref}
>
{body}
{threadSummary && <div className={css.Slot}>{threadSummary}</div>}
{reactions && <div className={css.Slot}>{reactions}</div>}
{readStatus && <div className={css.Slot}>{readStatus}</div>}
</div>
);
}
);
export type BubbleSyslineProps = {
iconSrc: IconSrc;
content: ReactNode;
time?: ReactNode;
};
// Rail-less system notice for the bubble DM — a centred, muted one-liner.
export function BubbleSysline({ iconSrc, content, time }: BubbleSyslineProps) {
return (
<div className={css.Sysline}>
<Icon className={css.SyslineIcon} size="50" src={iconSrc} />
<div className={css.SyslineBody}>{content}</div>
{time && <span className={css.SyslineTime}>{time}</span>}
</div>
);
}

View file

@ -1,4 +1,5 @@
export * from './Modern'; export * from './Modern';
export * from './Stream'; export * from './Stream';
export * from './Channel'; export * from './Channel';
export * from './Bubble';
export * from './Base'; export * from './Base';

View file

@ -95,6 +95,16 @@ export const MessageBase = recipe({
}, },
highlight: HighlightVariant, highlight: HighlightVariant,
selected: SelectedVariant, selected: SelectedVariant,
// Bubble (1:1 DM) rows carry NO horizontal row gutter: the centred timeline
// band (BubbleTimelineBand) owns the edge inset, so messages line up at the
// exact same 12px/40px gutter as the AI-bot chat. The base S400/S200 padding
// is the channel/stream layout's avatar + options-bar column only.
bubble: {
true: {
paddingLeft: 0,
paddingRight: 0,
},
},
}, },
defaultVariants: { defaultVariants: {
space: '400', space: '400',

View file

@ -64,6 +64,7 @@ function NewChatComposer({ preset, room }: { preset: BotPreset; room: Room }) {
fileDropContainerRef={fileDropRef} fileDropContainerRef={fileDropRef}
onSend={handleSend} onSend={handleSend}
textOnly textOnly
singleRow
/> />
</div> </div>
</div> </div>

View file

@ -228,9 +228,14 @@ interface RoomInputProps {
// navigates into the freshly-rooted thread (onSend) — a sticker/file first // navigates into the freshly-rooted thread (onSend) — a sticker/file first
// move would otherwise strand the user on the landing. // move would otherwise strand the user on the landing.
textOnly?: boolean; textOnly?: boolean;
// Single-row layout: the action buttons sit INLINE beside the textarea (plus
// on the left, mic/emoji/send on the right) instead of on a second row below
// it, so the composer is one short line. Used by the AI-bot chat. Default
// (false) keeps the two-row strip everywhere else.
singleRow?: boolean;
} }
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room, threadId, onSend, textOnly }, ref) => { ({ editor, fileDropContainerRef, roomId, room, threadId, onSend, textOnly, singleRow }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@ -1030,8 +1035,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
)} )}
</> </>
} }
// Single-row (AI-bot chat): the + sits inline to the LEFT of the
// textarea; voiceMode replaces the whole editable row so this is
// ignored while recording.
before={singleRow && !voiceMode && !textOnly ? plusButton : undefined}
// Single-row: mic / emoji / send sit inline to the RIGHT of the
// textarea, collapsing the old second action row.
after={
singleRow && !voiceMode ? (
<>
{!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
{!textOnly && emojiButton}
{sendButton}
</>
) : undefined
}
bottom={ bottom={
voiceMode ? null : ( singleRow || voiceMode ? null : (
<Box <Box
alignItems="Center" alignItems="Center"
gap="200" gap="200"

View file

@ -1,6 +1,103 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css'; import { globalStyle, keyframes, style } from '@vanilla-extract/css';
import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
import {
VOJO_BUBBLE_BAND_PX,
VOJO_HORSESHOE_GAP_PX,
VOJO_STICKY_DATE_TOP_PX,
} from '../../styles/horseshoe';
// Bubble (1:1 DM) timeline band — centre the message column in the same band
// the AI-bot chat uses (ThreadDrawerContentAssistant), so on wide web viewports
// the chat is centred instead of spreading edge-to-edge. Inert on mobile (band
// > viewport). The composer mirrors this via ComposerBubbleBand; both read the
// shared VOJO_BUBBLE_BAND_PX so they can't desync. The horizontal gutter is the
// SAME as the AI-bot chat (ThreadDrawerContentAssistant): 12px on native, 40px on
// desktop, applied to both edges so peer (left) and own (right) messages share
// the bot's «ideal» edge indent. MUST match ComposerBubbleBand or the message
// column and the input box won't line up.
export const BubbleTimelineBand = style({
width: '100%',
maxWidth: toRem(VOJO_BUBBLE_BAND_PX),
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
paddingLeft: toRem(VOJO_HORSESHOE_GAP_PX),
paddingRight: toRem(VOJO_HORSESHOE_GAP_PX),
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
});
// Day capsule for the bubble (1:1 DM) timeline («Среда, 4 июня» / «Сегодня») —
// a single dark-blue pill in the message-input tone (Surface.Container, the same
// token the composer card paints with), centred, with generous rounding. No
// border, no echo.
//
// It's a real CSS `position: sticky` element (engaged via the
// `[data-sticky-dates="on"]` rule below), so while you scroll through a day it
// stays pinned at the top, then settles back into its empty slot when you reach
// that day's start. Because sticky is compositor-driven it NEVER jitters (the old
// JS-transform emulation did).
export const BubbleDayCapsule = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${toRem(5)} ${config.space.S400}`,
borderRadius: toRem(14),
backgroundColor: color.Surface.Container,
// Token (not a literal) so the date stays readable in BOTH themes — a
// hard-coded light grey was ~1.7:1 on the white light-theme capsule.
color: color.Surface.OnContainer,
fontSize: toRem(13),
lineHeight: toRem(18),
fontWeight: 500,
whiteSpace: 'nowrap',
});
// Centred row holding the inline day capsule, and the real sticky element. The
// gap should read as EQUAL above and below the capsule. The previous day's last
// message adds nothing below itself (rows space via margin-top only), but the
// next day's first message adds its own SENDER_GAP (S300) on top — so the row's
// bottom margin is S300 less than its top (S500), making the effective gap
// symmetric at ~S500 on both sides.
export const BubbleDayCapsuleRow = style({
display: 'flex',
justifyContent: 'center',
margin: `${config.space.S500} 0 ${config.space.S300}`,
});
// Stickiness is toggled by an attribute the RoomTimeline scroll effect sets on
// the scroll container: ON while scrolled up into history, OFF at the live
// bottom (so no date is stuck at the top while the composer is up). Toggling
// `position` (sticky↔static) causes no layout shift — sticky reserves the same
// in-flow slot as static. The row's containing block is the tall timeline
// column, so the pill has the full day to stick across.
globalStyle(`[data-sticky-dates='on'] ${BubbleDayCapsuleRow}`, {
position: 'sticky',
top: toRem(VOJO_STICKY_DATE_TOP_PX),
zIndex: 3,
});
// Timeline scroll container — a plain native scroller (replaces folds' <Scroll>).
// We deliberately do NOT style `::-webkit-scrollbar`: defining it forces a
// space-RESERVING classic bar even on Android, which pushed own (right) messages
// ~6px further from the edge than peer (left) ones. With no webkit rule, Android
// WebView draws its NATIVE OVERLAY bar — thin, auto-hiding, and reserving ZERO
// space — so the scrollbar is no longer counted in the message gutter and both
// sides sit at the same inset. `scrollbar-width: thin` keeps the desktop bar slim;
// `scrollbar-color` gives it a subtle thumb on a transparent track.
export const TimelineScroll = style({
width: '100%',
height: '100%',
overflowX: 'hidden',
overflowY: 'auto',
scrollbarWidth: 'thin',
scrollbarColor: `${color.SurfaceVariant.ContainerLine} transparent`,
});
export const TimelineFloat = recipe({ export const TimelineFloat = recipe({
base: [ base: [
@ -47,8 +144,7 @@ export const JumpToLatestFab = style([
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.28), 0 2px 4px rgba(0, 0, 0, 0.16)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.28), 0 2px 4px rgba(0, 0, 0, 0.16)',
transition: transition: 'transform 180ms ease-out, opacity 180ms ease-out, background-color 120ms ease',
'transform 180ms ease-out, opacity 180ms ease-out, background-color 120ms ease',
zIndex: 5, zIndex: 5,
transform: 'scale(1)', transform: 'scale(1)',
opacity: 1, opacity: 1,

View file

@ -27,7 +27,7 @@ import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js'; import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Chip, Icon, Icons, Scroll, Text, as, config } from 'folds'; import { Box, Chip, Icon, Icons, Text, as, config } from 'folds';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -48,8 +48,6 @@ import {
MSticker, MSticker,
ImageContent, ImageContent,
EventContent, EventContent,
STREAM_MESSAGE_SPACING,
StreamDayDivider,
CHANNEL_MESSAGE_SPACING, CHANNEL_MESSAGE_SPACING,
ChannelDayDivider, ChannelDayDivider,
} from '../../components/message'; } from '../../components/message';
@ -111,6 +109,8 @@ import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { openMessageIdAtom } from '../../state/room/openMessage';
import { VOJO_STICKY_DATE_TOP_PX } from '../../styles/horseshoe';
import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@ -242,9 +242,11 @@ const PAGINATION_LIMIT = 80;
// room below the last message (timeline paddingBottom + overlay padding) — // room below the last message (timeline paddingBottom + overlay padding) —
// while the user is within this band of the live edge, small scrolls never // while the user is within this band of the live edge, small scrolls never
// strip the composer. // strip the composer.
const COMPOSER_HIDE_DELTA_PX = 12; const COMPOSER_HIDE_DELTA_PX = 8;
const COMPOSER_SHOW_DELTA_PX = 6; const COMPOSER_SHOW_DELTA_PX = 6;
const COMPOSER_NEAR_BOTTOM_PX = 200; // Tightened so the composer slides away (and the floating date appears) as soon
// as the user nudges up off the live edge, rather than only after a big scroll.
const COMPOSER_NEAR_BOTTOM_PX = 80;
// Jump-to-latest FAB visibility thresholds. Decoupled from the auto-follow // Jump-to-latest FAB visibility thresholds. Decoupled from the auto-follow
// `atBottom` gate (which carries a 1s debounce designed for live-timeline // `atBottom` gate (which carries a 1s debounce designed for live-timeline
@ -253,6 +255,18 @@ const COMPOSER_NEAR_BOTTOM_PX = 200;
const FAB_SHOW_DISTANCE_PX = 150; const FAB_SHOW_DISTANCE_PX = 150;
const FAB_HIDE_DISTANCE_PX = 50; const FAB_HIDE_DISTANCE_PX = 50;
// Sticky-date engage thresholds — much tighter than the FAB so stickiness turns
// on the moment the user starts scrolling up (when the composer also slides
// away), and turns back off only right at the live bottom (hysteresis).
const DATE_SHOW_DISTANCE_PX = 36;
const DATE_HIDE_DISTANCE_PX = 6;
// Stable empty set for the channel layout (no minute-grouped timestamps there) —
// shared by the hideTime / timeBelow refs so we don't allocate a fresh Set every
// render and so those terms in each row's renderSig stay constant for channel
// rows (no spurious repaints). Never mutated.
const EMPTY_ITEM_SET: Set<number> = new Set();
type Timeline = { type Timeline = {
linkedTimelines: EventTimeline[]; linkedTimelines: EventTimeline[];
range: ItemRange; range: ItemRange;
@ -604,6 +618,24 @@ export function RoomTimeline({
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
// Bubble (1:1 DM) tap-rail: reset the open-message on room change, and close
// it on any click outside a message row (single-open is owned by the atom; the
// per-row toggle handles taps ON a message — see Message.tsx).
const setOpenMessageId = useSetAtom(openMessageIdAtom);
useEffect(() => {
setOpenMessageId(null);
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Element | null;
if (target?.closest('[data-message-id]')) return;
setOpenMessageId(null);
};
document.addEventListener('pointerdown', onPointerDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
setOpenMessageId(null);
};
}, [room.roomId, setOpenMessageId]);
const mentionClickHandler = useMentionClickHandler(room.roomId); const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler(); const spoilerClickHandler = useSpoilerClickHandler();
@ -627,6 +659,7 @@ export function RoomTimeline({
const [fabHidden, setFabHidden] = useState(true); const [fabHidden, setFabHidden] = useState(true);
const fabHiddenRef = useRef(fabHidden); const fabHiddenRef = useRef(fabHidden);
fabHiddenRef.current = fabHidden; fabHiddenRef.current = fabHidden;
// Pulse counter — bumped on each fresh live message while the FAB is // Pulse counter — bumped on each fresh live message while the FAB is
// showing. Drives the `key` of an icon-wrap span so React remounts it and // showing. Drives the `key` of an icon-wrap span so React remounts it and
// the CSS keyframes restart. // the CSS keyframes restart.
@ -691,6 +724,18 @@ export function RoomTimeline({
const getScrollElement = useCallback(() => scrollRef.current, []); const getScrollElement = useCallback(() => scrollRef.current, []);
// Bubble (1:1 DM) tap-rail floats at a fixed viewport point, so close it on
// timeline scroll rather than let it hang detached over the moving messages
// (standard cursor-menu UX). Separate from the outside-tap close above so it
// can reach the scroll element (defined here, after scrollRef).
useEffect(() => {
const scrollEl = getScrollElement();
if (!scrollEl) return undefined;
const onScroll = () => setOpenMessageId(null);
scrollEl.addEventListener('scroll', onScroll, { passive: true });
return () => scrollEl.removeEventListener('scroll', onScroll);
}, [getScrollElement, setOpenMessageId]);
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } = const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
useVirtualPaginator({ useVirtualPaginator({
count: eventsLength, count: eventsLength,
@ -1257,6 +1302,76 @@ export function RoomTimeline({
const { t } = useTranslation(); const { t } = useTranslation();
// Sticky day capsules (bubble layout only). Each day boundary renders a REAL
// capsule that is a CSS `position: sticky` element (see RoomTimeline.css
// `[data-sticky-dates='on'] BubbleDayCapsuleRow`). The browser pins it on the
// compositor while you scroll through a day, then lets it settle back into its
// empty slot when you reach that day's start — so it NEVER jitters (the old
// JS-transform emulation lagged the scroll by a frame, which is what «дёргается»
// was). This effect does only two cheap, lag-tolerant things:
// 1. Engage/disengage stickiness via a container attribute — ON while scrolled
// up, OFF at the live bottom (so no date sits stuck at the top while the
// composer is up). Hysteresis between SHOW/HIDE distances.
// 2. Flat siblings all pin at the same `top`, so older days pile up behind the
// current one. We hide every piled-up capsule except the front (newest
// stuck) one, so a narrower pill never lets a wider older pill peek out.
// Both are binary toggles, so a one-frame scroll-event delay is invisible.
// `offsetTop` is the in-flow position (sticky doesn't change it), so the
// front detection and the virtual paginator's offsetTop maths stay in agreement.
useEffect(() => {
if (messageLayout === 'channel') return undefined;
const scrollEl = getScrollElement();
if (!scrollEl) return undefined;
let raf = 0;
let engaged = false;
const showAll = () => {
const rows = scrollEl.querySelectorAll<HTMLElement>('[data-day-divider]');
for (let i = 0; i < rows.length; i += 1) rows[i].style.visibility = '';
};
const recompute = () => {
raf = 0;
const distFromBottom = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
if (distFromBottom > DATE_SHOW_DISTANCE_PX) engaged = true;
else if (distFromBottom <= DATE_HIDE_DISTANCE_PX) engaged = false;
if (!engaged) {
if (scrollEl.dataset.stickyDates === 'on') scrollEl.dataset.stickyDates = 'off';
showAll();
return;
}
if (scrollEl.dataset.stickyDates !== 'on') scrollEl.dataset.stickyDates = 'on';
// Front = the last (newest, lowest in the chat) capsule whose natural slot
// has scrolled up to/past the pin — that's the one the browser shows pinned.
const rows = scrollEl.querySelectorAll<HTMLElement>('[data-day-divider]');
const { offsetTop: sOffsetTop, scrollTop } = scrollEl;
let front = -1;
const stuck: boolean[] = [];
for (let i = 0; i < rows.length; i += 1) {
const naturalTop = rows[i].offsetTop - sOffsetTop - scrollTop;
stuck[i] = naturalTop <= VOJO_STICKY_DATE_TOP_PX + 0.5;
if (stuck[i]) front = i;
}
for (let i = 0; i < rows.length; i += 1) {
rows[i].style.visibility = stuck[i] && i !== front ? 'hidden' : '';
}
};
const handler = () => {
if (!raf) raf = requestAnimationFrame(recompute);
};
recompute();
scrollEl.addEventListener('scroll', handler, { passive: true });
return () => {
scrollEl.removeEventListener('scroll', handler);
if (raf) cancelAnimationFrame(raf);
delete scrollEl.dataset.stickyDates;
showAll();
};
}, [getScrollElement, messageLayout]);
// Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one // Group `m.call.member` (StateEvent.GroupCallMemberPrefix) events into one
// aggregate bubble per CALL SESSION. Each session is delimited by «joined // aggregate bubble per CALL SESSION. Each session is delimited by «joined
// count went from 0 → ≥1, then back to 0». A session's anchor is its // count went from 0 → ≥1, then back to 0». A session's anchor is its
@ -1503,6 +1618,21 @@ export function RoomTimeline({
return anchors; return anchors;
}, [timeline]); }, [timeline]);
// Item index of the user's last own message at the live bottom (bubble layout),
// or undefined. Set from the pre-scan below; read inside the message renderers
// to mount the read-status line. A ref so these renderer closures — created
// here but run after the pre-scan assigns it — always see the fresh value.
const latestOwnItemRef = useRef<number | undefined>(undefined);
// Items whose per-message timestamp is hidden because the next renderable row
// is the same sender in the same minute — so a same-minute burst collapses to
// ONE timestamp (on the last message of the minute group), Telegram-style.
// Bubble layout only; set from the pre-scan, read in the message renderers.
const hideTimeRef = useRef<Set<number>>(new Set());
// Items whose (shown) timestamp drops BELOW the bubble rather than to the side:
// the members of a same-minute series (the shown one is its last message). A
// standalone message keeps its timestamp on the side. Bubble layout only.
const timeBelowRef = useRef<Set<number>>(new Set());
const renderMatrixEvent = useMatrixEventRenderer< const renderMatrixEvent = useMatrixEventRenderer<
// (mEventId, mEvent, item, timelineSet, collapse, railStart, railEnd, railHidden) // (mEventId, mEvent, item, timelineSet, collapse, railStart, railEnd, railHidden)
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean, boolean] [string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean, boolean]
@ -1530,6 +1660,9 @@ export function RoomTimeline({
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const isLatestOwn = item === latestOwnItemRef.current;
const showTime = !hideTimeRef.current.has(item);
const timeBelow = timeBelowRef.current.has(item);
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
const getContent = (() => const getContent = (() =>
@ -1546,7 +1679,18 @@ export function RoomTimeline({
return ( return (
<Message <Message
key={mEvent.getId()} key={mEvent.getId()}
renderSig={getMessageRenderSig(room, mEvent, editedEvent, showUrlPreview)} renderSig={getMessageRenderSig(
room,
mEvent,
editedEvent,
showUrlPreview,
isLatestOwn,
showTime,
timeBelow
)}
isLatestOwn={isLatestOwn}
showTime={showTime}
timeBelow={timeBelow}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
@ -1614,7 +1758,11 @@ export function RoomTimeline({
displayName={senderDisplayName} displayName={senderDisplayName}
senderId={senderId} senderId={senderId}
senderAvatarUrl={senderAvatarUrl} senderAvatarUrl={senderAvatarUrl}
hideVoiceAvatar={messageLayout === 'channel'} // Voice notes never draw their own avatar in the timeline: the
// bubble layout already places own → right / peer → left and
// colours the card per side, so an avatar is redundant. (Channel
// rows draw the per-message avatar themselves.)
hideVoiceAvatar
msgType={mEvent.getContent().msgtype ?? ''} msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()} ts={mEvent.getTs()}
edited={!!editedEvent} edited={!!editedEvent}
@ -1645,6 +1793,9 @@ export function RoomTimeline({
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const isLatestOwn = item === latestOwnItemRef.current;
const showTime = !hideTimeRef.current.has(item);
const timeBelow = timeBelowRef.current.has(item);
return ( return (
<EncryptedContent mEvent={mEvent}> <EncryptedContent mEvent={mEvent}>
@ -1665,8 +1816,14 @@ export function RoomTimeline({
room, room,
mEvent, mEvent,
getEditedEvent(mEventId, mEvent, timelineSet), getEditedEvent(mEventId, mEvent, timelineSet),
showUrlPreview showUrlPreview,
isLatestOwn,
showTime,
timeBelow
)} )}
isLatestOwn={isLatestOwn}
showTime={showTime}
timeBelow={timeBelow}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
@ -1766,7 +1923,12 @@ export function RoomTimeline({
displayName={senderDisplayName} displayName={senderDisplayName}
senderId={senderId} senderId={senderId}
senderAvatarUrl={senderAvatarUrl} senderAvatarUrl={senderAvatarUrl}
hideVoiceAvatar={messageLayout === 'channel'} // Hide the voice avatar in every layout — same as the
// cleartext branch above. (Encrypted DMs are the default,
// so this branch is the main 1:1 voice path.) The bubble
// places own/peer by side+colour; channel rows draw their
// own per-message avatar.
hideVoiceAvatar
msgType={mEvent.getContent().msgtype ?? ''} msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()} ts={mEvent.getTs()}
edited={!!editedEvent} edited={!!editedEvent}
@ -1812,11 +1974,25 @@ export function RoomTimeline({
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const isLatestOwn = item === latestOwnItemRef.current;
const showTime = !hideTimeRef.current.has(item);
const timeBelow = timeBelowRef.current.has(item);
return ( return (
<Message <Message
key={mEvent.getId()} key={mEvent.getId()}
renderSig={getMessageRenderSig(room, mEvent, undefined, showUrlPreview)} renderSig={getMessageRenderSig(
room,
mEvent,
undefined,
showUrlPreview,
isLatestOwn,
showTime,
timeBelow
)}
isLatestOwn={isLatestOwn}
showTime={showTime}
timeBelow={timeBelow}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
@ -2260,6 +2436,9 @@ export function RoomTimeline({
before: streamRenderableItemHasBefore, before: streamRenderableItemHasBefore,
hasRenderable: hasRenderableEvent, hasRenderable: hasRenderableEvent,
lastHead: streamLastHeadItem, lastHead: streamLastHeadItem,
lastOwn: latestOwnItem,
hideTime: hideTimeItems,
timeBelow: timeBelowItems,
} = (() => { } = (() => {
const before = new Map<number, boolean>(); const before = new Map<number, boolean>();
@ -2271,26 +2450,87 @@ export function RoomTimeline({
// A renderable row is a «head» (renders a dot) when it does NOT continue // A renderable row is a «head» (renders a dot) when it does NOT continue
// the previous renderable row's run; track the last one for the rail-end. // the previous renderable row's run; track the last one for the rail-end.
// We also track the LAST renderable event so the bubble read-status line can
// attach to it when it's the user's own message, and the items whose
// timestamp collapses into the next same-sender same-minute row.
let seenBefore = false; let seenBefore = false;
let prevRenderable: MatrixEvent | undefined; let prevRenderable: MatrixEvent | undefined;
let prevRenderableItem: number | undefined;
let lastHead: number | undefined; let lastHead: number | undefined;
let lastRenderableItem: number | undefined;
let lastRenderableEv: MatrixEvent | undefined;
const hideTime = new Set<number>();
// Items whose timestamp goes BELOW the bubble instead of on the side: the
// members of a same-sender same-minute series. Only the LAST of a series
// actually shows its time (the rest are in `hideTime`), and that last one is
// a continuation, so it lands here → below. A standalone message is in
// neither set → its time stays on the side.
const timeBelow = new Set<number>();
for (let index = 0; index < items.length; index += 1) { for (let index = 0; index < items.length; index += 1) {
before.set(items[index], seenBefore); before.set(items[index], seenBefore);
if (renderableFlags[index]) { if (renderableFlags[index]) {
seenBefore = true; seenBefore = true;
const ev = getTimelineItemEvent(items[index]); const ev = getTimelineItemEvent(items[index]);
if (ev) { if (ev) {
// Same-sender continuation in the same calendar minute → the PREVIOUS
// row's timestamp is redundant; hide it so the minute group shows one
// stamp (on its last message). floor(ts/60000) buckets by wall-clock
// minute identically in every timezone. The CURRENT row is a series
// member, so its (possibly shown) stamp drops below.
if (
prevRenderable !== undefined &&
prevRenderableItem !== undefined &&
isStreamRunContinuation(prevRenderable, ev) &&
Math.floor(prevRenderable.getTs() / 60000) === Math.floor(ev.getTs() / 60000)
) {
hideTime.add(prevRenderableItem);
timeBelow.add(items[index]);
}
lastRenderableItem = items[index];
lastRenderableEv = ev;
const isHead = const isHead =
prevRenderable === undefined || !isStreamRunContinuation(prevRenderable, ev); prevRenderable === undefined || !isStreamRunContinuation(prevRenderable, ev);
if (isHead) lastHead = items[index]; if (isHead) lastHead = items[index];
prevRenderable = ev; prevRenderable = ev;
prevRenderableItem = items[index];
} }
} }
} }
return { before, hasRenderable: renderableFlags.some(Boolean), lastHead }; const myId = mx.getUserId();
const lastType = lastRenderableEv?.getType();
const lastOwn =
lastRenderableEv !== undefined &&
lastRenderableItem !== undefined &&
lastRenderableEv.getSender() === myId &&
// A redacted own message (visible only with the dev show-hidden-events
// toggle) carries no read state — don't hang a status line off a deleted bubble.
!lastRenderableEv.isRedacted() &&
(lastType === MessageEvent.RoomMessage ||
lastType === MessageEvent.RoomMessageEncrypted ||
lastType === MessageEvent.Sticker)
? lastRenderableItem
: undefined;
return {
before,
hasRenderable: renderableFlags.some(Boolean),
lastHead,
lastOwn,
hideTime,
timeBelow,
};
})(); })();
// Read-status line shows only at the live bottom of a 1:1 (bubble) timeline,
// under the user's genuine last message — never in channel rooms or while
// scrolled up into history.
latestOwnItemRef.current =
messageLayout !== 'channel' && liveTimelineLinked && rangeAtEnd ? latestOwnItem : undefined;
// Minute-grouped timestamps (hide / drop-below) are a bubble-layout affordance only.
hideTimeRef.current = messageLayout !== 'channel' ? hideTimeItems : EMPTY_ITEM_SET;
timeBelowRef.current = messageLayout !== 'channel' ? timeBelowItems : EMPTY_ITEM_SET;
const eventRenderer = (item: number) => { const eventRenderer = (item: number) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item); const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null; if (!eventTimeline) return null;
@ -2395,16 +2635,23 @@ export function RoomTimeline({
return timeDayMonYear(mEvent.getTs()); return timeDayMonYear(mEvent.getTs());
})(); })();
const renderDayDivider = () => ( const renderDayDivider = () =>
<MessageBase messageLayout === 'channel' ? (
space={messageLayout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING} <MessageBase space={CHANNEL_MESSAGE_SPACING}>
>
{messageLayout === 'channel' ? (
<ChannelDayDivider label={dayLabel} /> <ChannelDayDivider label={dayLabel} />
) : (
<StreamDayDivider label={dayLabel} />
)}
</MessageBase> </MessageBase>
) : (
// Bubble (1:1 DM): a centred, single dark-blue date pill. The row is the
// real `position: sticky` element — `data-day-divider` is the hook the
// scroll effect uses to engage stickiness and pick the front pill when
// several pile up. No MessageBase wrapper: the row must be a direct child
// of the timeline column so its sticky containing block is the whole day,
// not a one-row box.
<div className={css.BubbleDayCapsuleRow} data-day-divider="true">
<span className={css.BubbleDayCapsule} role="separator">
{dayLabel}
</span>
</div>
); );
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null; const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
@ -2422,9 +2669,14 @@ export function RoomTimeline({
return eventJSX; return eventJSX;
}; };
const unreadFloatShown = !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
return ( return (
<Box grow="Yes" style={{ position: 'relative' }}> <Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && ( {/* Bubble (1:1 DM) day dates are the inline capsules themselves, made
sticky via real CSS `position: sticky` (engaged by the effect above)
no separate floating pill. */}
{unreadFloatShown && (
<TimelineFloat position="Top"> <TimelineFloat position="Top">
<Chip <Chip
variant="Primary" variant="Primary"
@ -2447,10 +2699,12 @@ export function RoomTimeline({
</Chip> </Chip>
</TimelineFloat> </TimelineFloat>
)} )}
<Scroll ref={scrollRef} visibility="Hover"> <div ref={scrollRef} className={css.TimelineScroll}>
<Box <Box
direction="Column" direction="Column"
justifyContent="End" justifyContent="End"
// Bubble (1:1 DM) layout: centre the message column in the AI-chat band.
className={messageLayout === 'stream' ? css.BubbleTimelineBand : undefined}
style={{ style={{
minHeight: '100%', minHeight: '100%',
// Bottom padding reserves room for the overlay composer painted // Bottom padding reserves room for the overlay composer painted
@ -2504,7 +2758,7 @@ export function RoomTimeline({
{liveTimelineLinked && rangeAtEnd && <RoomTimelineTyping room={room} />} {liveTimelineLinked && rangeAtEnd && <RoomTimelineTyping room={room} />}
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />
</Box> </Box>
</Scroll> </div>
<button <button
type="button" type="button"
className={css.JumpToLatestFab} className={css.JumpToLatestFab}

View file

@ -1,7 +1,11 @@
import { globalStyle, style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds'; import { color, toRem } from 'folds';
import { Editor, EditorTextarea, EditorTextareaScroll } from '../../components/editor/Editor.css'; import { Editor, EditorTextarea, EditorTextareaScroll } from '../../components/editor/Editor.css';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; import {
VOJO_BUBBLE_BAND_PX,
VOJO_HORSESHOE_GAP_PX,
VOJO_HORSESHOE_RADIUS_PX,
} from '../../styles/horseshoe';
// Main chat composer — Dawn canon (stream-v2-dawn.jsx line 285-307): a // Main chat composer — Dawn canon (stream-v2-dawn.jsx line 285-307): a
// floating dark card with all-corners rounded geometry. Two-row layout // floating dark card with all-corners rounded geometry. Two-row layout
@ -30,6 +34,27 @@ export const ComposerDesktopClamp = style({
marginRight: 'auto', marginRight: 'auto',
}); });
// Bubble (1:1 DM) composer band — fit the input form to the same ~960px centred
// band as the bubble timeline + the AI-bot chat (ThreadComposerAssistant), so on
// wide web the composer width matches the messages. Horizontal gutter matches
// BubbleTimelineBand / the bot chat: 12px on native, 40px on desktop, so the
// composer stays aligned with the message column. Replaces ComposerDesktopClamp
// for 1:1 DMs.
export const ComposerBubbleBand = style({
width: '100%',
maxWidth: toRem(VOJO_BUBBLE_BAND_PX),
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
});
// Outer absolute-positioned wrapper for the composer overlay. Carries the // Outer absolute-positioned wrapper for the composer overlay. Carries the
// slide/fade transition driven by the `data-hidden` attribute set from // slide/fade transition driven by the `data-hidden` attribute set from
// React state. CSS class (not inline `transition`) so the // React state. CSS class (not inline `transition`) so the

View file

@ -19,8 +19,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { editableActiveElement } from '../../utils/dom'; import { editableActiveElement } from '../../utils/dom';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoom } from '../../hooks/useRoom'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useThreadDrawerOpen } from '../../hooks/useChannelsMode'; import { useChannelsMode, useThreadDrawerOpen } from '../../hooks/useChannelsMode';
import { VOJO_HORSESHOE_GAP_PX } from '../../styles/horseshoe'; import { VOJO_HORSESHOE_GAP_PX } from '../../styles/horseshoe';
import * as css from './RoomView.css'; import * as css from './RoomView.css';
@ -95,6 +95,12 @@ export function RoomView({ eventId }: { eventId?: string }) {
// mobile / tablet keep the full-width card. // mobile / tablet keep the full-width card.
const isDesktop = useScreenSizeContext() === ScreenSize.Desktop; const isDesktop = useScreenSizeContext() === ScreenSize.Desktop;
// 1:1 DM (bubble layout): fit the composer to the same centred ~960px band as
// the bubble timeline + the AI-bot chat, instead of the 75% desktop clamp.
const isOneOnOne = useIsOneOnOne();
const channelsMode = useChannelsMode();
const isBubble = isOneOnOne && !channelsMode;
useEffect(() => { useEffect(() => {
const el = composerWrapRef.current; const el = composerWrapRef.current;
if (!el) { if (!el) {
@ -177,10 +183,17 @@ export function RoomView({ eventId }: { eventId?: string }) {
onFocusCapture={() => setComposerHidden(false)} onFocusCapture={() => setComposerHidden(false)}
> >
<div <div
className={classNames(css.ChatComposer, isDesktop && css.ComposerDesktopClamp)} className={classNames(
style={{ css.ChatComposer,
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`, isBubble ? css.ComposerBubbleBand : isDesktop && css.ComposerDesktopClamp
}} )}
style={
// ComposerBubbleBand owns its own (responsive) horizontal padding;
// the non-bubble path keeps the fixed horseshoe-void inline padding.
isBubble
? undefined
: { padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}` }
}
> >
{tombstoneEvent ? ( {tombstoneEvent ? (
<RoomTombstone <RoomTombstone

View file

@ -1,6 +1,10 @@
import { keyframes, style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; import {
VOJO_BUBBLE_BAND_PX,
VOJO_HORSESHOE_GAP_PX,
VOJO_HORSESHOE_RADIUS_PX,
} from '../../styles/horseshoe';
// Desktop wrapper for the resizable thread drawer. Sizing and the // Desktop wrapper for the resizable thread drawer. Sizing and the
// absolutely-positioned resize handle live here; the inner aside // absolutely-positioned resize handle live here; the inner aside
@ -185,7 +189,7 @@ export const ThreadDrawerContentAssistant = style([
{ {
paddingTop: config.space.S400, paddingTop: config.space.S400,
width: '100%', width: '100%',
maxWidth: toRem(960), maxWidth: toRem(VOJO_BUBBLE_BAND_PX),
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
boxSizing: 'border-box', boxSizing: 'border-box',
@ -274,7 +278,7 @@ export const ThreadComposerAssistant = style([
ThreadComposer, ThreadComposer,
{ {
width: '100%', width: '100%',
maxWidth: toRem(960), maxWidth: toRem(VOJO_BUBBLE_BAND_PX),
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
boxSizing: 'border-box', boxSizing: 'border-box',

View file

@ -1434,6 +1434,12 @@ export function ThreadDrawer({
roomId={room.roomId} roomId={room.roomId}
threadId={rootId} threadId={rootId}
fileDropContainerRef={fileDropContainerRef} fileDropContainerRef={fileDropContainerRef}
// AI-bot conversation: a single-row, text-only composer — just the
// textarea + send, no +/mic/emoji (matches the bot's new-chat
// composer so the toolbar doesn't appear only after the first send).
// Channel threads keep the default full two-row strip.
singleRow={assistantStyle}
textOnly={assistantStyle}
/> />
) : ( ) : (
<RoomInputPlaceholder <RoomInputPlaceholder

View file

@ -3,17 +3,15 @@ import { Box, Icon, Icons, Text, color } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
BubbleLayout,
ChannelLayout, ChannelLayout,
ChannelMessageAvatar, ChannelMessageAvatar,
StreamLayout,
Time, Time,
Username, Username,
UsernameBold, UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import { Event } from './Message'; import { Event } from './Message';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { getMemberDisplayName } from '../../../utils/room'; import { getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
import { MemberPowerTag } from '../../../../types/matrix/room'; import { MemberPowerTag } from '../../../../types/matrix/room';
@ -94,8 +92,10 @@ export function CallMessage({
canDelete, canDelete,
hideReadReceipts, hideReadReceipts,
showDeveloperTools, showDeveloperTools,
streamRailStart, // Vestigial Stream-rail props — accepted but unused by the bubble/channel
streamRailEnd, // layouts (pending the stream-rail cleanup).
streamRailStart: _streamRailStart,
streamRailEnd: _streamRailEnd,
layout = 'stream', layout = 'stream',
channelHeaderInBubble, channelHeaderInBubble,
memberPowerTag, memberPowerTag,
@ -105,7 +105,6 @@ export function CallMessage({
}: CallMessageProps) { }: CallMessageProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
const senderId = aggregate.anchorSenderId; const senderId = aggregate.anchorSenderId;
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId(); const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
@ -117,10 +116,6 @@ export function CallMessage({
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const usernameStyle = { color: usernameColor ?? color.Primary.Main }; const usernameStyle = { color: usernameColor ?? color.Primary.Main };
// Only the Stream layout renders the rail dot — skip the discarded
// push-action eval / Receipt subscription on channel-layout rows.
const dot = useDotColor(room, mEvent, layout !== 'channel', hideReadReceipts);
// Other side never joined → caller's view «Cancelled», callee's view «Missed». // Other side never joined → caller's view «Cancelled», callee's view «Missed».
// When `mergedCount > 1` the bubble represents a chain of retries that // When `mergedCount > 1` the bubble represents a chain of retries that
// we collapsed at the timeline aggregation step; switch to the plural // we collapsed at the timeline aggregation step; switch to the plural
@ -173,11 +168,6 @@ export function CallMessage({
</Box> </Box>
); );
// No inline colour/size — the `StreamName` wrapper supplies the bold,
// pure white/black, larger-than-body styling that the name inherits. Own
// events show the user's own nick (display name), not a «me» label.
const streamHeader = <Username as="span">{senderName}</Username>;
return ( return (
<Event <Event
key={mEvent.getId()} key={mEvent.getId()}
@ -187,6 +177,7 @@ export function CallMessage({
canDelete={canDelete} canDelete={canDelete}
hideReadReceipts={hideReadReceipts} hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
bubble={layout !== 'channel'}
{...rest} {...rest}
> >
{layout === 'channel' ? ( {layout === 'channel' ? (
@ -211,19 +202,9 @@ export function CallMessage({
{bubbleBody} {bubbleBody}
</ChannelLayout> </ChannelLayout>
) : ( ) : (
<StreamLayout <BubbleLayout time={<Time ts={mEvent.getTs()} compact />} isOwn={isOwnMessage}>
time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color}
dotOpacity={dot.opacity}
dotProminent={dot.prominent}
isOwn={isOwnMessage}
compact={isMobile}
railStart={streamRailStart}
railEnd={streamRailEnd}
header={streamHeader}
>
{bubbleBody} {bubbleBody}
</StreamLayout> </BubbleLayout>
)} )}
</Event> </Event>
); );

View file

@ -0,0 +1,43 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { EventStatus, MatrixEvent, Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import { MessageDeliveryStatus, useMessageStatus } from '../../../hooks/useMessageStatus';
import * as css from '../../../components/message/layout/Bubble.css';
type DmReadStatusLineProps = {
room: Room;
mEvent: MatrixEvent;
// Privacy mode (settings `hideActivity`): never reveal peer read-state — cap
// at «Sent» instead of flipping to «Read». Mirrors useDotColor's gate.
hideReadReceipts?: boolean;
};
// Read/delivery status under the user's LAST own message in a 1:1 DM bubble
// chat: Sending… → Sent → Read (or «Not sent» on failure). Reuses
// useMessageStatus (reactive via RoomEvent.Receipt / LocalEchoUpdated), so it
// flips to «Read» the instant the peer's read receipt lands.
export function DmReadStatusLine({ room, mEvent, hideReadReceipts }: DmReadStatusLineProps) {
const { t } = useTranslation();
const status = useMessageStatus(room, mEvent);
if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) {
return (
<span className={classNames(css.Status, css.StatusFailed)}>{t('Room.delivery.failed')}</span>
);
}
if (status === MessageDeliveryStatus.Sending) {
return <span className={css.Status}>{t('Room.delivery.sending')}</span>;
}
if (status === MessageDeliveryStatus.Read && !hideReadReceipts) {
return <span className={css.Status}>{t('Room.delivery.read')}</span>;
}
if (status === MessageDeliveryStatus.Read || status === MessageDeliveryStatus.Sent) {
return <span className={css.Status}>{t('Room.delivery.sent')}</span>;
}
return null;
}

View file

@ -25,15 +25,20 @@ import {
import React, { import React, {
ComponentProps, ComponentProps,
FormEventHandler, FormEventHandler,
KeyboardEventHandler,
memo, memo,
MouseEventHandler, MouseEventHandler,
ReactNode, ReactNode,
useCallback, useCallback,
useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react'; } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { useHover, useFocusWithin } from 'react-aria'; import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk'; import type { StateEvents } from 'matrix-js-sdk';
@ -42,24 +47,26 @@ import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { isVoiceMessageContent } from '../../../../types/matrix/common'; import { isVoiceMessageContent } from '../../../../types/matrix/common';
import { import {
BubbleLayout,
CHANNEL_MESSAGE_SPACING, CHANNEL_MESSAGE_SPACING,
ChannelLayout, ChannelLayout,
ChannelMessageAvatar, ChannelMessageAvatar,
MessageBase, MessageBase,
STREAM_MESSAGE_SPACING, STREAM_MESSAGE_SPACING,
StreamLayout,
Time, Time,
Username, Username,
UsernameBold, UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import { StreamMediaContext } from '../../../components/RenderMessageContent'; import { StreamMediaContext } from '../../../components/RenderMessageContent';
import { ChatComposer } from '../RoomView.css';
import { logMedia } from '../../../components/message/attachment/streamMediaDebug'; import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room'; import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { openMessageIdAtom } from '../../../state/room/openMessage';
import { DmReadStatusLine } from './DmReadStatusLine';
import * as css from './styles.css'; import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers'; import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer'; import { TextViewer } from '../../../components/text-viewer';
@ -849,13 +856,27 @@ export type MessageProps = {
// whether to mount the card based on `channelsMode && !isBridged` — // whether to mount the card based on `channelsMode && !isBridged` —
// outside channels-mode this stays `undefined` and the slot collapses. // outside channels-mode this stays `undefined` and the slot collapses.
threadSummary?: React.ReactNode; threadSummary?: React.ReactNode;
// Choose timeline visual. `'stream'` = rail + dot + bubble — used for // Choose timeline visual. `'stream'` = the simple 1:1 DM bubble chat
// 1:1 DMs and 1:1 bot control rooms. `'channel'` = avatar + bubble with // (peer = plain text, own = right bubble, muted corner time — like the
// an in-bubble username/time header — used for every non-1:1 room // AI-bot chat) — used for 1:1 DMs and 1:1 bot control rooms.
// (group DMs, group rooms under /space/, channels). Default `'stream'` // `'channel'` = avatar + bubble with an in-bubble username/time header —
// so card-preview callers (pin menu, message search, inbox) get the // used for every non-1:1 room (group DMs, group rooms under /space/,
// legacy DM look. // channels). Default `'stream'` so card-preview callers (pin menu, message
// search) get the DM look.
layout?: 'stream' | 'channel'; layout?: 'stream' | 'channel';
// Bubble layout only: this is the user's LAST own message at the live end
// of the timeline → render the read/delivery status line under it. Folded
// into `renderSig` by the caller so the prior tail repaints when it stops
// being the latest. Ignored by the channel layout.
isLatestOwn?: boolean;
// Bubble layout only: show this row's timestamp. The caller hides it (false)
// on every row but the last of a same-sender same-minute group, so a burst
// collapses to one stamp. Defaults to true; ignored by the channel layout.
showTime?: boolean;
// Bubble layout only: this row is the tail of a same-minute series, so its
// timestamp drops BELOW the bubble (like media) instead of sitting on the
// side. A standalone message keeps the side timestamp. Ignored by channel.
timeBelow?: boolean;
// Forwarded to `ChannelLayout.headerInBubble`. Every current timeline // Forwarded to `ChannelLayout.headerInBubble`. Every current timeline
// caller passes `true` alongside `layout='channel'`; kept as a prop so // caller passes `true` alongside `layout='channel'`; kept as a prop so
// un-bubbled `ChannelLayout` consumers stay possible. // un-bubbled `ChannelLayout` consumers stay possible.
@ -865,6 +886,39 @@ export type MessageProps = {
// decrypt/echo) and rename/urlPreview changes repaint the row. // decrypt/echo) and rename/urlPreview changes repaint the row.
renderSig?: string; renderSig?: string;
}; };
// Bubble (1:1 DM) tap-rail wrapper: floats the action bar at the tap point, just
// above the finger/cursor, and clamps it inside the viewport so it never spills
// off-screen on a narrow native window. `position: fixed` (css.TapRail) is
// viewport-relative here because no message-row ancestor is transformed; the
// layout effect measures the bar and nudges `left`/`top` within an 8px margin
// before paint (so there's no visible jump). It renders inside the message row
// (`[data-message-id]`), so the outside-tap close handler exempts it.
function BubbleTapRail({
point,
children,
}: {
point: { x: number; y: number };
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: point.x, top: point.y });
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
const m = 8;
const left = Math.max(m, Math.min(point.x - width / 2, window.innerWidth - width - m));
const top = Math.max(m, Math.min(point.y - height - m, window.innerHeight - height - m));
setPos({ left, top });
}, [point.x, point.y]);
return (
<div ref={ref} className={css.TapRail} style={{ left: pos.left, top: pos.top }}>
{children}
</div>
);
}
const MessageInner = as<'div', MessageProps>( const MessageInner = as<'div', MessageProps>(
( (
{ {
@ -872,7 +926,9 @@ const MessageInner = as<'div', MessageProps>(
room, room,
mEvent, mEvent,
collapse, collapse,
railHidden, // Vestigial Stream-rail props — accepted (RoomTimeline still passes them)
// but unused by the bubble/channel layouts. Pending the stream-rail cleanup.
railHidden: _railHidden,
highlight, highlight,
edit, edit,
canDelete, canDelete,
@ -892,13 +948,16 @@ const MessageInner = as<'div', MessageProps>(
memberPowerTag, memberPowerTag,
accessibleTagColors, accessibleTagColors,
legacyUsernameColor, legacyUsernameColor,
streamRailStart, streamRailStart: _streamRailStart,
streamRailEnd, streamRailEnd: _streamRailEnd,
hideThreadReplyAffordance, hideThreadReplyAffordance,
hideMainReplyAffordance, hideMainReplyAffordance,
msgType, msgType,
threadSummary, threadSummary,
layout = 'stream', layout = 'stream',
isLatestOwn,
showTime = true,
timeBelow,
channelHeaderInBubble, channelHeaderInBubble,
children, children,
...props ...props
@ -925,17 +984,64 @@ const MessageInner = as<'div', MessageProps>(
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
// Only the Stream layout renders the rail dot — skip the push-action eval
// and Receipt subscription on channel-layout rows where it is discarded.
const dot = useDotColor(room, mEvent, layout !== 'channel', hideReadReceipts);
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
// Touch-friendly action-rail sizing — one step larger on mobile so the hover // Touch-friendly action-rail sizing — one step larger on mobile so the rail
// rail is comfortable under a finger (desktop stays compact for the mouse). // is comfortable under a finger (desktop stays compact for the mouse).
const railBtnSize = isMobile ? '400' : '300'; const railBtnSize = isMobile ? '400' : '300';
const railIconSize = isMobile ? '200' : '100'; const railIconSize = isMobile ? '200' : '100';
const railGap = isMobile ? '200' : '100'; const railGap = isMobile ? '200' : '100';
// Bubble (1:1 DM) layout: a tap toggles THIS row's action rail (single-open
// via the shared atom) instead of hover. `selectAtom` keeps the subscription
// per-row, so a tap only re-renders the two rows whose open-state flips.
const isBubble = layout !== 'channel';
const railOpenSelector = useMemo(
() => selectAtom(openMessageIdAtom, (id) => id === mEvent.getId()),
[mEvent]
);
const railOpen = useAtomValue(railOpenSelector);
const setOpenMessageId = useSetAtom(openMessageIdAtom);
// Viewport point (clientX/Y of the tap) where the rail should float — just
// above it, clamped on-screen by BubbleTapRail. Captured on each open tap.
const [railPoint, setRailPoint] = useState<{ x: number; y: number } | null>(null);
const toggleRail = () => {
const id = mEvent.getId();
if (id) setOpenMessageId((prev) => (prev === id ? null : id));
};
const handleBubbleToggle: MouseEventHandler<HTMLDivElement> = (evt) => {
if (!isBubble || edit) return;
// Don't hijack a text selection, nor taps on interactive children (links,
// buttons, media players, the waveform slider, the action rail, reaction
// chips) — those run their own action and must not also toggle the rail.
if (!window.getSelection()?.isCollapsed) return;
if (
(evt.target as Element | null)?.closest(
'a, button, input, textarea, label, select, img, video, audio, canvas, [role="button"], [role="slider"], [contenteditable="true"], [data-no-rail-toggle]'
)
)
return;
// Float the rail right where the finger/cursor landed (clamped on-screen).
setRailPoint({ x: evt.clientX, y: evt.clientY });
toggleRail();
};
// Keyboard a11y: the row is focusable (tabIndex 0); Enter/Space toggles the
// rail — the keyboard equivalent of the tap. Gated to when the row ITSELF
// holds focus (not a child link/button), so Enter on a focused link still
// follows it. Replaces the focus-within reveal the hover rail used to give.
const handleBubbleKeyDown: KeyboardEventHandler<HTMLDivElement> = (evt) => {
if (!isBubble || edit) return;
if (evt.target !== evt.currentTarget) return;
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
// No pointer for the keyboard path — anchor the rail above the row's top edge.
const r = evt.currentTarget.getBoundingClientRect();
setRailPoint({ x: r.left + r.width / 2, y: r.top + 8 });
toggleRail();
}
};
const bubbleSelected = isBubble && (railOpen || !!menuAnchor || !!emojiBoardAnchor);
// msgType comes from the parent — RoomTimeline reads // msgType comes from the parent — RoomTimeline reads
// `mEvent.getContent().msgtype` synchronously and re-evaluates inside // `mEvent.getContent().msgtype` synchronously and re-evaluates inside
// EncryptedContent's render-prop, which re-runs on Decrypted. Avoiding // EncryptedContent's render-prop, which re-runs on Decrypted. Avoiding
@ -974,20 +1080,23 @@ const MessageInner = as<'div', MessageProps>(
); );
const msgContentJSX = ( const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%', width: '100%' }}>
{reply} {reply}
{edit && onEditId ? ( {edit && onEditId ? (
// Edit form styled as the main composer — a single rounded card
// (`ChatComposer` paints the inner Editor with Surface.Container + 32px
// radius). The bubble drops its own chrome while editing (see
// BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble.
<div className={ChatComposer} style={{ width: '100%' }}>
<MessageEditor <MessageEditor
style={{ style={{ maxWidth: '100%', width: '100%' }}
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId} roomId={room.roomId}
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
onCancel={() => onEditId()} onCancel={() => onEditId()}
/> />
</div>
) : ( ) : (
children children
)} )}
@ -1040,38 +1149,16 @@ const MessageInner = as<'div', MessageProps>(
// channels-mode replies aren't visible in main timeline anyway). // channels-mode replies aren't visible in main timeline anyway).
const isThreadReply = isThreadedMessage && !mEvent.isThreadRoot; const isThreadReply = isThreadedMessage && !mEvent.isThreadRoot;
return ( // The action rail (react / reply / thread / edit / …). Extracted so the same
<MessageBase // bar can render in two places: as a row-anchored overlay for channel/stream
className={classNames(css.MessageBase, className)} // rows, and — for the bubble layout — passed into BubbleLayout so it floats
tabIndex={0} // centred just above the bubble itself.
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING} const railVisible =
// Stream rows always render with collapsed marginTop (=0) so the !edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor);
// bubble-to-bubble gap is uniform regardless of same-sender grouping. // The reaction emoji board, shared by the desktop anchored PopOut and the
// Total gap = 4×S100 (StreamRoot + MessageBase vertical padding on // mobile centred Overlay (an anchored PopOut drifts off-screen on the small
// both sides) = 16px. The rail-bridge in layout.css.ts is still S400 // native viewport — `съезжает` — so mobile centres it instead).
// each side — it overshoots slightly across the smaller gap, but the const emojiBoardEl = (
// overlap lands inside DotColumn behind the dot halo and stays
// invisible. `collapse` still drives avatar/header visibility inside
// ChannelLayout — channel mode keeps the original behaviour.
collapse={layout === 'channel' ? collapse : true}
highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor}
{...props}
{...hoverProps}
{...focusWithinProps}
ref={ref}
>
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap={railGap}>
{canSendReaction && (
<PopOut
position="Bottom"
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={
<EmojiBoard <EmojiBoard
imagePackRooms={imagePackRooms ?? []} imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false} returnFocusOnDeactivate={false}
@ -1090,7 +1177,30 @@ const MessageInner = as<'div', MessageProps>(
setEmojiBoardAnchor(undefined); setEmojiBoardAnchor(undefined);
}} }}
/> />
} );
const railBar = railVisible ? (
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap={railGap}>
{canSendReaction &&
(isMobile ? (
// Mobile: the board opens as a centred Overlay (rendered in
// MessageBase), so the button is a plain toggle here.
<IconButton
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size={railBtnSize}
radii="300"
aria-pressed={!!emojiBoardAnchor}
>
<Icon src={RailIcons.Smile} size={railIconSize} />
</IconButton>
) : (
<PopOut
position="Bottom"
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
anchor={emojiBoardAnchor}
content={emojiBoardEl}
> >
<IconButton <IconButton
onClick={handleOpenEmojiBoard} onClick={handleOpenEmojiBoard}
@ -1102,7 +1212,7 @@ const MessageInner = as<'div', MessageProps>(
<Icon src={RailIcons.Smile} size={railIconSize} /> <Icon src={RailIcons.Smile} size={railIconSize} />
</IconButton> </IconButton>
</PopOut> </PopOut>
)} ))}
{!hideMainReplyAffordance && ( {!hideMainReplyAffordance && (
<IconButton <IconButton
onClick={onReplyClick} onClick={onReplyClick}
@ -1169,12 +1279,7 @@ const MessageInner = as<'div', MessageProps>(
radii="300" radii="300"
onClick={handleAddReactions} onClick={handleAddReactions}
> >
<Text <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.add_reaction')} {t('Room.add_reaction')}
</Text> </Text>
</MenuItem> </MenuItem>
@ -1197,12 +1302,7 @@ const MessageInner = as<'div', MessageProps>(
closeMenu(); closeMenu();
}} }}
> >
<Text <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.reply')} {t('Room.reply')}
</Text> </Text>
</MenuItem> </MenuItem>
@ -1218,12 +1318,7 @@ const MessageInner = as<'div', MessageProps>(
closeMenu(); closeMenu();
}} }}
> >
<Text <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.reply_in_thread')} {t('Room.reply_in_thread')}
</Text> </Text>
</MenuItem> </MenuItem>
@ -1239,12 +1334,7 @@ const MessageInner = as<'div', MessageProps>(
closeMenu(); closeMenu();
}} }}
> >
<Text <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{t('Room.edit_message')} {t('Room.edit_message')}
</Text> </Text>
</MenuItem> </MenuItem>
@ -1257,11 +1347,7 @@ const MessageInner = as<'div', MessageProps>(
/> />
)} )}
{showDeveloperTools && ( {showDeveloperTools && (
<MessageSourceCodeItem <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)} )}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} /> <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && ( {canPinEvent && (
@ -1274,18 +1360,10 @@ const MessageInner = as<'div', MessageProps>(
<Line size="300" /> <Line size="300" />
<Box direction="Column" gap="100" className={css.MessageMenuGroup}> <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{!mEvent.isRedacted() && canDelete && ( {!mEvent.isRedacted() && canDelete && (
<MessageDeleteItem <MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)} )}
{mEvent.getSender() !== mx.getUserId() && ( {mEvent.getSender() !== mx.getUserId() && (
<MessageReportItem <MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)} )}
</Box> </Box>
</> </>
@ -1306,7 +1384,60 @@ const MessageInner = as<'div', MessageProps>(
</PopOut> </PopOut>
</Box> </Box>
</Menu> </Menu>
</div> ) : null;
return (
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
// Bubble (1:1 DM): drop the row's S400/S200 horizontal gutter so the
// message edge inset is owned entirely by the centred band (12px/40px),
// identical to the AI-bot chat. Channel rows keep the gutter.
bubble={isBubble}
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
// Stream rows always render with collapsed marginTop (=0) so the
// bubble-to-bubble gap is uniform regardless of same-sender grouping.
// Total gap = 4×S100 (StreamRoot + MessageBase vertical padding on
// both sides) = 16px. The rail-bridge in layout.css.ts is still S400
// each side — it overshoots slightly across the smaller gap, but the
// overlap lands inside DotColumn behind the dot halo and stays
// invisible. `collapse` still drives avatar/header visibility inside
// ChannelLayout — channel mode keeps the original behaviour.
collapse={layout === 'channel' ? collapse : true}
highlight={highlight}
// Bubble layout scopes its «selected» ring to the bubble itself (see
// BubbleLayout); the full-width MessageBase fill is only for channel rows.
selected={!isBubble && (!!menuAnchor || !!emojiBoardAnchor)}
// Bubble layout: keyboard toggle of the action rail (no-op elsewhere).
onKeyDown={handleBubbleKeyDown}
{...props}
{...hoverProps}
{...focusWithinProps}
ref={ref}
>
{/* Channel rows anchor the rail to the row's top corner; the bubble layout
floats it at the tap point (BubbleTapRail), clamped on-screen. */}
{!isBubble && railBar && <div className={css.MessageOptionsBase}>{railBar}</div>}
{isBubble && railBar && railPoint && (
<BubbleTapRail point={railPoint}>{railBar}</BubbleTapRail>
)}
{/* Mobile: the reaction emoji board renders as a centred Overlay (an
anchored PopOut drifts off the small native viewport). */}
{isMobile && canSendReaction && !edit && !!emojiBoardAnchor && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setEmojiBoardAnchor(undefined),
escapeDeactivates: stopPropagation,
}}
>
{emojiBoardEl}
</FocusTrap>
</OverlayCenter>
</Overlay>
)} )}
{layout === 'channel' ? ( {layout === 'channel' ? (
<ChannelLayout <ChannelLayout
@ -1354,51 +1485,37 @@ const MessageInner = as<'div', MessageProps>(
</StreamMediaContext.Provider> </StreamMediaContext.Provider>
</ChannelLayout> </ChannelLayout>
) : ( ) : (
<StreamLayout <BubbleLayout
time={<Time ts={mEvent.getTs()} compact />} time={showTime ? <Time ts={mEvent.getTs()} compact /> : undefined}
dotColor={dot.color}
dotOpacity={dot.opacity}
dotProminent={dot.prominent}
isOwn={isOwnMessage} isOwn={isOwnMessage}
compact={isMobile} // Same-sender continuation row: tuck tighter under the previous one
// A same-sender continuation row (any minute): hide the dot / time // (no sender-gap). Time stays per-message regardless. See RoomTimeline
// / nick and stack the body tight under the previous one. Only the
// first message of a run keeps the full header. See RoomTimeline
// `collapsed`. // `collapsed`.
collapsed={collapse} collapsed={collapse}
railStart={streamRailStart} selected={bubbleSelected}
railEnd={streamRailEnd} // While editing, the body IS a composer card — drop the bubble chrome
railHidden={railHidden} // so it reads as one box, not a composer nested in a bubble.
editing={!!edit}
// Grouped same-minute series → timestamp below the bubble; singles keep
// it on the side (RoomTimeline computes timeBelow). Media is always below.
timeBelow={timeBelow}
mediaMode={mediaMode || voiceMode} mediaMode={mediaMode || voiceMode}
reactions={reactions} reactions={reactions}
threadSummary={threadSummary} threadSummary={threadSummary}
header={ // Read/delivery status under the user's LAST own message only — at
// Author nick prints once at the head of a same-sender run; every // the live end of the timeline (RoomTimeline computes isLatestOwn).
// continuation row (`collapse`) drops it. Media heads show the readStatus={
// nick here too (above the media) — there's no overlay on the isOwnMessage && isLatestOwn && !edit ? (
// image any more. <DmReadStatusLine room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
collapse ? undefined : ( ) : undefined
// No inline colour / size — the `StreamName` wrapper supplies
// the bold, pure white/black, larger-than-body styling, and the
// button inherits it. css.Username already truncates.
<Username
as="button"
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
{/* Own messages show the user's own nick (display name), not
a «me» label VS-Code-style per-author turn labels. */}
{senderDisplayName}
</Username>
)
} }
onClick={handleBubbleToggle}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<StreamMediaContext.Provider value={streamMediaCtx}> <StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX} {msgContentJSX}
</StreamMediaContext.Provider> </StreamMediaContext.Provider>
</StreamLayout> </BubbleLayout>
)} )}
</MessageBase> </MessageBase>
); );
@ -1424,7 +1541,19 @@ export function getMessageRenderSig(
room: Room, room: Room,
mEvent: MatrixEvent, mEvent: MatrixEvent,
editedEvent: MatrixEvent | undefined, editedEvent: MatrixEvent | undefined,
urlPreview: boolean | undefined urlPreview: boolean | undefined,
// Bubble layout: whether this is the user's last own message (drives the
// read-status line). Folded in so the prior tail repaints the instant it
// stops being latest — the memo comparator only diffs renderSig.
isLatestOwn?: boolean,
// Bubble layout: whether this row shows its timestamp (false when a same-
// sender same-minute row follows). Folded in so a row drops its stamp the
// instant the next same-minute message arrives.
showTime?: boolean,
// Bubble layout: whether the timestamp drops below (same-minute series tail) vs
// sits on the side (single). Folded in so the placement flips when a row joins
// or leaves a minute series.
timeBelow?: boolean
): string { ): string {
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName =
@ -1439,6 +1568,9 @@ export function getMessageRenderSig(
mEvent.status ?? '', mEvent.status ?? '',
senderDisplayName, senderDisplayName,
urlPreview ? '1' : '0', urlPreview ? '1' : '0',
isLatestOwn ? '1' : '0',
showTime === false ? '0' : '1',
timeBelow ? '1' : '0',
].join('\u0001'); ].join('\u0001');
} }
@ -1446,8 +1578,9 @@ export function getMessageRenderSig(
// event / receipt / scroll-state change and rebuilds all ~80 visible rows; this // event / receipt / scroll-state change and rebuilds all ~80 visible rows; this
// lets React skip rows whose visible output is unchanged. SAFE because the // lets React skip rows whose visible output is unchanged. SAFE because the
// dynamic children self-subscribe to their own data (Reactions→useRelations, // dynamic children self-subscribe to their own data (Reactions→useRelations,
// Reply→useRoomEvent, ThreadSummaryCard→ThreadEvent) and the delivery dot // Reply→useRoomEvent, ThreadSummaryCard→ThreadEvent) and the bubble read-status
// self-subscribes (useDotColor→RoomEvent.Receipt) — so we only need to compare // line self-subscribes (DmReadStatusLine→useMessageStatus→RoomEvent.Receipt /
// LocalEchoUpdated) — so we only need to compare
// the row's own scalar/identity props, the PRESENCE of the dynamic node slots, // the row's own scalar/identity props, the PRESENCE of the dynamic node slots,
// and the parent-computed renderSig snapshot. The comparator errs toward re-rendering: // and the parent-computed renderSig snapshot. The comparator errs toward re-rendering:
// any prop whose identity is unstable simply defeats the memo (no perf win) but // any prop whose identity is unstable simply defeats the memo (no perf win) but
@ -1514,6 +1647,9 @@ export type EventProps = {
canDelete?: boolean; canDelete?: boolean;
hideReadReceipts?: boolean; hideReadReceipts?: boolean;
showDeveloperTools?: boolean; showDeveloperTools?: boolean;
// Bubble (1:1 DM) layout: drop the row's horizontal gutter (the band owns the
// edge inset). Set by SyslineMessage / CallMessage when layout !== 'channel'.
bubble?: boolean;
}; };
export const Event = as<'div', EventProps>( export const Event = as<'div', EventProps>(
( (
@ -1525,6 +1661,7 @@ export const Event = as<'div', EventProps>(
canDelete, canDelete,
hideReadReceipts, hideReadReceipts,
showDeveloperTools, showDeveloperTools,
bubble,
children, children,
...props ...props
}, },
@ -1569,6 +1706,7 @@ export const Event = as<'div', EventProps>(
<MessageBase <MessageBase
className={classNames(css.MessageBase, className)} className={classNames(css.MessageBase, className)}
tabIndex={0} tabIndex={0}
bubble={bubble}
space={STREAM_MESSAGE_SPACING} space={STREAM_MESSAGE_SPACING}
autoCollapse autoCollapse
highlight={highlight} highlight={highlight}

View file

@ -2,9 +2,9 @@ import React, { ReactNode } from 'react';
import { Box, Icons, Text } from 'folds'; import { Box, Icons, Text } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { import {
BubbleSysline,
ChannelLayout, ChannelLayout,
ChannelMessageAvatar, ChannelMessageAvatar,
EventContent,
Time, Time,
Username, Username,
UsernameBold, UsernameBold,
@ -42,8 +42,10 @@ export function SyslineMessage({
canDelete, canDelete,
hideReadReceipts, hideReadReceipts,
showDeveloperTools, showDeveloperTools,
streamRailStart, // Vestigial Stream-rail props — accepted but unused by the bubble/channel
streamRailEnd, // layouts (pending the stream-rail cleanup).
streamRailStart: _streamRailStart,
streamRailEnd: _streamRailEnd,
layout = 'stream', layout = 'stream',
channelHeaderInBubble, channelHeaderInBubble,
...rest ...rest
@ -63,6 +65,7 @@ export function SyslineMessage({
canDelete={canDelete} canDelete={canDelete}
hideReadReceipts={hideReadReceipts} hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
bubble={layout !== 'channel'}
{...rest} {...rest}
> >
{layout === 'channel' ? ( {layout === 'channel' ? (
@ -91,18 +94,13 @@ export function SyslineMessage({
</Box> </Box>
</ChannelLayout> </ChannelLayout>
) : ( ) : (
// System notices (room name / topic / avatar changes) render as // System notices (room name / topic / avatar changes) render as a
// muted «Thinking»-style rail rows — the same understated treatment // muted, centred one-liner in the bubble DM — no rail, no bubble,
// as every other Stream sysline (user point 10), never as a chat // matching the AI-bot chat's understated tone.
// bubble. The rail dot is a fixed neutral grey (EventContent owns it), <BubbleSysline
// since a system event has no message read-state to encode.
<EventContent
time={<Time ts={mEvent.getTs()} compact />}
iconSrc={Icons.Info} iconSrc={Icons.Info}
content={body} content={body}
railStart={streamRailStart} time={<Time ts={mEvent.getTs()} compact />}
railEnd={streamRailEnd}
layout="stream"
/> />
)} )}
</Event> </Event>

View file

@ -5,6 +5,17 @@ export const MessageBase = style({
position: 'relative', position: 'relative',
}); });
// Bubble (1:1 DM) tap-rail holder: positioned at the tap point (fixed → viewport
// coords; no transformed ancestor sits above a message row, so `fixed` is truly
// viewport-relative). `left`/`top` are set inline from the clamped tap point so
// the bar appears just above the finger/cursor and never spills off-screen. It
// stays inside `[data-message-id]`, so the outside-tap close handler exempts it.
export const TapRail = style({
position: 'fixed',
zIndex: 10,
display: 'flex',
});
export const MessageOptionsBase = style([ export const MessageOptionsBase = style([
DefaultReset, DefaultReset,
{ {
@ -20,6 +31,7 @@ export const MessageOptionsBase = style([
zIndex: 5, zIndex: 5,
}, },
]); ]);
export const MessageOptionsBar = style([ export const MessageOptionsBar = style([
DefaultReset, DefaultReset,
{ {

View file

@ -0,0 +1,9 @@
import { atom } from 'jotai';
// The event-id of the bubble-layout (1:1 DM) message whose action rail is
// currently open via tap — or null when none is open. Single-open: tapping a
// message sets this, tapping it again clears it, tapping another switches.
// RoomTimeline resets it on room change and on an outside click. Subscribed
// per-row through `selectAtom(openMessageIdAtom, id => id === eventId)` so a tap
// only re-renders the two rows whose open-ness actually flips.
export const openMessageIdAtom = atom<string | null>(null);

View file

@ -28,3 +28,18 @@ export const VOJO_HORSESHOE_GAP_PX = 12;
// lockstep, including the bg-image squares painted into those corners // lockstep, including the bg-image squares painted into those corners
// (which must match the radius exactly to avoid bleed-out). // (which must match the radius exactly to avoid bleed-out).
export const VOJO_HORSESHOE_RADIUS_PX = 32; export const VOJO_HORSESHOE_RADIUS_PX = 32;
// Max width (px) of the centred reading band shared by the AI-bot chat
// (ThreadDrawerContentAssistant / ThreadComposerAssistant) and the 1:1 DM
// bubble chat (BubbleTimelineBand / ComposerBubbleBand). The timeline column
// and the composer MUST use the same value or the messages and the input form
// desync on wide web viewports — so it lives here as one source of truth.
export const VOJO_BUBBLE_BAND_PX = 960;
// Distance (px) from the timeline top at which a bubble (1:1 DM) day capsule
// pins while you scroll through that day. The capsule is a real CSS
// `position: sticky` element (compositor-driven, so it never jitters); this
// value is BOTH the CSS `top` of the sticky pill AND the threshold the
// RoomTimeline scroll effect uses to decide which piled capsule is the
// front (visible) one — so it must stay a single shared source of truth.
export const VOJO_STICKY_DATE_TOP_PX = 8;