163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
import React, { ReactNode, useImperativeHandle, useRef } from 'react';
|
|
import classNames from 'classnames';
|
|
import { as } from 'folds';
|
|
import * as css from './layout.css';
|
|
import { useStreamLayoutDebug } from './streamDebug';
|
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
|
|
|
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
|
// layout.css.ts (StreamRailBridgeY = S400) always match the gap between rows.
|
|
// All three Stream call sites — RoomTimeline.tsx StreamDayDivider wrapper plus
|
|
// Message.tsx Message / Event MessageBase — share this single constant.
|
|
export const STREAM_MESSAGE_SPACING = '400' as const;
|
|
|
|
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
|
|
//
|
|
// Visual structure:
|
|
// ┌──┬─────────────────────────────────────────────────┐
|
|
// │ │ bubble (asymmetric radius) │
|
|
// │● │ ┌──────────────────────────────────────────┐ │
|
|
// │ │ │ time · sender · e2ee chip (header line) │ │
|
|
// │ │ │ message body │ │
|
|
// │ │ └──────────────────────────────────────────┘ │
|
|
// └──┴─────────────────────────────────────────────────┘
|
|
// rail+dot bubble (time absolutely-positioned to the
|
|
// LEFT of the bubble, on the rail-time column)
|
|
//
|
|
// Read-state lives entirely on the dot now (own = Primary violet, opacity
|
|
// 0.3 → 1.0 when peer reads; incoming = author hash, opacity 1.0 → 0.3 once
|
|
// I read it). The legacy WhatsApp checkmark `<MessageStatus>` is intentionally
|
|
// not rendered in Stream — the dot already encodes that signal.
|
|
//
|
|
// Geometry constants live in layout.css.ts (StreamRailWidth, StreamDotSize).
|
|
// `time` and `header` are caller-controlled ReactNodes so Message.tsx keeps
|
|
// ownership of the timestamp formatting / sender Username component / e2ee
|
|
// indicators it already builds.
|
|
|
|
export type StreamLayoutProps = {
|
|
time?: ReactNode;
|
|
dotColor: string;
|
|
dotOpacity: number;
|
|
isOwn?: boolean;
|
|
compact?: boolean;
|
|
header?: ReactNode;
|
|
railStart?: boolean;
|
|
railEnd?: boolean;
|
|
};
|
|
|
|
// Stream day divider — used by RoomTimeline.tsx in place of the legacy
|
|
// TimelineDivider for DM rooms. Sits as a regular row in the timeline so the
|
|
// rail flows through it: rail-column has the date label (small mono uppercase),
|
|
// a slightly larger Fleet-soft dot anchors on the rail, and the rest of the
|
|
// row is a faint hairline. Matches stream-v2-dawn.jsx::DawnPhoneV3 line 73-78.
|
|
export type StreamDayDividerProps = {
|
|
label: ReactNode;
|
|
};
|
|
|
|
export const StreamDayDivider = as<'div', StreamDayDividerProps>(
|
|
({ className, label, ...props }, ref) => {
|
|
// Reads screen size internally instead of taking a prop so RoomTimeline
|
|
// doesn't have to thread it through. The day-divider must use the SAME
|
|
// `compact` value as message rows above and below or the rail and label
|
|
// would mis-align horizontally on desktop.
|
|
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
|
return (
|
|
<div
|
|
className={classNames(css.StreamDayRoot({ compact }), className)}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
{/* Rail segment under the row — same recipe as message rows so the
|
|
rail flows continuously through the day boundary. */}
|
|
<span className={css.StreamRail} aria-hidden />
|
|
<div className={css.StreamDayLabel}>{label}</div>
|
|
<span className={css.StreamDayDot} aria-hidden />
|
|
<div className={css.StreamDayLine} aria-hidden />
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
export const StreamLayout = as<'div', StreamLayoutProps>(
|
|
(
|
|
{
|
|
className,
|
|
time,
|
|
dotColor,
|
|
dotOpacity,
|
|
isOwn,
|
|
compact,
|
|
header,
|
|
railStart,
|
|
railEnd,
|
|
children,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const timeRef = useRef<HTMLSpanElement>(null);
|
|
const railRef = useRef<HTMLSpanElement>(null);
|
|
const dotRef = useRef<HTMLSpanElement>(null);
|
|
const bubbleRef = useRef<HTMLDivElement>(null);
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
|
|
|
|
// Debug helper is dev-only and behind a localStorage opt-in (see
|
|
// streamDebug.ts). After P3c every timeline row goes through StreamLayout,
|
|
// so `active` is unconditionally true here.
|
|
useStreamLayoutDebug(
|
|
'message',
|
|
{
|
|
root: rootRef,
|
|
timeColumn: timeRef,
|
|
rail: railRef,
|
|
dot: dotRef,
|
|
content: bubbleRef,
|
|
header: headerRef,
|
|
},
|
|
true
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={classNames(css.StreamRoot({ compact: !!compact }), className)}
|
|
{...props}
|
|
ref={rootRef}
|
|
>
|
|
<span
|
|
className={classNames(
|
|
css.StreamRail,
|
|
railStart && railEnd && css.StreamRailSingle,
|
|
railStart && !railEnd && css.StreamRailStart,
|
|
railEnd && !railStart && css.StreamRailEnd
|
|
)}
|
|
aria-hidden
|
|
ref={railRef}
|
|
/>
|
|
<div className={css.StreamBubble({ own: !!isOwn, compact: !!compact })} ref={bubbleRef}>
|
|
<span className={css.StreamHeaderTime} ref={timeRef}>
|
|
{time}
|
|
</span>
|
|
<span
|
|
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
|
|
aria-hidden
|
|
ref={dotRef}
|
|
>
|
|
<span
|
|
className={css.StreamDotFill}
|
|
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
|
|
/>
|
|
</span>
|
|
{header && (
|
|
<div className={css.StreamBubbleHeader} ref={headerRef}>
|
|
{header}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|