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 ( -
+
{!hideAvatar && ( ( - ({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => ( + ({ className, highlight, selected, collapse, autoCollapse, space, bubble, ...props }, ref) => (
; + +// 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.
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 && ( )} - +
} - +