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

172 lines
6.4 KiB
TypeScript

import React, { type MouseEventHandler, type ReactNode } from 'react';
import classNames from 'classnames';
import { Avatar, Box, Icon, Icons, type IconSrc, as } from 'folds';
import { type Room } from 'matrix-js-sdk';
import * as css from './Channel.css';
import { CHANNEL_AVATAR_PX } from './Channel.css';
import { UserAvatar } from '../../user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc } from '../../../utils/room';
import { mxcUrlToHttp } from '../../../utils/matrix';
// MessageBase recipe `space` variant for channel rows. '300' tightens the
// vertical rhythm vs the '400' Stream uses for bubble rows — channel
// rows have no bubble border, so they don't need as much breathing room.
export const CHANNEL_MESSAGE_SPACING = '300' as const;
export type ChannelLayoutProps = {
// Avatar slot — pass `undefined` for collapsed rows (adjacent same-user)
// so the slot stays a fixed-width spacer and the body column aligns
// across all rows in the cluster.
avatar?: ReactNode;
// Username + time inline. `undefined` on collapsed rows.
header?: ReactNode;
reactions?: ReactNode;
threadSummary?: ReactNode;
// Forwarded onto the row root as `data-own="true"|"false"`. Channels
// main timeline doesn't style off it; the thread-drawer bubble CSS
// reads it to mirror `StreamBubble`'s own-vs-incoming notch corner.
isOwn?: boolean;
// `true` (thread drawer): the header (name + time) renders INSIDE the body
// slot above the content, and the body is a chat bubble — the compact
// in-bubble look.
// `false` (Discord-style main timeline): the header renders as a sibling row
// ABOVE the body (next to the avatar) and the body is plain text (no bubble)
// for everyone — Discord groups don't bubble messages. See `data-bubble`
// below and `Channel.css.ts`.
headerInBubble?: boolean;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
};
// Channels timeline message row primitive. No rail. No bubble. Avatar
// + body two-column flex; body holds header (name + time), content,
// thread-summary, reactions in vertical flow.
export const ChannelLayout = as<'div', ChannelLayoutProps>(
(
{
className,
avatar,
header,
reactions,
threadSummary,
isOwn,
headerInBubble,
onContextMenu,
children,
...props
},
ref
) => (
<div
className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu}
data-own={isOwn ? 'true' : 'false'}
// Bubbles only in the compact in-bubble mode (thread drawer). The Discord
// header-above main timeline renders ALL messages as plain text (no
// bubbles) — own and peer alike.
data-bubble={headerInBubble ? 'true' : undefined}
{...props}
ref={ref}
>
<div className={css.ChannelAvatarSlot}>{avatar}</div>
<div className={css.ChannelBody}>
{!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
<div className={css.ChannelMessageBody}>
{headerInBubble && header && (
<div className={css.ChannelHeader} data-in-bubble="true">
{header}
</div>
)}
{children}
{headerInBubble && threadSummary && (
// Inside-bubble footer: stretches via negative margins to the
// bubble's inner border edge, paints a 1px top divider, and
// hosts the existing thread-summary button as a flush
// full-width chip. Reads as one continuous card with a
// section break instead of two stacked pills.
<div className={css.ChannelBubbleThreadSummary}>{threadSummary}</div>
)}
</div>
{!headerInBubble && threadSummary && (
<div className={css.ChannelThreadSummary}>{threadSummary}</div>
)}
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
</div>
</div>
)
);
export type ChannelDayDividerProps = {
label: ReactNode;
};
// Section break between days in the channels timeline. Horizontal line
// + centered uppercase label, spans full row including the avatar gutter
// so it reads as a structural separator.
export const ChannelDayDivider = as<'div', ChannelDayDividerProps>(
({ className, label, ...props }, ref) => (
<div
className={classNames(css.ChannelDayDividerRoot, className)}
role="separator"
aria-label={typeof label === 'string' ? label : undefined}
{...props}
ref={ref}
>
<span className={css.ChannelDayDividerLine} aria-hidden />
<span className={css.ChannelDayDividerLabel}>{label}</span>
<span className={css.ChannelDayDividerLine} aria-hidden />
</div>
)
);
export type ChannelEventContentProps = {
iconSrc: IconSrc;
content: ReactNode;
};
// Sysline (membership change / room.create / pinned events / etc) row
// for channels. Single-line, indented past the avatar slot so it visually
// belongs to the body column, no rail/timestamp chrome.
export function ChannelEventContent({ iconSrc, content }: ChannelEventContentProps) {
return (
<Box className={css.ChannelSysline} alignItems="Center" gap="200">
<Icon className={css.ChannelSyslineIcon} size="50" src={iconSrc} />
<div className={css.ChannelSyslineBody}>{content}</div>
</Box>
);
}
export type ChannelMessageAvatarProps = {
room: Room;
senderId: string;
senderDisplayName: string;
};
// Resolves the sender avatar (with auth + thumbnail) and renders the
// folds `<Avatar>` + `<UserAvatar>` combination. Lifted out of `Message`
// so the `useMediaAuthentication` / `useMatrixClient` hook calls only run
// when channel layout is selected (Stream rows don't need an avatar).
export function ChannelMessageAvatar({
room,
senderId,
senderDisplayName,
}: ChannelMessageAvatarProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarMxc = getMemberAvatarMxc(room, senderId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<Avatar size="300" style={{ width: CHANNEL_AVATAR_PX, height: CHANNEL_AVATAR_PX }}>
<UserAvatar
userId={senderId}
src={avatarUrl}
alt={senderDisplayName}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
);
}