feat(syslines): render membership and room-state events as sender-anchored chat bubbles via StreamLayout instead of thin rail syslines

This commit is contained in:
heaven 2026-05-14 01:39:51 +03:00
parent a893e86d92
commit 8400ef54ee
3 changed files with 175 additions and 112 deletions

View file

@ -90,6 +90,7 @@ import {
Event, Event,
CallMessage, CallMessage,
CallAggregate, CallAggregate,
SyslineMessage,
EncryptedContent, EncryptedContent,
useMessageInteractionHandlers, useMessageInteractionHandlers,
} from './message'; } from './message';
@ -1849,44 +1850,23 @@ export function RoomTimeline({
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const parsed = parseMemberEvent(mEvent); const parsed = parseMemberEvent(mEvent);
const iconSrc =
parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon;
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact
/>
);
return ( return (
<Event <SyslineMessage
key={mEvent.getId()} key={mEvent.getId()}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
body={parsed.body}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
> streamRailStart={streamRailStart}
<EventContent streamRailEnd={streamRailEnd}
time={timeJSX} layout={messageLayout}
railStart={streamRailStart} channelHeaderInBubble={channelHeaderInBubble}
railEnd={streamRailEnd} />
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={iconSrc}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
{parsed.body}
</Text>
</Box>
}
/>
</Event>
); );
}, },
[StateEvent.RoomName]: ( [StateEvent.RoomName]: (
@ -1900,44 +1880,30 @@ export function RoomTimeline({
) => { ) => {
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact
/>
);
return ( return (
<Event <SyslineMessage
key={mEvent.getId()} key={mEvent.getId()}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
body={
<>
<b>{senderName}</b>
{t('Organisms.RoomCommon.changed_room_name')}
</>
}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
> streamRailStart={streamRailStart}
<EventContent streamRailEnd={streamRailEnd}
time={timeJSX} layout={messageLayout}
railStart={streamRailStart} channelHeaderInBubble={channelHeaderInBubble}
railEnd={streamRailEnd} />
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{t('Organisms.RoomCommon.changed_room_name')}
</Text>
</Box>
}
/>
</Event>
); );
}, },
[StateEvent.RoomTopic]: ( [StateEvent.RoomTopic]: (
@ -1951,44 +1917,30 @@ export function RoomTimeline({
) => { ) => {
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact
/>
);
return ( return (
<Event <SyslineMessage
key={mEvent.getId()} key={mEvent.getId()}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
body={
<>
<b>{senderName}</b>
{' changed room topic'}
</>
}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
> streamRailStart={streamRailStart}
<EventContent streamRailEnd={streamRailEnd}
time={timeJSX} layout={messageLayout}
railStart={streamRailStart} channelHeaderInBubble={channelHeaderInBubble}
railEnd={streamRailEnd} />
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' changed room topic'}
</Text>
</Box>
}
/>
</Event>
); );
}, },
[StateEvent.RoomAvatar]: ( [StateEvent.RoomAvatar]: (
@ -2002,44 +1954,30 @@ export function RoomTimeline({
) => { ) => {
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact
/>
);
return ( return (
<Event <SyslineMessage
key={mEvent.getId()} key={mEvent.getId()}
data-message-item={item} data-message-item={item}
data-message-id={mEventId} data-message-id={mEventId}
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
body={
<>
<b>{senderName}</b>
{' changed room avatar'}
</>
}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity} hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools} showDeveloperTools={showDeveloperTools}
> streamRailStart={streamRailStart}
<EventContent streamRailEnd={streamRailEnd}
time={timeJSX} layout={messageLayout}
railStart={streamRailStart} channelHeaderInBubble={channelHeaderInBubble}
railEnd={streamRailEnd} />
layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' changed room avatar'}
</Text>
</Box>
}
/>
</Event>
); );
}, },
[StateEvent.GroupCallMemberPrefix]: ( [StateEvent.GroupCallMemberPrefix]: (

View file

@ -0,0 +1,124 @@
import React, { ReactNode } from 'react';
import { Box, Text } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import {
ChannelLayout,
ChannelMessageAvatar,
StreamLayout,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import { Event } from './Message';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
export type SyslineMessageProps = {
room: Room;
mEvent: MatrixEvent;
// Pre-rendered body — already includes any sender names / interpolation
// the per-type renderer wants to show. The sysline bubble adds no extra
// header or icon, so the body is the only visible content.
body: ReactNode;
highlight: boolean;
canDelete?: boolean;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
streamRailStart?: boolean;
streamRailEnd?: boolean;
layout?: 'stream' | 'channel';
channelHeaderInBubble?: boolean;
// Spread onto Event wrapper (data-message-item / data-message-id) so the
// virtual paginator can locate the row.
[dataAttr: `data-${string}`]: string | number | undefined;
};
export function SyslineMessage({
room,
mEvent,
body,
highlight,
canDelete,
hideReadReceipts,
showDeveloperTools,
streamRailStart,
streamRailEnd,
layout = 'stream',
channelHeaderInBubble,
...rest
}: SyslineMessageProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
const dot = useDotColor(room, mEvent, true, hideReadReceipts);
const senderId = mEvent.getSender() ?? '';
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const bubbleBody = (
<Box style={{ minWidth: 0 }}>
<Text as="span" size="T300" priority="300">
{body}
</Text>
</Box>
);
return (
<Event
key={mEvent.getId()}
room={room}
mEvent={mEvent}
highlight={highlight}
canDelete={canDelete}
hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools}
{...rest}
>
{layout === 'channel' ? (
<ChannelLayout
isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble}
avatar={
<ChannelMessageAvatar
room={room}
senderId={senderId}
senderDisplayName={senderName}
/>
}
header={
<>
<Username as="span">
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderName}
</UsernameBold>
</Text>
</Username>
<Time ts={mEvent.getTs()} compact size="T200" priority="300" />
</>
}
>
{bubbleBody}
</ChannelLayout>
) : (
<StreamLayout
time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color}
dotOpacity={dot.opacity}
isOwn={isOwnMessage}
compact={isMobile}
railStart={streamRailStart}
railEnd={streamRailEnd}
>
{bubbleBody}
</StreamLayout>
)}
</Event>
);
}

View file

@ -1,5 +1,6 @@
export * from './Reactions'; export * from './Reactions';
export * from './Message'; export * from './Message';
export * from './CallMessage'; export * from './CallMessage';
export * from './SyslineMessage';
export * from './EncryptedContent'; export * from './EncryptedContent';
export * from './useMessageInteractionHandlers'; export * from './useMessageInteractionHandlers';