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:
parent
b56a47db4d
commit
4b7ad11620
25 changed files with 1388 additions and 394 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "Развернуть аватар",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(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.
|
// Fixed height so own and other are pixel-identical regardless of fill.
|
||||||
height: toRem(56),
|
height: toRem(56),
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
209
src/app/components/message/layout/Bubble.css.ts
Normal file
209
src/app/components/message/layout/Bubble.css.ts
Normal 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),
|
||||||
|
});
|
||||||
119
src/app/components/message/layout/Bubble.tsx
Normal file
119
src/app/components/message/layout/Bubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,17 +2635,24 @@ 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} />
|
||||||
) : (
|
</MessageBase>
|
||||||
<StreamDayDivider label={dayLabel} />
|
) : (
|
||||||
)}
|
// Bubble (1:1 DM): a centred, single dark-blue date pill. The row is the
|
||||||
</MessageBase>
|
// 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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
43
src/app/features/room/message/DmReadStatusLine.tsx
Normal file
43
src/app/features/room/message/DmReadStatusLine.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 ? (
|
||||||
<MessageEditor
|
// Edit form styled as the main composer — a single rounded card
|
||||||
style={{
|
// (`ChatComposer` paints the inner Editor with Surface.Container + 32px
|
||||||
maxWidth: '100%',
|
// radius). The bubble drops its own chrome while editing (see
|
||||||
width: '100vw',
|
// BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble.
|
||||||
}}
|
<div className={ChatComposer} style={{ width: '100%' }}>
|
||||||
roomId={room.roomId}
|
<MessageEditor
|
||||||
room={room}
|
style={{ maxWidth: '100%', width: '100%' }}
|
||||||
mEvent={mEvent}
|
roomId={room.roomId}
|
||||||
imagePackRooms={imagePackRooms}
|
room={room}
|
||||||
onCancel={() => onEditId()}
|
mEvent={mEvent}
|
||||||
/>
|
imagePackRooms={imagePackRooms}
|
||||||
|
onCancel={() => onEditId()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|
@ -1040,10 +1149,251 @@ 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;
|
||||||
|
|
||||||
|
// The action rail (react / reply / thread / edit / …). Extracted so the same
|
||||||
|
// bar can render in two places: as a row-anchored overlay for channel/stream
|
||||||
|
// rows, and — for the bubble layout — passed into BubbleLayout so it floats
|
||||||
|
// centred just above the bubble itself.
|
||||||
|
const railVisible =
|
||||||
|
!edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor);
|
||||||
|
// The reaction emoji board, shared by the desktop anchored PopOut and the
|
||||||
|
// mobile centred Overlay (an anchored PopOut drifts off-screen on the small
|
||||||
|
// native viewport — `съезжает` — so mobile centres it instead).
|
||||||
|
const emojiBoardEl = (
|
||||||
|
<EmojiBoard
|
||||||
|
imagePackRooms={imagePackRooms ?? []}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
allowTextCustomEmoji
|
||||||
|
onEmojiSelect={(key) => {
|
||||||
|
const evtId = mEvent.getId();
|
||||||
|
if (evtId) onReactionToggle(evtId, key);
|
||||||
|
setEmojiBoardAnchor(undefined);
|
||||||
|
}}
|
||||||
|
onCustomEmojiSelect={(mxc, shortcode) => {
|
||||||
|
const evtId = mEvent.getId();
|
||||||
|
if (evtId) onReactionToggle(evtId, mxc, shortcode);
|
||||||
|
setEmojiBoardAnchor(undefined);
|
||||||
|
}}
|
||||||
|
requestClose={() => {
|
||||||
|
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
|
||||||
|
onClick={handleOpenEmojiBoard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size={railBtnSize}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!emojiBoardAnchor}
|
||||||
|
>
|
||||||
|
<Icon src={RailIcons.Smile} size={railIconSize} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
))}
|
||||||
|
{!hideMainReplyAffordance && (
|
||||||
|
<IconButton
|
||||||
|
onClick={onReplyClick}
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size={railBtnSize}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={RailIcons.Reply} size={railIconSize} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{!isThreadReply && !hideThreadReplyAffordance && (
|
||||||
|
<IconButton
|
||||||
|
onClick={(ev: Parameters<typeof onReplyClick>[0]) => onReplyClick(ev, true)}
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size={railBtnSize}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={RailIcons.Thread} size={railIconSize} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onEditId(mEvent.getId())}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size={railBtnSize}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={RailIcons.Edit} size={railIconSize} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<PopOut
|
||||||
|
anchor={menuAnchor}
|
||||||
|
position="Bottom"
|
||||||
|
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
||||||
|
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
{canSendReaction && (
|
||||||
|
<MessageQuickReactions
|
||||||
|
onReaction={(key, shortcode) => {
|
||||||
|
const evtId = mEvent.getId();
|
||||||
|
if (evtId) onReactionToggle(evtId, key, shortcode);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||||
|
{canSendReaction && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={RailIcons.Smile} />}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleAddReactions}
|
||||||
|
>
|
||||||
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||||
|
{t('Room.add_reaction')}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{relations && (
|
||||||
|
<MessageAllReactionItem
|
||||||
|
room={room}
|
||||||
|
relations={relations}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hideMainReplyAffordance && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={RailIcons.Reply} />}
|
||||||
|
radii="300"
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
|
||||||
|
onReplyClick(evt);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||||
|
{t('Room.reply')}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{!isThreadReply && !hideThreadReplyAffordance && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon src={RailIcons.Thread} size="100" />}
|
||||||
|
radii="300"
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
|
||||||
|
onReplyClick(evt, true);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||||
|
{t('Room.reply_in_thread')}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={RailIcons.Edit} />}
|
||||||
|
radii="300"
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
onClick={() => {
|
||||||
|
onEditId(mEvent.getId());
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||||
|
{t('Room.edit_message')}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{!hideReadReceipts && (
|
||||||
|
<MessageReadReceiptItem
|
||||||
|
room={room}
|
||||||
|
eventId={mEvent.getId() ?? ''}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDeveloperTools && (
|
||||||
|
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
)}
|
||||||
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
{canPinEvent && (
|
||||||
|
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{((!mEvent.isRedacted() && canDelete) ||
|
||||||
|
mEvent.getSender() !== mx.getUserId()) && (
|
||||||
|
<>
|
||||||
|
<Line size="300" />
|
||||||
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||||
|
{!mEvent.isRedacted() && canDelete && (
|
||||||
|
<MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
)}
|
||||||
|
{mEvent.getSender() !== mx.getUserId() && (
|
||||||
|
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size={railBtnSize}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
aria-pressed={!!menuAnchor}
|
||||||
|
>
|
||||||
|
<Icon src={RailIcons.More} size={railIconSize} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
tabIndex={0}
|
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}
|
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
|
||||||
// Stream rows always render with collapsed marginTop (=0) so the
|
// Stream rows always render with collapsed marginTop (=0) so the
|
||||||
// bubble-to-bubble gap is uniform regardless of same-sender grouping.
|
// bubble-to-bubble gap is uniform regardless of same-sender grouping.
|
||||||
|
|
@ -1055,258 +1405,39 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// ChannelLayout — channel mode keeps the original behaviour.
|
// ChannelLayout — channel mode keeps the original behaviour.
|
||||||
collapse={layout === 'channel' ? collapse : true}
|
collapse={layout === 'channel' ? collapse : true}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
// 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}
|
{...props}
|
||||||
{...hoverProps}
|
{...hoverProps}
|
||||||
{...focusWithinProps}
|
{...focusWithinProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{!edit && (hover || !!menuAnchor || !!emojiBoardAnchor) && (
|
{/* Channel rows anchor the rail to the row's top corner; the bubble layout
|
||||||
<div className={css.MessageOptionsBase}>
|
floats it at the tap point (BubbleTapRail), clamped on-screen. */}
|
||||||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
{!isBubble && railBar && <div className={css.MessageOptionsBase}>{railBar}</div>}
|
||||||
<Box gap={railGap}>
|
{isBubble && railBar && railPoint && (
|
||||||
{canSendReaction && (
|
<BubbleTapRail point={railPoint}>{railBar}</BubbleTapRail>
|
||||||
<PopOut
|
)}
|
||||||
position="Bottom"
|
{/* Mobile: the reaction emoji board renders as a centred Overlay (an
|
||||||
align={emojiBoardAnchor?.width === 0 ? 'Start' : 'End'}
|
anchored PopOut drifts off the small native viewport). */}
|
||||||
offset={emojiBoardAnchor?.width === 0 ? 0 : undefined}
|
{isMobile && canSendReaction && !edit && !!emojiBoardAnchor && (
|
||||||
anchor={emojiBoardAnchor}
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
content={
|
<OverlayCenter>
|
||||||
<EmojiBoard
|
<FocusTrap
|
||||||
imagePackRooms={imagePackRooms ?? []}
|
focusTrapOptions={{
|
||||||
returnFocusOnDeactivate={false}
|
initialFocus: false,
|
||||||
allowTextCustomEmoji
|
clickOutsideDeactivates: true,
|
||||||
onEmojiSelect={(key) => {
|
onDeactivate: () => setEmojiBoardAnchor(undefined),
|
||||||
const evtId = mEvent.getId();
|
escapeDeactivates: stopPropagation,
|
||||||
if (evtId) onReactionToggle(evtId, key);
|
}}
|
||||||
setEmojiBoardAnchor(undefined);
|
>
|
||||||
}}
|
{emojiBoardEl}
|
||||||
onCustomEmojiSelect={(mxc, shortcode) => {
|
</FocusTrap>
|
||||||
const evtId = mEvent.getId();
|
</OverlayCenter>
|
||||||
if (evtId) onReactionToggle(evtId, mxc, shortcode);
|
</Overlay>
|
||||||
setEmojiBoardAnchor(undefined);
|
|
||||||
}}
|
|
||||||
requestClose={() => {
|
|
||||||
setEmojiBoardAnchor(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={handleOpenEmojiBoard}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size={railBtnSize}
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={!!emojiBoardAnchor}
|
|
||||||
>
|
|
||||||
<Icon src={RailIcons.Smile} size={railIconSize} />
|
|
||||||
</IconButton>
|
|
||||||
</PopOut>
|
|
||||||
)}
|
|
||||||
{!hideMainReplyAffordance && (
|
|
||||||
<IconButton
|
|
||||||
onClick={onReplyClick}
|
|
||||||
data-event-id={mEvent.getId()}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size={railBtnSize}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Icon src={RailIcons.Reply} size={railIconSize} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{!isThreadReply && !hideThreadReplyAffordance && (
|
|
||||||
<IconButton
|
|
||||||
onClick={(ev: Parameters<typeof onReplyClick>[0]) => onReplyClick(ev, true)}
|
|
||||||
data-event-id={mEvent.getId()}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size={railBtnSize}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Icon src={RailIcons.Thread} size={railIconSize} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{canEditEvent(mx, mEvent) && onEditId && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => onEditId(mEvent.getId())}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size={railBtnSize}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Icon src={RailIcons.Edit} size={railIconSize} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<PopOut
|
|
||||||
anchor={menuAnchor}
|
|
||||||
position="Bottom"
|
|
||||||
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
|
|
||||||
offset={menuAnchor?.width === 0 ? 0 : undefined}
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setMenuAnchor(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
{canSendReaction && (
|
|
||||||
<MessageQuickReactions
|
|
||||||
onReaction={(key, shortcode) => {
|
|
||||||
const evtId = mEvent.getId();
|
|
||||||
if (evtId) onReactionToggle(evtId, key, shortcode);
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
|
||||||
{canSendReaction && (
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={RailIcons.Smile} />}
|
|
||||||
radii="300"
|
|
||||||
onClick={handleAddReactions}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={css.MessageMenuItemText}
|
|
||||||
as="span"
|
|
||||||
size="T300"
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{t('Room.add_reaction')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{relations && (
|
|
||||||
<MessageAllReactionItem
|
|
||||||
room={room}
|
|
||||||
relations={relations}
|
|
||||||
onClose={closeMenu}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hideMainReplyAffordance && (
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={RailIcons.Reply} />}
|
|
||||||
radii="300"
|
|
||||||
data-event-id={mEvent.getId()}
|
|
||||||
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
|
|
||||||
onReplyClick(evt);
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={css.MessageMenuItemText}
|
|
||||||
as="span"
|
|
||||||
size="T300"
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{t('Room.reply')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{!isThreadReply && !hideThreadReplyAffordance && (
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
after={<Icon src={RailIcons.Thread} size="100" />}
|
|
||||||
radii="300"
|
|
||||||
data-event-id={mEvent.getId()}
|
|
||||||
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
|
|
||||||
onReplyClick(evt, true);
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={css.MessageMenuItemText}
|
|
||||||
as="span"
|
|
||||||
size="T300"
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{t('Room.reply_in_thread')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{canEditEvent(mx, mEvent) && onEditId && (
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={RailIcons.Edit} />}
|
|
||||||
radii="300"
|
|
||||||
data-event-id={mEvent.getId()}
|
|
||||||
onClick={() => {
|
|
||||||
onEditId(mEvent.getId());
|
|
||||||
closeMenu();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={css.MessageMenuItemText}
|
|
||||||
as="span"
|
|
||||||
size="T300"
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{t('Room.edit_message')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{!hideReadReceipts && (
|
|
||||||
<MessageReadReceiptItem
|
|
||||||
room={room}
|
|
||||||
eventId={mEvent.getId() ?? ''}
|
|
||||||
onClose={closeMenu}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showDeveloperTools && (
|
|
||||||
<MessageSourceCodeItem
|
|
||||||
room={room}
|
|
||||||
mEvent={mEvent}
|
|
||||||
onClose={closeMenu}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
|
||||||
{canPinEvent && (
|
|
||||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{((!mEvent.isRedacted() && canDelete) ||
|
|
||||||
mEvent.getSender() !== mx.getUserId()) && (
|
|
||||||
<>
|
|
||||||
<Line size="300" />
|
|
||||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
|
||||||
{!mEvent.isRedacted() && canDelete && (
|
|
||||||
<MessageDeleteItem
|
|
||||||
room={room}
|
|
||||||
mEvent={mEvent}
|
|
||||||
onClose={closeMenu}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{mEvent.getSender() !== mx.getUserId() && (
|
|
||||||
<MessageReportItem
|
|
||||||
room={room}
|
|
||||||
mEvent={mEvent}
|
|
||||||
onClose={closeMenu}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size={railBtnSize}
|
|
||||||
radii="300"
|
|
||||||
onClick={handleOpenMenu}
|
|
||||||
aria-pressed={!!menuAnchor}
|
|
||||||
>
|
|
||||||
<Icon src={RailIcons.More} size={railIconSize} />
|
|
||||||
</IconButton>
|
|
||||||
</PopOut>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
9
src/app/state/room/openMessage.ts
Normal file
9
src/app/state/room/openMessage.ts
Normal 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);
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue