diff --git a/public/locales/en.json b/public/locales/en.json
index 198fcd53..f1b18f4c 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -478,6 +478,12 @@
"duration_seconds": "{{seconds}} sec"
},
"Room": {
+ "delivery": {
+ "sending": "Sending…",
+ "sent": "Sent",
+ "read": "Read",
+ "failed": "Not sent"
+ },
"drag_to_close": "Drag up to close",
"collapse_avatar": "Collapse avatar",
"expand_avatar": "Open avatar",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 096412eb..fb83ce93 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -484,6 +484,12 @@
"duration_seconds": "{{seconds}} сек"
},
"Room": {
+ "delivery": {
+ "sending": "Отправляется…",
+ "sent": "Отправлено",
+ "read": "Прочитано",
+ "failed": "Не отправлено"
+ },
"drag_to_close": "Потянуть вверх чтобы закрыть",
"collapse_avatar": "Свернуть аватар",
"expand_avatar": "Развернуть аватар",
diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
index 25debac6..5ca274c4 100644
--- a/src/app/components/RenderMessageContent.tsx
+++ b/src/app/components/RenderMessageContent.tsx
@@ -40,7 +40,7 @@ import { testMatrixTo } from '../plugins/matrix-to';
import { IAudioContent, IImageContent, isVoiceMessageContent } from '../../types/matrix/common';
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
// in the timeline; pin-menu / message-search leave it null and fall back
// to the legacy MImage / MVideo Attachment chrome.
diff --git a/src/app/components/message/content/VoiceContent.css.ts b/src/app/components/message/content/VoiceContent.css.ts
index 617e367a..122f65be 100644
--- a/src/app/components/message/content/VoiceContent.css.ts
+++ b/src/app/components/message/content/VoiceContent.css.ts
@@ -23,6 +23,16 @@ export const Row = style({
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 /
// fallback fills it). Strip any container fill folds might apply.
export const AvatarSlot = style({
@@ -37,8 +47,20 @@ export const Bubble = style({
alignItems: 'center',
gap: config.space.S300,
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,
- maxWidth: toRem(400),
+ maxWidth: toRem(331),
+ '@media': {
+ 'screen and (min-width: 600px)': {
+ flexBasis: toRem(280),
+ maxWidth: toRem(400),
+ },
+ },
// Fixed height so own and other are pixel-identical regardless of fill.
height: toRem(56),
boxSizing: 'border-box',
diff --git a/src/app/components/message/content/VoiceContent.tsx b/src/app/components/message/content/VoiceContent.tsx
index cb9afad7..b95a1994 100644
--- a/src/app/components/message/content/VoiceContent.tsx
+++ b/src/app/components/message/content/VoiceContent.tsx
@@ -128,7 +128,13 @@ export function VoiceContent({
const displayTime = playing || currentTime > 0 ? currentTime : duration;
return (
-
;
+
+// 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),
+});
diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx
new file mode 100644
index 00000000..43f3c681
--- /dev/null
+++ b/src/app/components/message/layout/Bubble.tsx
@@ -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?: 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 (the tap-rail floats at the tap
+// point, positioned by ); 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 = (
+
{children}
+ );
+
+ let body: ReactNode;
+ if (editing) {
+ // The child is already a composer card; render it full width, no chrome.
+ body =
{children}
;
+ } 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 && {time}}
+ >
+ );
+ } else {
+ // Single text message: timestamp on the inner side (own → left, peer → right).
+ body = (
+
+ {isOwn && time && {time}}
+ {contentCell}
+ {!isOwn && time && {time}}
+
+ );
+ }
+
+ return (
+
+ {body}
+ {threadSummary &&
{threadSummary}
}
+ {reactions &&
{reactions}
}
+ {readStatus &&
{readStatus}
}
+
+ );
+ }
+);
+
+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 (
+
+
+
{content}
+ {time && {time}}
+
+ );
+}
diff --git a/src/app/components/message/layout/index.ts b/src/app/components/message/layout/index.ts
index cc5381bc..daecdd4d 100644
--- a/src/app/components/message/layout/index.ts
+++ b/src/app/components/message/layout/index.ts
@@ -1,4 +1,5 @@
export * from './Modern';
export * from './Stream';
export * from './Channel';
+export * from './Bubble';
export * from './Base';
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index 1daa9ca4..1e186b4e 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -95,6 +95,16 @@ export const MessageBase = recipe({
},
highlight: HighlightVariant,
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: {
space: '400',
diff --git a/src/app/features/bots/BotConversations.tsx b/src/app/features/bots/BotConversations.tsx
index 0b5f959c..afce0d4c 100644
--- a/src/app/features/bots/BotConversations.tsx
+++ b/src/app/features/bots/BotConversations.tsx
@@ -64,6 +64,7 @@ function NewChatComposer({ preset, room }: { preset: BotPreset; room: Room }) {
fileDropContainerRef={fileDropRef}
onSend={handleSend}
textOnly
+ singleRow
/>
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index f1a530f1..c58335a6 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -228,9 +228,14 @@ interface RoomInputProps {
// navigates into the freshly-rooted thread (onSend) — a sticker/file first
// move would otherwise strand the user on the landing.
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(
- ({ editor, fileDropContainerRef, roomId, room, threadId, onSend, textOnly }, ref) => {
+ ({ editor, fileDropContainerRef, roomId, room, threadId, onSend, textOnly, singleRow }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -1030,8 +1035,23 @@ export const RoomInput = forwardRef(
)}
>
}
+ // 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={
- voiceMode ? null : (
+ singleRow || voiceMode ? null : (
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' ).
+// 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({
base: [
@@ -47,8 +144,7 @@ export const JumpToLatestFab = style([
border: 'none',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.28), 0 2px 4px rgba(0, 0, 0, 0.16)',
- transition:
- 'transform 180ms ease-out, opacity 180ms ease-out, background-color 120ms ease',
+ transition: 'transform 180ms ease-out, opacity 180ms ease-out, background-color 120ms ease',
zIndex: 5,
transform: 'scale(1)',
opacity: 1,
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index eb939ec5..b0af31ba 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -27,7 +27,7 @@ import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js';
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 { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
@@ -48,8 +48,6 @@ import {
MSticker,
ImageContent,
EventContent,
- STREAM_MESSAGE_SPACING,
- StreamDayDivider,
CHANNEL_MESSAGE_SPACING,
ChannelDayDivider,
} from '../../components/message';
@@ -111,6 +109,8 @@ import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useRoomUnread } from '../../state/hooks/unread';
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 { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -242,9 +242,11 @@ const PAGINATION_LIMIT = 80;
// room below the last message (timeline paddingBottom + overlay padding) —
// while the user is within this band of the live edge, small scrolls never
// strip the composer.
-const COMPOSER_HIDE_DELTA_PX = 12;
+const COMPOSER_HIDE_DELTA_PX = 8;
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
// `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_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 = new Set();
+
type Timeline = {
linkedTimelines: EventTimeline[];
range: ItemRange;
@@ -604,6 +618,24 @@ export function RoomTimeline({
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
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 spoilerClickHandler = useSpoilerClickHandler();
@@ -627,6 +659,7 @@ export function RoomTimeline({
const [fabHidden, setFabHidden] = useState(true);
const fabHiddenRef = useRef(fabHidden);
fabHiddenRef.current = fabHidden;
+
// 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
// the CSS keyframes restart.
@@ -691,6 +724,18 @@ export function RoomTimeline({
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 } =
useVirtualPaginator({
count: eventsLength,
@@ -1257,6 +1302,76 @@ export function RoomTimeline({
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('[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('[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
// 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
@@ -1503,6 +1618,21 @@ export function RoomTimeline({
return anchors;
}, [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(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>(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>(new Set());
+
const renderMatrixEvent = useMatrixEventRenderer<
// (mEventId, mEvent, item, timelineSet, collapse, railStart, railEnd, railHidden)
[string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean, boolean]
@@ -1530,6 +1660,9 @@ export function RoomTimeline({
const hasReactions = reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
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 getContent = (() =>
@@ -1546,7 +1679,18 @@ export function RoomTimeline({
return (
0;
const { replyEventId, threadRootId } = mEvent;
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 (
@@ -1665,8 +1816,14 @@ export function RoomTimeline({
room,
mEvent,
getEditedEvent(mEventId, mEvent, timelineSet),
- showUrlPreview
+ showUrlPreview,
+ isLatestOwn,
+ showTime,
+ timeBelow
)}
+ isLatestOwn={isLatestOwn}
+ showTime={showTime}
+ timeBelow={timeBelow}
data-message-item={item}
data-message-id={mEventId}
room={room}
@@ -1766,7 +1923,12 @@ export function RoomTimeline({
displayName={senderDisplayName}
senderId={senderId}
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 ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
@@ -1812,11 +1974,25 @@ export function RoomTimeline({
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
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 (
{
const before = new Map();
@@ -2271,26 +2450,87 @@ export function RoomTimeline({
// 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.
+ // 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 prevRenderable: MatrixEvent | undefined;
+ let prevRenderableItem: number | undefined;
let lastHead: number | undefined;
+ let lastRenderableItem: number | undefined;
+ let lastRenderableEv: MatrixEvent | undefined;
+ const hideTime = new Set();
+ // 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();
for (let index = 0; index < items.length; index += 1) {
before.set(items[index], seenBefore);
if (renderableFlags[index]) {
seenBefore = true;
const ev = getTimelineItemEvent(items[index]);
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 =
prevRenderable === undefined || !isStreamRunContinuation(prevRenderable, ev);
if (isHead) lastHead = items[index];
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 [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
@@ -2395,17 +2635,24 @@ export function RoomTimeline({
return timeDayMonYear(mEvent.getTs());
})();
- const renderDayDivider = () => (
-
- {messageLayout === 'channel' ? (
+ const renderDayDivider = () =>
+ messageLayout === 'channel' ? (
+
- ) : (
-
- )}
-
- );
+
+ ) : (
+ // 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.
+
+
+ {dayLabel}
+
+
+ );
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
@@ -2422,9 +2669,14 @@ export function RoomTimeline({
return eventJSX;
};
+ const unreadFloatShown = !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
+
return (
- {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 && (
)}
-
+