feat(channels): ship M3 channel timeline avatar-name layout with thread summary cards and drawer header counter polish
This commit is contained in:
parent
a2ee725e4b
commit
e84c4da093
14 changed files with 746 additions and 49 deletions
|
|
@ -509,12 +509,17 @@
|
|||
"empty_message": "Empty message",
|
||||
"edited": " (edited)",
|
||||
|
||||
"thread_in_channel": "Thread in #{{channel}}",
|
||||
"thread_caption": "Thread",
|
||||
"thread_in_channel_subtitle": "in #{{channel}}",
|
||||
"thread_close": "Close thread",
|
||||
"thread_no_replies": "No one has replied yet",
|
||||
"thread_root_error": "Could not load the original message",
|
||||
"thread_paginate_error": "Could not load replies",
|
||||
"thread_retry": "Retry",
|
||||
"thread_summary_count_one": "{{count}} reply",
|
||||
"thread_summary_count_other": "{{count}} replies",
|
||||
"thread_summary_open_thread": "Open thread",
|
||||
"thread_summary_last_reply_by": "last reply from {{name}}",
|
||||
"no_post_permission": "You do not have permission to post in this room",
|
||||
|
||||
"conversation_beginning": "This is the beginning of conversation.",
|
||||
|
|
|
|||
|
|
@ -513,12 +513,19 @@
|
|||
"empty_message": "Пустое сообщение",
|
||||
"edited": " (изменено)",
|
||||
|
||||
"thread_in_channel": "Тред в #{{channel}}",
|
||||
"thread_caption": "Тред",
|
||||
"thread_in_channel_subtitle": "в #{{channel}}",
|
||||
"thread_close": "Закрыть тред",
|
||||
"thread_no_replies": "Никто пока не ответил",
|
||||
"thread_root_error": "Не удалось загрузить исходное сообщение",
|
||||
"thread_paginate_error": "Не удалось загрузить ответы",
|
||||
"thread_retry": "Повторить",
|
||||
"thread_summary_count_one": "{{count}} ответ",
|
||||
"thread_summary_count_few": "{{count}} ответа",
|
||||
"thread_summary_count_many": "{{count}} ответов",
|
||||
"thread_summary_count_other": "{{count}} ответа",
|
||||
"thread_summary_open_thread": "Открыть тред",
|
||||
"thread_summary_last_reply_by": "последний ответ от {{name}}",
|
||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||
|
||||
"conversation_beginning": "Начало переписки.",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Box, Icon, IconSrc, color } from 'folds';
|
|||
import React, { ReactNode, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as layoutCss from '../layout/layout.css';
|
||||
import { ChannelEventContent } from '../layout/Channel';
|
||||
import { useStreamLayoutDebug } from '../layout/streamDebug';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
|
||||
|
|
@ -11,8 +12,19 @@ export type EventContentProps = {
|
|||
content: ReactNode;
|
||||
railStart?: boolean;
|
||||
railEnd?: boolean;
|
||||
// M3: pick rendering. `'stream'` = 3-track rail/dot grid (DM/Bots).
|
||||
// `'channel'` = no-rail flex row (channels timeline). Time slot is
|
||||
// dropped for channels — syslines render compact without timestamp.
|
||||
layout?: 'stream' | 'channel';
|
||||
};
|
||||
export function EventContent({ time, iconSrc, content, railStart, railEnd }: EventContentProps) {
|
||||
export function EventContent({
|
||||
time,
|
||||
iconSrc,
|
||||
content,
|
||||
railStart,
|
||||
railEnd,
|
||||
layout = 'stream',
|
||||
}: EventContentProps) {
|
||||
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const timeRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -32,6 +44,10 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
|
|||
true
|
||||
);
|
||||
|
||||
if (layout === 'channel') {
|
||||
return <ChannelEventContent iconSrc={iconSrc} content={content} />;
|
||||
}
|
||||
|
||||
// Sysline = thin one-line state-event row that lives ON the rail.
|
||||
// Same 3-track grid as message rows (StreamRoot) — track 1 timestamp,
|
||||
// track 2 dot column, track 3 body — so the dot's X aligns with the
|
||||
|
|
|
|||
123
src/app/components/message/layout/Channel.css.ts
Normal file
123
src/app/components/message/layout/Channel.css.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
|
||||
// for visual weight matching the channels mockup. Consumers override
|
||||
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX`
|
||||
// constant keeps the CSS slot width and the inline override in sync.
|
||||
export const CHANNEL_AVATAR_PX = 36;
|
||||
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
|
||||
|
||||
export const ChannelRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: config.space.S300,
|
||||
paddingLeft: config.space.S400,
|
||||
paddingRight: config.space.S400,
|
||||
paddingTop: config.space.S100,
|
||||
paddingBottom: config.space.S100,
|
||||
minWidth: 0,
|
||||
// Hover bg subtle so adjacent rows still read as distinct units even
|
||||
// without bubble borders. `@media (hover: hover)` keeps this inert on
|
||||
// touch where there's no pointer to follow.
|
||||
'@media': {
|
||||
'(hover: hover) and (pointer: fine)': {
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Fixed-width slot keeps the body column aligned across collapsed rows
|
||||
// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`,
|
||||
// so the body's left edge stays put).
|
||||
export const ChannelAvatarSlot = style({
|
||||
width: ChannelAvatarWidth,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: toRem(2),
|
||||
});
|
||||
|
||||
export const ChannelBody = style({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
});
|
||||
|
||||
export const ChannelHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: config.space.S200,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const ChannelMessageBody = style({
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const ChannelReactions = style({
|
||||
minWidth: 0,
|
||||
marginTop: config.space.S100,
|
||||
});
|
||||
|
||||
export const ChannelThreadSummary = style({
|
||||
minWidth: 0,
|
||||
marginTop: config.space.S100,
|
||||
});
|
||||
|
||||
// Horizontal line + centered label. Spans the full row width including
|
||||
// the avatar slot so the line reads as a section break, not a per-message
|
||||
// chrome element.
|
||||
export const ChannelDayDividerRoot = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S300,
|
||||
paddingLeft: config.space.S400,
|
||||
paddingRight: config.space.S400,
|
||||
paddingTop: config.space.S400,
|
||||
paddingBottom: config.space.S400,
|
||||
});
|
||||
|
||||
export const ChannelDayDividerLine = style({
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
});
|
||||
|
||||
export const ChannelDayDividerLabel = style({
|
||||
fontSize: toRem(11),
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.7,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
// Sysline (membership / room.create / pinned-events). Compact single
|
||||
// row aligned with the body gutter — the sysline sits where messages'
|
||||
// body would, indented past the avatar slot, so the column reads
|
||||
// continuous.
|
||||
export const ChannelSysline = style({
|
||||
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S400})`,
|
||||
paddingRight: config.space.S400,
|
||||
paddingTop: config.space.S100,
|
||||
paddingBottom: config.space.S100,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
});
|
||||
|
||||
export const ChannelSyslineIcon = style({
|
||||
flexShrink: 0,
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
export const ChannelSyslineBody = style({
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
});
|
||||
123
src/app/components/message/layout/Channel.tsx
Normal file
123
src/app/components/message/layout/Channel.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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;
|
||||
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, onContextMenu, children, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
className={classNames(css.ChannelRow, className)}
|
||||
onContextMenu={onContextMenu}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={css.ChannelAvatarSlot}>{avatar}</div>
|
||||
<div className={css.ChannelBody}>
|
||||
{header && <div className={css.ChannelHeader}>{header}</div>}
|
||||
<div className={css.ChannelMessageBody}>{children}</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,6 +43,11 @@ export type StreamLayoutProps = {
|
|||
// column — outside the bubble's bg/border so reactions read as
|
||||
// floating chips on the page background, not as a part of the bubble.
|
||||
reactions?: ReactNode;
|
||||
// M3a: thread summary pill rendered BETWEEN the bubble and reactions
|
||||
// for thread roots in channels-mode. Same column as reactions so it
|
||||
// aligns with the bubble's left edge. Optional — caller passes
|
||||
// `undefined` outside channels-mode or when the root has no replies.
|
||||
threadSummary?: ReactNode;
|
||||
};
|
||||
|
||||
export type StreamDayDividerProps = {
|
||||
|
|
@ -101,6 +106,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
railEnd,
|
||||
mediaMode,
|
||||
reactions,
|
||||
threadSummary,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
|
|
@ -174,6 +180,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
)}
|
||||
{children}
|
||||
</div>
|
||||
{threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>}
|
||||
{reactions && <div className={css.StreamReactions}>{reactions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './Modern';
|
||||
export * from './Stream';
|
||||
export * from './Channel';
|
||||
export * from './Base';
|
||||
|
|
|
|||
|
|
@ -402,6 +402,15 @@ export const StreamReactions = style({
|
|||
minWidth: 0,
|
||||
});
|
||||
|
||||
// M3a: thread summary pill slot, sits between bubble and reactions in
|
||||
// the same column. The card itself owns its margins/spacing — the
|
||||
// wrapper just contributes `align-items: flex-start` from StreamColumn
|
||||
// so the pill aligns to the bubble's left edge.
|
||||
export const StreamThreadSummary = style({
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const StreamBubble = recipe({
|
||||
base: {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ import {
|
|||
EventContent,
|
||||
STREAM_MESSAGE_SPACING,
|
||||
StreamDayDivider,
|
||||
CHANNEL_MESSAGE_SPACING,
|
||||
ChannelDayDivider,
|
||||
} from '../../components/message';
|
||||
import {
|
||||
factoryRenderLinkifyWithMention,
|
||||
|
|
@ -87,6 +89,7 @@ import { useSetting } from '../../state/hooks/settings';
|
|||
import { settingsAtom } from '../../state/settings';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||
import { ThreadSummaryCard } from './ThreadSummaryCard';
|
||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||
import { RoomIntro } from '../../components/room-intro';
|
||||
|
|
@ -490,6 +493,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
// nowhere to lead. Pre-M2 the button existed in those surfaces but
|
||||
// never produced a visible thread.
|
||||
const hideThreadReplyAffordance = !channelsMode || isBridged;
|
||||
// M3a: thread summary pill rides a tighter gate — non-bridged channels
|
||||
// are the only surface where threads are first-class. The card itself
|
||||
// returns null when the root has no replies, so mounting on every
|
||||
// visible row in channels-mode is safe (`useVirtualPaginator` keeps
|
||||
// the rendered window bounded; SDK Room maxListeners is 100).
|
||||
const showThreadSummary = channelsMode && !isBridged;
|
||||
// M3: channels timeline uses avatar-first ChannelLayout (no rail, no
|
||||
// bubble). DM/Bots stay on Stream rail. Bridged Telegram channels use
|
||||
// Channel layout too — visually consistent with native channels — only
|
||||
// the thread plus / cards differ (bridge has no thread semantic).
|
||||
const messageLayout: 'stream' | 'channel' = channelsMode ? 'channel' : 'stream';
|
||||
// M2: when the thread drawer is open, the channel composer is
|
||||
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
|
||||
// the channel timeline's «Reply» menu would write a reply chip into
|
||||
|
|
@ -1304,6 +1318,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||
threadSummary={
|
||||
showThreadSummary ? (
|
||||
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||
) : undefined
|
||||
}
|
||||
layout={messageLayout}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
|
|
@ -1407,6 +1427,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||
threadSummary={
|
||||
showThreadSummary ? (
|
||||
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||
) : undefined
|
||||
}
|
||||
layout={messageLayout}
|
||||
>
|
||||
{(() => {
|
||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||
|
|
@ -1521,6 +1547,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
streamRailEnd={streamRailEnd}
|
||||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||
threadSummary={
|
||||
showThreadSummary ? (
|
||||
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||
) : undefined
|
||||
}
|
||||
layout={messageLayout}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
|
|
@ -1584,6 +1616,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={iconSrc}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1632,6 +1665,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={Icons.Hash}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1681,6 +1715,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={Icons.Hash}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1730,6 +1765,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={Icons.Hash}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1787,6 +1823,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1833,6 +1870,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={Icons.Code}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1881,6 +1919,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
iconSrc={Icons.Code}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -2092,8 +2131,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
})();
|
||||
|
||||
const renderDayDivider = () => (
|
||||
<MessageBase space={STREAM_MESSAGE_SPACING}>
|
||||
<StreamDayDivider label={dayLabel} />
|
||||
<MessageBase
|
||||
space={messageLayout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
|
||||
>
|
||||
{messageLayout === 'channel' ? (
|
||||
<ChannelDayDivider label={dayLabel} />
|
||||
) : (
|
||||
<StreamDayDivider label={dayLabel} />
|
||||
)}
|
||||
</MessageBase>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,51 @@ export const ThreadDivider = style({
|
|||
flexShrink: 0,
|
||||
});
|
||||
|
||||
// Two-line drawer header caption + subtitle (M3 polish). The caption is
|
||||
// uppercase tracking-wide ALL-CAPS «ТРЕД», the subtitle is the channel
|
||||
// reference «в #channel».
|
||||
export const ThreadDrawerHeaderCaption = style({
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
fontWeight: 600,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.6,
|
||||
});
|
||||
|
||||
export const ThreadDrawerHeaderSubtitle = style({
|
||||
color: color.Surface.OnContainer,
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
// Counter row between the root and replies: dot + «N ОТВЕТОВ» uppercase
|
||||
// label. Replaces the static 1-px divider when at least one reply exists.
|
||||
export const ThreadCounterRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
padding: `${config.space.S100} ${config.space.S400}`,
|
||||
borderTop: `1px solid ${color.Surface.ContainerLine}`,
|
||||
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const ThreadCounterDot = style({
|
||||
flexShrink: 0,
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Primary.Main,
|
||||
});
|
||||
|
||||
export const ThreadCounterText = style({
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
export const ThreadComposer = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S400} ${config.space.S400}`,
|
||||
|
|
|
|||
|
|
@ -149,21 +149,21 @@ function ThreadEventCard({
|
|||
return (
|
||||
<div className={css.ThreadEventCard}>
|
||||
<div className={css.ThreadEventAvatar}>
|
||||
<Avatar size="200">
|
||||
<Avatar size="300" style={{ width: '32px', height: '32px' }}>
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={senderDisplayName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className={css.ThreadEventBody}>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="T300">
|
||||
<Text size="T400">
|
||||
<b>{senderDisplayName}</b>
|
||||
</Text>
|
||||
<Time ts={mEvent.getTs()} compact />
|
||||
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
|
||||
</Box>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent
|
||||
|
|
@ -781,7 +781,24 @@ export function ThreadDrawer({
|
|||
mediaAutoLoad={mediaAutoLoad}
|
||||
showUrlPreview={showUrlPreview}
|
||||
/>
|
||||
<div className={css.ThreadDivider} />
|
||||
{(() => {
|
||||
// Counter prefers the larger of materialized `thread.length`
|
||||
// (replyCount + pending) and loaded `replies.length` — covers
|
||||
// the cold-load window where the SDK can synthesize an empty
|
||||
// Thread (length=0) shortly before the relations fetch lands
|
||||
// a populated `replies` list (`??` would have collapsed to 0
|
||||
// and silently shown the divider despite present replies).
|
||||
const counterCount = Math.max(thread?.length ?? 0, replies.length);
|
||||
if (counterCount === 0) return <div className={css.ThreadDivider} />;
|
||||
return (
|
||||
<div className={css.ThreadCounterRow} aria-hidden>
|
||||
<span className={css.ThreadCounterDot} />
|
||||
<span className={css.ThreadCounterText}>
|
||||
{t('Room.thread_summary_count', { count: counterCount })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{coldLoadError && (
|
||||
<div className={css.ThreadErrorState}>
|
||||
<Text size="T300" align="Center" priority="400">
|
||||
|
|
@ -857,10 +874,12 @@ export function ThreadDrawer({
|
|||
>
|
||||
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200" id={headerId}>
|
||||
<Icon src={Icons.Hash} size="100" />
|
||||
<Text size="H5" truncate>
|
||||
{t('Room.thread_in_channel', { channel: room.name ?? '' })}
|
||||
<Box grow="Yes" direction="Column" id={headerId}>
|
||||
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
|
||||
{t('Room.thread_caption')}
|
||||
</Text>
|
||||
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
|
||||
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
|
|
|
|||
72
src/app/features/room/ThreadSummaryCard.css.ts
Normal file
72
src/app/features/room/ThreadSummaryCard.css.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// Compact dark pill rendered inside `ChannelLayout.threadSummary` slot
|
||||
// (between the message body and reactions). Click target is the whole
|
||||
// pill — no inner focusable elements, single tab stop.
|
||||
//
|
||||
// Visual: dot indicator + bold accent count + bullet + time + chevron.
|
||||
// Bg uses the SurfaceVariant elevation chain (Container → ContainerHover
|
||||
// → ContainerActive on hover/focus) so the pill reads as a tappable
|
||||
// raised target against both the canvas and the row's own hover bg.
|
||||
export const Root = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R400,
|
||||
border: 'none',
|
||||
// One step above `SurfaceVariant.Container` so the pill reads as a
|
||||
// tappable, raised target against both the canvas (`Surface.Container`)
|
||||
// and the row's hover bg.
|
||||
background: color.SurfaceVariant.ContainerHover,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
cursor: 'pointer',
|
||||
maxWidth: toRem(540),
|
||||
minWidth: 0,
|
||||
textAlign: 'left',
|
||||
appearance: 'none',
|
||||
font: 'inherit',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
|
||||
outlineOffset: toRem(1),
|
||||
backgroundColor: color.SurfaceVariant.ContainerActive,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6px dot indicator. Filled `Primary.Main` — on M4 will flip to
|
||||
// `OnContainerLine` for read threads, kept static on M3.
|
||||
export const Dot = style({
|
||||
flexShrink: 0,
|
||||
width: toRem(6),
|
||||
height: toRem(6),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Primary.Main,
|
||||
});
|
||||
|
||||
export const Count = style({
|
||||
flexShrink: 0,
|
||||
color: color.Primary.Main,
|
||||
});
|
||||
|
||||
export const Separator = style({
|
||||
flexShrink: 0,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
export const TimeText = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const Chevron = style({
|
||||
flexShrink: 0,
|
||||
marginLeft: config.space.S100,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.5,
|
||||
});
|
||||
164
src/app/features/room/ThreadSummaryCard.tsx
Normal file
164
src/app/features/room/ThreadSummaryCard.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import { Icon, Icons, Text, as } from 'folds';
|
||||
import {
|
||||
type IThreadBundledRelationship,
|
||||
type MatrixEvent,
|
||||
RelationType,
|
||||
type Room,
|
||||
type Thread,
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
|
||||
import * as css from './ThreadSummaryCard.css';
|
||||
import { Time } from '../../components/message';
|
||||
import { getChannelsThreadPath } from '../../pages/pathUtils';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
|
||||
export type ThreadSummaryCardProps = {
|
||||
room: Room;
|
||||
rootEvent: MatrixEvent;
|
||||
};
|
||||
|
||||
// Compact dark-pill summary rendered under thread roots in the channels
|
||||
// timeline. Shows: dot indicator + reply count (bold, Primary.Main) +
|
||||
// last reply time + chevron. Click navigates to
|
||||
// `/channels/<space>/<room>/thread/<rootId>/` (M2 drawer).
|
||||
//
|
||||
// State model: tracks the SDK `Thread` object (created lazily on first
|
||||
// reply, fired via `ThreadEvent.New` on the room) and re-renders on
|
||||
// `ThreadEvent.Update` / `NewReply` for live count and time updates.
|
||||
// Falls back to `mEvent.getServerAggregatedRelation('m.thread')` count
|
||||
// + bundled `latest_event.origin_server_ts` for cold-load before the
|
||||
// Thread is materialized.
|
||||
//
|
||||
// Returns `null` when there are no replies — mounting on every channel
|
||||
// root is fine because of viewport virtualization in `RoomTimeline`.
|
||||
export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
|
||||
({ room, rootEvent, className, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceIdOrAlias, roomIdOrAlias } = useParams();
|
||||
const rootId = rootEvent.getId();
|
||||
|
||||
const [thread, setThread] = useState<Thread | null>(() =>
|
||||
rootId ? rootEvent.getThread() ?? room.getThread(rootId) ?? null : null
|
||||
);
|
||||
|
||||
// Catch the moment SDK promotes the root into a Thread (first reply
|
||||
// arrives or relations come in via cold-load fetch). `ThreadEvent.New`
|
||||
// fires on `Room` (not Thread). Listener is one-shot — once we match
|
||||
// our rootId we detach so unrelated thread births in the same room
|
||||
// don't keep waking this row up. Skipping attach entirely when we
|
||||
// already have a thread keeps non-null instances listener-free.
|
||||
useEffect(() => {
|
||||
if (!rootId || thread) return undefined;
|
||||
const handleNew = (newThread: Thread) => {
|
||||
if (newThread.id === rootId) {
|
||||
setThread(newThread);
|
||||
room.off(ThreadEvent.New, handleNew);
|
||||
}
|
||||
};
|
||||
room.on(ThreadEvent.New, handleNew);
|
||||
// Race re-check: SDK may have created the Thread between the lazy
|
||||
// useState initializer and this effect mount. Cheap insurance —
|
||||
// mirrors the cold-load repair pattern in ThreadDrawer.tsx.
|
||||
const existing = room.getThread(rootId);
|
||||
if (existing) {
|
||||
setThread(existing);
|
||||
room.off(ThreadEvent.New, handleNew);
|
||||
return undefined;
|
||||
}
|
||||
return () => {
|
||||
room.off(ThreadEvent.New, handleNew);
|
||||
};
|
||||
}, [room, rootId, thread]);
|
||||
|
||||
// Force a re-render on thread metadata changes. `ThreadEvent.NewReply`
|
||||
// is intentionally NOT subscribed: SDK emits Update right after every
|
||||
// NewReply (`models/thread.js::addEvent` → `updateThreadMetadata`), so
|
||||
// listening on both fires the tick twice per arrival. Capture the
|
||||
// emitter ref inside the effect for cleanup leak-safety against rare
|
||||
// snapshot replacements after timeline reset.
|
||||
const [, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!thread) return undefined;
|
||||
const localThread = thread;
|
||||
const tick = () => setVersion((v) => v + 1);
|
||||
localThread.on(ThreadEvent.Update, tick);
|
||||
return () => {
|
||||
localThread.off(ThreadEvent.Update, tick);
|
||||
};
|
||||
}, [thread]);
|
||||
|
||||
const bundled = rootId
|
||||
? rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread)
|
||||
: undefined;
|
||||
|
||||
// When a Thread is materialized, trust its length even if it's 0 —
|
||||
// bundled aggregations don't shrink on redaction so they'd otherwise
|
||||
// surface stale counts on threads where every reply was redacted.
|
||||
// Bundled is the cold-load fallback only.
|
||||
const count = thread !== null ? thread.length : bundled?.count ?? 0;
|
||||
|
||||
if (!rootId || count === 0) return null;
|
||||
|
||||
const latestReply: MatrixEvent | undefined = thread?.replyToEvent ?? undefined;
|
||||
// Time source: prefer live Thread.replyToEvent timestamp; fall back to
|
||||
// bundled aggregation's `latest_event.origin_server_ts` so cold-loaded
|
||||
// cards still show a time before the SDK materializes the Thread.
|
||||
const lastTs =
|
||||
latestReply?.getTs() ??
|
||||
(typeof bundled?.latest_event?.origin_server_ts === 'number'
|
||||
? bundled.latest_event.origin_server_ts
|
||||
: undefined);
|
||||
|
||||
// Last-sender display name kept for screen-reader aria-label only —
|
||||
// visual surface is dot + count + time + chevron per current mockup.
|
||||
const senderId = latestReply?.getSender();
|
||||
const senderDisplayName = senderId
|
||||
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
||||
: null;
|
||||
|
||||
const countLabel = t('Room.thread_summary_count', { count });
|
||||
const ariaParts = [t('Room.thread_summary_open_thread'), countLabel];
|
||||
if (senderDisplayName) {
|
||||
ariaParts.push(t('Room.thread_summary_last_reply_by', { name: senderDisplayName }));
|
||||
}
|
||||
const ariaLabel = ariaParts.join(', ');
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!spaceIdOrAlias || !roomIdOrAlias) return;
|
||||
navigate(getChannelsThreadPath(spaceIdOrAlias, roomIdOrAlias, rootId));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
className={classNames(css.Root, className)}
|
||||
onClick={handleOpen}
|
||||
aria-label={ariaLabel}
|
||||
data-event-id={rootId}
|
||||
{...props}
|
||||
>
|
||||
<span className={css.Dot} aria-hidden />
|
||||
<Text className={css.Count} as="span" size="T200">
|
||||
<b>{countLabel}</b>
|
||||
</Text>
|
||||
{lastTs && (
|
||||
<>
|
||||
<span className={css.Separator} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Time className={css.TimeText} ts={lastTs} compact size="T200" priority="300" />
|
||||
</>
|
||||
)}
|
||||
<Icon className={css.Chevron} src={Icons.ChevronRight} size="100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -38,6 +38,9 @@ import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
|||
import classNames from 'classnames';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
CHANNEL_MESSAGE_SPACING,
|
||||
ChannelLayout,
|
||||
ChannelMessageAvatar,
|
||||
MessageBase,
|
||||
STREAM_MESSAGE_SPACING,
|
||||
StreamLayout,
|
||||
|
|
@ -692,6 +695,16 @@ export type MessageProps = {
|
|||
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
|
||||
// local useState would race against the commit↔effect gap.
|
||||
msgType?: string;
|
||||
// M3a: thread summary pill (count + last reply preview) rendered
|
||||
// between the bubble and reactions. Caller (`RoomTimeline`) decides
|
||||
// whether to mount the card based on `channelsMode && !isBridged` —
|
||||
// outside channels-mode this stays `undefined` and the slot collapses.
|
||||
threadSummary?: React.ReactNode;
|
||||
// M3: choose timeline visual. `'stream'` = rail + dot + bubble (current
|
||||
// DM/Bots layout). `'channel'` = avatar + name + time inline + body
|
||||
// without bubble (Slack/Discord-style channels timeline). Default
|
||||
// `'stream'` so non-channels callers don't have to opt in.
|
||||
layout?: 'stream' | 'channel';
|
||||
};
|
||||
export const Message = as<'div', MessageProps>(
|
||||
(
|
||||
|
|
@ -724,6 +737,8 @@ export const Message = as<'div', MessageProps>(
|
|||
hideThreadReplyAffordance,
|
||||
hideMainReplyAffordance,
|
||||
msgType,
|
||||
threadSummary,
|
||||
layout = 'stream',
|
||||
children,
|
||||
...props
|
||||
},
|
||||
|
|
@ -775,7 +790,10 @@ export const Message = as<'div', MessageProps>(
|
|||
|
||||
const streamMediaCtx = useMemo(
|
||||
() =>
|
||||
mediaMode
|
||||
// Stream-only: the overlay username on top of media collapses the
|
||||
// bubble's header slot. Channel layout renders username above the
|
||||
// image normally, so the overlay is suppressed (`null` ctx).
|
||||
mediaMode && layout === 'stream'
|
||||
? {
|
||||
own: isOwnMessage,
|
||||
username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName,
|
||||
|
|
@ -786,6 +804,7 @@ export const Message = as<'div', MessageProps>(
|
|||
: null,
|
||||
[
|
||||
mediaMode,
|
||||
layout,
|
||||
isOwnMessage,
|
||||
senderDisplayName,
|
||||
senderId,
|
||||
|
|
@ -866,7 +885,7 @@ export const Message = as<'div', MessageProps>(
|
|||
<MessageBase
|
||||
className={classNames(css.MessageBase, className)}
|
||||
tabIndex={0}
|
||||
space={STREAM_MESSAGE_SPACING}
|
||||
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
|
||||
collapse={collapse}
|
||||
highlight={highlight}
|
||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||
|
|
@ -1119,39 +1138,81 @@ export const Message = as<'div', MessageProps>(
|
|||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
<StreamLayout
|
||||
time={<Time ts={mEvent.getTs()} compact />}
|
||||
dotColor={dot.color}
|
||||
dotOpacity={dot.opacity}
|
||||
isOwn={isOwnMessage}
|
||||
compact={isMobile}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
mediaMode={mediaMode}
|
||||
reactions={reactions}
|
||||
header={
|
||||
mediaMode ? undefined : (
|
||||
<Username
|
||||
as="button"
|
||||
style={{ color: usernameColor ?? color.Primary.Main }}
|
||||
data-user-id={senderId}
|
||||
onContextMenu={onUserClick}
|
||||
onClick={onUsernameClick}
|
||||
>
|
||||
<Text as="span" size="T200" truncate>
|
||||
<UsernameBold>
|
||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||
</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
)
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<StreamMediaContext.Provider value={streamMediaCtx}>
|
||||
{msgContentJSX}
|
||||
</StreamMediaContext.Provider>
|
||||
</StreamLayout>
|
||||
{layout === 'channel' ? (
|
||||
<ChannelLayout
|
||||
avatar={
|
||||
!collapse ? (
|
||||
<ChannelMessageAvatar
|
||||
room={room}
|
||||
senderId={senderId}
|
||||
senderDisplayName={senderDisplayName}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
header={
|
||||
!collapse ? (
|
||||
<>
|
||||
<Username
|
||||
as="button"
|
||||
style={{ color: usernameColor ?? color.Primary.Main }}
|
||||
data-user-id={senderId}
|
||||
onContextMenu={onUserClick}
|
||||
onClick={onUsernameClick}
|
||||
>
|
||||
<Text as="span" size="T400" truncate>
|
||||
<UsernameBold>
|
||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||
</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
reactions={reactions}
|
||||
threadSummary={threadSummary}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<StreamMediaContext.Provider value={streamMediaCtx}>
|
||||
{msgContentJSX}
|
||||
</StreamMediaContext.Provider>
|
||||
</ChannelLayout>
|
||||
) : (
|
||||
<StreamLayout
|
||||
time={<Time ts={mEvent.getTs()} compact />}
|
||||
dotColor={dot.color}
|
||||
dotOpacity={dot.opacity}
|
||||
isOwn={isOwnMessage}
|
||||
compact={isMobile}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
mediaMode={mediaMode}
|
||||
reactions={reactions}
|
||||
threadSummary={threadSummary}
|
||||
header={
|
||||
mediaMode ? undefined : (
|
||||
<Username
|
||||
as="button"
|
||||
style={{ color: usernameColor ?? color.Primary.Main }}
|
||||
data-user-id={senderId}
|
||||
onContextMenu={onUserClick}
|
||||
onClick={onUsernameClick}
|
||||
>
|
||||
<Text as="span" size="T200" truncate>
|
||||
<UsernameBold>
|
||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||
</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
)
|
||||
}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<StreamMediaContext.Provider value={streamMediaCtx}>
|
||||
{msgContentJSX}
|
||||
</StreamMediaContext.Provider>
|
||||
</StreamLayout>
|
||||
)}
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue