feat(channels): ship M3 channel timeline avatar-name layout with thread summary cards and drawer header counter polish
This commit is contained in:
parent
4632be30f7
commit
307af24d1e
14 changed files with 746 additions and 49 deletions
|
|
@ -509,12 +509,17 @@
|
||||||
"empty_message": "Empty message",
|
"empty_message": "Empty message",
|
||||||
"edited": " (edited)",
|
"edited": " (edited)",
|
||||||
|
|
||||||
"thread_in_channel": "Thread in #{{channel}}",
|
"thread_caption": "Thread",
|
||||||
|
"thread_in_channel_subtitle": "in #{{channel}}",
|
||||||
"thread_close": "Close thread",
|
"thread_close": "Close thread",
|
||||||
"thread_no_replies": "No one has replied yet",
|
"thread_no_replies": "No one has replied yet",
|
||||||
"thread_root_error": "Could not load the original message",
|
"thread_root_error": "Could not load the original message",
|
||||||
"thread_paginate_error": "Could not load replies",
|
"thread_paginate_error": "Could not load replies",
|
||||||
"thread_retry": "Retry",
|
"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",
|
"no_post_permission": "You do not have permission to post in this room",
|
||||||
|
|
||||||
"conversation_beginning": "This is the beginning of conversation.",
|
"conversation_beginning": "This is the beginning of conversation.",
|
||||||
|
|
|
||||||
|
|
@ -513,12 +513,19 @@
|
||||||
"empty_message": "Пустое сообщение",
|
"empty_message": "Пустое сообщение",
|
||||||
"edited": " (изменено)",
|
"edited": " (изменено)",
|
||||||
|
|
||||||
"thread_in_channel": "Тред в #{{channel}}",
|
"thread_caption": "Тред",
|
||||||
|
"thread_in_channel_subtitle": "в #{{channel}}",
|
||||||
"thread_close": "Закрыть тред",
|
"thread_close": "Закрыть тред",
|
||||||
"thread_no_replies": "Никто пока не ответил",
|
"thread_no_replies": "Никто пока не ответил",
|
||||||
"thread_root_error": "Не удалось загрузить исходное сообщение",
|
"thread_root_error": "Не удалось загрузить исходное сообщение",
|
||||||
"thread_paginate_error": "Не удалось загрузить ответы",
|
"thread_paginate_error": "Не удалось загрузить ответы",
|
||||||
"thread_retry": "Повторить",
|
"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": "У вас нет разрешения на отправку сообщений в этой комнате",
|
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||||
|
|
||||||
"conversation_beginning": "Начало переписки.",
|
"conversation_beginning": "Начало переписки.",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Box, Icon, IconSrc, color } from 'folds';
|
||||||
import React, { ReactNode, useRef } from 'react';
|
import React, { ReactNode, useRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as layoutCss from '../layout/layout.css';
|
import * as layoutCss from '../layout/layout.css';
|
||||||
|
import { ChannelEventContent } from '../layout/Channel';
|
||||||
import { useStreamLayoutDebug } from '../layout/streamDebug';
|
import { useStreamLayoutDebug } from '../layout/streamDebug';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
|
||||||
|
|
@ -11,8 +12,19 @@ export type EventContentProps = {
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
railStart?: boolean;
|
railStart?: boolean;
|
||||||
railEnd?: 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 compact = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const timeRef = useRef<HTMLDivElement>(null);
|
const timeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -32,6 +44,10 @@ export function EventContent({ time, iconSrc, content, railStart, railEnd }: Eve
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (layout === 'channel') {
|
||||||
|
return <ChannelEventContent iconSrc={iconSrc} content={content} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Sysline = thin one-line state-event row that lives ON the rail.
|
// Sysline = thin one-line state-event row that lives ON the rail.
|
||||||
// Same 3-track grid as message rows (StreamRoot) — track 1 timestamp,
|
// 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
|
// 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
|
// column — outside the bubble's bg/border so reactions read as
|
||||||
// floating chips on the page background, not as a part of the bubble.
|
// floating chips on the page background, not as a part of the bubble.
|
||||||
reactions?: ReactNode;
|
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 = {
|
export type StreamDayDividerProps = {
|
||||||
|
|
@ -101,6 +106,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
railEnd,
|
railEnd,
|
||||||
mediaMode,
|
mediaMode,
|
||||||
reactions,
|
reactions,
|
||||||
|
threadSummary,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -174,6 +180,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
{threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>}
|
||||||
{reactions && <div className={css.StreamReactions}>{reactions}</div>}
|
{reactions && <div className={css.StreamReactions}>{reactions}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './Modern';
|
export * from './Modern';
|
||||||
export * from './Stream';
|
export * from './Stream';
|
||||||
|
export * from './Channel';
|
||||||
export * from './Base';
|
export * from './Base';
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,15 @@ export const StreamReactions = style({
|
||||||
minWidth: 0,
|
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({
|
export const StreamBubble = recipe({
|
||||||
base: {
|
base: {
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ import {
|
||||||
EventContent,
|
EventContent,
|
||||||
STREAM_MESSAGE_SPACING,
|
STREAM_MESSAGE_SPACING,
|
||||||
StreamDayDivider,
|
StreamDayDivider,
|
||||||
|
CHANNEL_MESSAGE_SPACING,
|
||||||
|
ChannelDayDivider,
|
||||||
} from '../../components/message';
|
} from '../../components/message';
|
||||||
import {
|
import {
|
||||||
factoryRenderLinkifyWithMention,
|
factoryRenderLinkifyWithMention,
|
||||||
|
|
@ -87,6 +89,7 @@ import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
|
import { ThreadSummaryCard } from './ThreadSummaryCard';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||||
import { RoomIntro } from '../../components/room-intro';
|
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
|
// nowhere to lead. Pre-M2 the button existed in those surfaces but
|
||||||
// never produced a visible thread.
|
// never produced a visible thread.
|
||||||
const hideThreadReplyAffordance = !channelsMode || isBridged;
|
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
|
// M2: when the thread drawer is open, the channel composer is
|
||||||
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
|
// unmounted by RoomView (single-Slate-at-a-time pattern). Clicking
|
||||||
// the channel timeline's «Reply» menu would write a reply chip into
|
// 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 ?? ''}
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||||
|
threadSummary={
|
||||||
|
showThreadSummary ? (
|
||||||
|
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
layout={messageLayout}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1407,6 +1427,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
msgType={mEvent.getContent().msgtype ?? ''}
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||||
|
threadSummary={
|
||||||
|
showThreadSummary ? (
|
||||||
|
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
layout={messageLayout}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||||
|
|
@ -1521,6 +1547,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
streamRailEnd={streamRailEnd}
|
streamRailEnd={streamRailEnd}
|
||||||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||||
|
threadSummary={
|
||||||
|
showThreadSummary ? (
|
||||||
|
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
layout={messageLayout}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1584,6 +1616,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={iconSrc}
|
iconSrc={iconSrc}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1632,6 +1665,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={Icons.Hash}
|
iconSrc={Icons.Hash}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1681,6 +1715,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={Icons.Hash}
|
iconSrc={Icons.Hash}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1730,6 +1765,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={Icons.Hash}
|
iconSrc={Icons.Hash}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1787,6 +1823,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1833,6 +1870,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={Icons.Code}
|
iconSrc={Icons.Code}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1881,6 +1919,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
layout={messageLayout}
|
||||||
iconSrc={Icons.Code}
|
iconSrc={Icons.Code}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -2092,8 +2131,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const renderDayDivider = () => (
|
const renderDayDivider = () => (
|
||||||
<MessageBase space={STREAM_MESSAGE_SPACING}>
|
<MessageBase
|
||||||
<StreamDayDivider label={dayLabel} />
|
space={messageLayout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
|
||||||
|
>
|
||||||
|
{messageLayout === 'channel' ? (
|
||||||
|
<ChannelDayDivider label={dayLabel} />
|
||||||
|
) : (
|
||||||
|
<StreamDayDivider label={dayLabel} />
|
||||||
|
)}
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,51 @@ export const ThreadDivider = style({
|
||||||
flexShrink: 0,
|
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({
|
export const ThreadComposer = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
padding: `0 ${config.space.S400} ${config.space.S400}`,
|
padding: `0 ${config.space.S400} ${config.space.S400}`,
|
||||||
|
|
|
||||||
|
|
@ -149,21 +149,21 @@ function ThreadEventCard({
|
||||||
return (
|
return (
|
||||||
<div className={css.ThreadEventCard}>
|
<div className={css.ThreadEventCard}>
|
||||||
<div className={css.ThreadEventAvatar}>
|
<div className={css.ThreadEventAvatar}>
|
||||||
<Avatar size="200">
|
<Avatar size="300" style={{ width: '32px', height: '32px' }}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={senderId}
|
userId={senderId}
|
||||||
src={avatarUrl ?? undefined}
|
src={avatarUrl ?? undefined}
|
||||||
alt={senderDisplayName}
|
alt={senderDisplayName}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.ThreadEventBody}>
|
<div className={css.ThreadEventBody}>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Text size="T300">
|
<Text size="T400">
|
||||||
<b>{senderDisplayName}</b>
|
<b>{senderDisplayName}</b>
|
||||||
</Text>
|
</Text>
|
||||||
<Time ts={mEvent.getTs()} compact />
|
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
|
||||||
</Box>
|
</Box>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent
|
<RedactedContent
|
||||||
|
|
@ -781,7 +781,24 @@ export function ThreadDrawer({
|
||||||
mediaAutoLoad={mediaAutoLoad}
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
showUrlPreview={showUrlPreview}
|
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 && (
|
{coldLoadError && (
|
||||||
<div className={css.ThreadErrorState}>
|
<div className={css.ThreadErrorState}>
|
||||||
<Text size="T300" align="Center" priority="400">
|
<Text size="T300" align="Center" priority="400">
|
||||||
|
|
@ -857,10 +874,12 @@ export function ThreadDrawer({
|
||||||
>
|
>
|
||||||
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200" id={headerId}>
|
<Box grow="Yes" direction="Column" id={headerId}>
|
||||||
<Icon src={Icons.Hash} size="100" />
|
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
|
||||||
<Text size="H5" truncate>
|
{t('Room.thread_caption')}
|
||||||
{t('Room.thread_in_channel', { channel: room.name ?? '' })}
|
</Text>
|
||||||
|
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
|
||||||
|
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center">
|
<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 classNames from 'classnames';
|
||||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import {
|
import {
|
||||||
|
CHANNEL_MESSAGE_SPACING,
|
||||||
|
ChannelLayout,
|
||||||
|
ChannelMessageAvatar,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
STREAM_MESSAGE_SPACING,
|
STREAM_MESSAGE_SPACING,
|
||||||
StreamLayout,
|
StreamLayout,
|
||||||
|
|
@ -692,6 +695,16 @@ export type MessageProps = {
|
||||||
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
|
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
|
||||||
// local useState would race against the commit↔effect gap.
|
// local useState would race against the commit↔effect gap.
|
||||||
msgType?: string;
|
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>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -724,6 +737,8 @@ export const Message = as<'div', MessageProps>(
|
||||||
hideThreadReplyAffordance,
|
hideThreadReplyAffordance,
|
||||||
hideMainReplyAffordance,
|
hideMainReplyAffordance,
|
||||||
msgType,
|
msgType,
|
||||||
|
threadSummary,
|
||||||
|
layout = 'stream',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -775,7 +790,10 @@ export const Message = as<'div', MessageProps>(
|
||||||
|
|
||||||
const streamMediaCtx = useMemo(
|
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,
|
own: isOwnMessage,
|
||||||
username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName,
|
username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName,
|
||||||
|
|
@ -786,6 +804,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
: null,
|
: null,
|
||||||
[
|
[
|
||||||
mediaMode,
|
mediaMode,
|
||||||
|
layout,
|
||||||
isOwnMessage,
|
isOwnMessage,
|
||||||
senderDisplayName,
|
senderDisplayName,
|
||||||
senderId,
|
senderId,
|
||||||
|
|
@ -866,7 +885,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={STREAM_MESSAGE_SPACING}
|
space={layout === 'channel' ? CHANNEL_MESSAGE_SPACING : STREAM_MESSAGE_SPACING}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||||
|
|
@ -1119,39 +1138,81 @@ export const Message = as<'div', MessageProps>(
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<StreamLayout
|
{layout === 'channel' ? (
|
||||||
time={<Time ts={mEvent.getTs()} compact />}
|
<ChannelLayout
|
||||||
dotColor={dot.color}
|
avatar={
|
||||||
dotOpacity={dot.opacity}
|
!collapse ? (
|
||||||
isOwn={isOwnMessage}
|
<ChannelMessageAvatar
|
||||||
compact={isMobile}
|
room={room}
|
||||||
railStart={streamRailStart}
|
senderId={senderId}
|
||||||
railEnd={streamRailEnd}
|
senderDisplayName={senderDisplayName}
|
||||||
mediaMode={mediaMode}
|
/>
|
||||||
reactions={reactions}
|
) : undefined
|
||||||
header={
|
}
|
||||||
mediaMode ? undefined : (
|
header={
|
||||||
<Username
|
!collapse ? (
|
||||||
as="button"
|
<>
|
||||||
style={{ color: usernameColor ?? color.Primary.Main }}
|
<Username
|
||||||
data-user-id={senderId}
|
as="button"
|
||||||
onContextMenu={onUserClick}
|
style={{ color: usernameColor ?? color.Primary.Main }}
|
||||||
onClick={onUsernameClick}
|
data-user-id={senderId}
|
||||||
>
|
onContextMenu={onUserClick}
|
||||||
<Text as="span" size="T200" truncate>
|
onClick={onUsernameClick}
|
||||||
<UsernameBold>
|
>
|
||||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
<Text as="span" size="T400" truncate>
|
||||||
</UsernameBold>
|
<UsernameBold>
|
||||||
</Text>
|
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||||
</Username>
|
</UsernameBold>
|
||||||
)
|
</Text>
|
||||||
}
|
</Username>
|
||||||
onContextMenu={handleContextMenu}
|
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
|
||||||
>
|
</>
|
||||||
<StreamMediaContext.Provider value={streamMediaCtx}>
|
) : undefined
|
||||||
{msgContentJSX}
|
}
|
||||||
</StreamMediaContext.Provider>
|
reactions={reactions}
|
||||||
</StreamLayout>
|
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>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue