vojo/src/app/components/message/layout/Stream.tsx

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>
);
}
);