feat(timeline): replace empty-chat RoomIntro with context-aware placeholder for 1:1, group and bridged rooms

This commit is contained in:
heaven 2026-05-29 00:07:18 +03:00
parent aa3dbc13ef
commit 297b55f693
8 changed files with 170 additions and 155 deletions

View file

@ -121,7 +121,6 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group
- `setting-tile/` — Settings list item pattern - `setting-tile/` — Settings list item pattern
- `sequence-card/`, `cutout-card/` — Card layouts - `sequence-card/`, `cutout-card/` — Card layouts
- `uia-stages/` — User-interactive auth stages (email, captcha, token) - `uia-stages/` — User-interactive auth stages (email, captcha, token)
- `room-intro/` — Room introduction card
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs - `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs
- `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9) - `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9)
@ -347,8 +346,8 @@ P3c examples missed initially:
- `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split - `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal- mutual DMs vs mutual rooms» — needed m.direct semantic, not universal-
Direct. Mechanical rename broke the split. Direct. Mechanical rename broke the split.
- `mDirects.has(room.roomId)` in `RoomIntro`, `RoomSettings`, `RoomProfile`, - `mDirects.has(room.roomId)` in `RoomIntro` (since removed), `RoomSettings`,
`SpaceSettings` for peer-avatar fallback — needed member-count semantic `RoomProfile`, `SpaceSettings` for peer-avatar fallback — needed member-count semantic
consistent with `RoomViewHeader`. Mechanical preservation of m.direct consistent with `RoomViewHeader`. Mechanical preservation of m.direct
diverged the chrome. diverged the chrome.

View file

@ -548,11 +548,19 @@
"thread_summary_highlight_one": "{{count}} mention", "thread_summary_highlight_one": "{{count}} mention",
"thread_summary_highlight_other": "{{count}} mentions", "thread_summary_highlight_other": "{{count}} mentions",
"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.", "empty_dm": "The hardest part is the first message.",
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}", "empty_dm_alt_1": "You have to start somewhere.",
"invite_member": "Invite Member", "empty_dm_alt_2": "Someone has to go first.",
"open_old_room": "Open Old Room", "empty_dm_alt_3": "A blank canvas. Not a single typo — yet.",
"join_old_room": "Join Old Room", "empty_group": "The group is set up. Who goes first?",
"empty_group_alt_1": "No one has said anything here yet.",
"empty_group_alt_2": "The calm before the first message.",
"empty_group_alt_3": "Everyone's here — go ahead.",
"empty_bridge": "Messages here travel through the {{network}} bridge.",
"empty_bridge_alt_1": "This chat is linked to {{network}}.",
"empty_bridge_alt_2": "Your contact is writing from {{network}}.",
"empty_bridge_generic": "Messages here travel through a bridge.",
"empty_encrypted": "Messages are protected with end-to-end encryption.",
"leave_room_title": "Leave Room", "leave_room_title": "Leave Room",
"leave_room_confirm": "Are you sure you want to leave this room?", "leave_room_confirm": "Are you sure you want to leave this room?",
"leave_room_error": "Failed to leave room! {{error}}", "leave_room_error": "Failed to leave room! {{error}}",

View file

@ -564,11 +564,19 @@
"thread_summary_highlight_many": "{{count}} упоминаний", "thread_summary_highlight_many": "{{count}} упоминаний",
"thread_summary_highlight_other": "{{count}} упоминания", "thread_summary_highlight_other": "{{count}} упоминания",
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате", "no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
"conversation_beginning": "Начало переписки.", "empty_dm": "Самое сложное — первое сообщение.",
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}", "empty_dm_alt_1": "С чего-то надо начать.",
"invite_member": "Пригласить", "empty_dm_alt_2": "Кто-то должен написать первым.",
"open_old_room": "Открыть старую комнату", "empty_dm_alt_3": "Чистый лист. Ни одной опечатки — пока.",
"join_old_room": "Войти в старую комнату", "empty_group": "Группа создана. Кто первый?",
"empty_group_alt_1": "Здесь пока никто ничего не сказал.",
"empty_group_alt_2": "Тишина перед первым сообщением.",
"empty_group_alt_3": "Все в сборе — можно начинать.",
"empty_bridge": "Сообщения идут через мост с {{network}}.",
"empty_bridge_alt_1": "Этот чат соединён с {{network}}.",
"empty_bridge_alt_2": "Собеседник пишет из {{network}}.",
"empty_bridge_generic": "Сообщения идут через мост.",
"empty_encrypted": "Сообщения защищены сквозным шифрованием.",
"leave_room_title": "Покинуть комнату", "leave_room_title": "Покинуть комнату",
"leave_room_confirm": "Покинуть эту комнату?", "leave_room_confirm": "Покинуть эту комнату?",
"leave_room_error": "Не удалось покинуть комнату! {{error}}", "leave_room_error": "Не удалось покинуть комнату! {{error}}",

View file

@ -1,126 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Trans, useTranslation } from 'react-i18next';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { InviteUserPrompt } from '../invite-user-prompt';
export type RoomIntroProps = {
room: Room;
};
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
// Match RoomViewHeader's peer-avatar logic — pull the fallback only when the
// room is strictly 1:1, not when it carries an `m.direct` flag. Bridged
// Telegram 1:1s and just-promoted self-DMs both lack the flag but have
// member-count = 2, so this picks them up correctly.
const isOneOnOne = useIsOneOnOne();
const [invitePrompt, setInvitePrompt] = useState(false);
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarMxc = useRoomAvatar(room, isOneOnOne);
const name = useRoomName(room);
const topic = useRoomTopic(room);
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const createContent = createEvent?.getContent<IRoomCreateContent>();
const ts = createEvent?.getTs();
const creatorId = createEvent?.getSender();
const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id;
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
);
return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
<Avatar size="500">
<RoomAvatar
roomId={room.roomId}
src={avatarHttpUrl ?? undefined}
alt={name}
renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
/>
</Avatar>
</Box>
<Box direction="Column" gap="300">
<Box direction="Column" gap="100">
<Text size="H3" priority="500">
{name}
</Text>
<Text size="T400" priority="400">
{typeof topic === 'string' ? topic : t('Room.conversation_beginning')}
</Text>
{creatorName && ts && (
<Text size="T200" priority="300">
<Trans
i18nKey="Room.created_by"
values={{
creator: creatorName,
date: timeDayMonYear(ts),
time: timeHourMinute(ts),
}}
components={{ bold: <b /> }}
/>
</Text>
)}
</Box>
<Box gap="200" wrap="Wrap">
<Button onClick={() => setInvitePrompt(true)} variant="Secondary" size="300" radii="300">
<Text size="B300">{t('Room.invite_member')}</Text>
</Button>
{invitePrompt && (
<InviteUserPrompt room={room} requestClose={() => setInvitePrompt(false)} />
)}
{typeof prevRoomId === 'string' &&
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
<Button
onClick={() => navigateRoom(prevRoomId, createContent?.predecessor?.event_id)}
variant="Success"
size="300"
fill="Soft"
radii="300"
>
<Text size="B300">{t('Room.open_old_room')}</Text>
</Button>
) : (
<Button
onClick={() => joinPrevRoom(prevRoomId)}
variant="Secondary"
size="300"
fill="Soft"
radii="300"
disabled={prevRoomState.status === AsyncStatus.Loading}
after={
prevRoomState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Secondary" fill="Soft" />
) : undefined
}
>
<Text size="B300">{t('Room.join_old_room')}</Text>
</Button>
))}
</Box>
</Box>
</Box>
);
});

View file

@ -1 +0,0 @@
export * from './RoomIntro';

View file

@ -0,0 +1,97 @@
import React, { useMemo } from 'react';
import { Box, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useIsOneOnOne } from '../../hooks/useRoom';
import { useIsBridgedRoom } from '../../hooks/useIsBridgedRoom';
import { getBridgeNetworkName } from '../../utils/room';
// Context-aware empty-chat placeholder. The room class decides which copy the
// (hour-rotated) line is drawn from:
// • native 1:1 → conversational `dm` prompt (+ E2E note if encrypted)
// • native group → conversational `group` prompt (+ E2E note if encrypted)
// • bridged 1:1 → `dm` prompt + a muted line naming the bridge network
// • bridged group → just the bridge-network line (no conversational prompt)
// The E2E note is shown only for native (non-bridged) encrypted rooms — a
// bridge leg (e.g. Telegram) is NOT end-to-end, so claiming so would mislead.
//
// Rotation mirrors the composer placeholder in `RoomInput.tsx`: the hour-of-day
// slot indexes a set, memoised against the room context so the line is stable
// while the user sits in one chat and only re-rolls on navigation. Keep these
// key lists in sync with `public/locales/{en,ru}.json` `Room.empty_*` entries.
const EMPTY_KEY_SETS = {
dm: ['Room.empty_dm', 'Room.empty_dm_alt_1', 'Room.empty_dm_alt_2', 'Room.empty_dm_alt_3'],
group: [
'Room.empty_group',
'Room.empty_group_alt_1',
'Room.empty_group_alt_2',
'Room.empty_group_alt_3',
],
bridge: ['Room.empty_bridge', 'Room.empty_bridge_alt_1', 'Room.empty_bridge_alt_2'],
bridgeGeneric: ['Room.empty_bridge_generic'],
} as const;
type EmptySetKind = keyof typeof EMPTY_KEY_SETS;
const pickByHour = (kind: EmptySetKind): string => {
const set = EMPTY_KEY_SETS[kind];
return set[new Date().getHours() % set.length];
};
export function EmptyTimeline({ room, roomId }: { room: Room; roomId: string }) {
const { t } = useTranslation();
const isOneOnOne = useIsOneOnOne();
const bridged = useIsBridgedRoom(room);
const network = bridged ? getBridgeNetworkName(room) : undefined;
// E2E note only for native encrypted rooms — never for bridges (the remote
// leg isn't end-to-end, so the claim wouldn't hold).
const encrypted = !bridged && room.hasEncryptionStateEvent();
const promptKind: EmptySetKind = isOneOnOne ? 'dm' : 'group';
const bridgeKind: EmptySetKind = network ? 'bridge' : 'bridgeGeneric';
const promptKey = useMemo(
() => pickByHour(promptKind),
// roomId is an intentional re-roll trigger on chat navigation, not read in
// the callback body.
// eslint-disable-next-line react-hooks/exhaustive-deps
[roomId, promptKind]
);
const bridgeKey = useMemo(
() => (bridged ? pickByHour(bridgeKind) : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[roomId, bridged, bridgeKind]
);
const bridgeLine = bridgeKey ? t(bridgeKey, { network }) : null;
// bridged group → the bridge line stands alone as the primary line; every
// other class leads with the conversational prompt.
const bridgeLineIsPrimary = bridged && !isOneOnOne;
const primary = bridgeLineIsPrimary ? bridgeLine : t(promptKey);
let secondary: string | null = null;
if (!bridgeLineIsPrimary) {
if (bridged) secondary = bridgeLine;
else if (encrypted) secondary = t('Room.empty_encrypted');
}
return (
<Box
grow="Yes"
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="100"
style={{ padding: '0 24px', textAlign: 'center' }}
>
<Text size="T300" priority="300">
{primary}
</Text>
{secondary && (
<Text size="T200" priority="300">
{secondary}
</Text>
)}
</Box>
);
}

View file

@ -27,7 +27,7 @@ import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js'; import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Chip, Icon, Icons, Scroll, Text, as, config, toRem } from 'folds'; import { Box, Chip, Icon, Icons, Scroll, Text, as, config } from 'folds';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -87,7 +87,7 @@ import {
import { ThreadSummaryCard } from './ThreadSummaryCard'; 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 { EmptyTimeline } from './EmptyTimeline';
import { import {
getIntersectionObserverEntry, getIntersectionObserverEntry,
useIntersectionObserver, useIntersectionObserver,
@ -2197,7 +2197,11 @@ export function RoomTimeline({
// Crucially this looks at renderability, not at `isPrevRendered` — the // Crucially this looks at renderability, not at `isPrevRendered` — the
// latter is mutated by reaction / edit / hidden service events and would // latter is mutated by reaction / edit / hidden service events and would
// otherwise reset rail-start in the middle of a continuous DM thread. // otherwise reset rail-start in the middle of a continuous DM thread.
const { before: streamRenderableItemHasBefore, after: streamRenderableItemHasAfter } = (() => { const {
before: streamRenderableItemHasBefore,
after: streamRenderableItemHasAfter,
hasRenderable: hasRenderableEvent,
} = (() => {
const before = new Map<number, boolean>(); const before = new Map<number, boolean>();
const after = new Map<number, boolean>(); const after = new Map<number, boolean>();
@ -2219,7 +2223,7 @@ export function RoomTimeline({
if (renderableFlags[index]) seenAfter = true; if (renderableFlags[index]) seenAfter = true;
} }
return { before, after }; return { before, after, hasRenderable: renderableFlags.some(Boolean) };
})(); })();
const eventRenderer = (item: number) => { const eventRenderer = (item: number) => {
@ -2370,17 +2374,15 @@ export function RoomTimeline({
paddingBottom: `calc(${config.space.S600} + ${bottomOverlayHeight}px)`, paddingBottom: `calc(${config.space.S600} + ${bottomOverlayHeight}px)`,
}} }}
> >
{!canPaginateBack && rangeAtStart && getItems().length > 0 && ( {/* No real message anywhere in the (fully-loaded) timeline a
<div brand-new room has only hidden membership/creation events, so we
style={{ gate on renderable messages, not raw event count. Suppressed the
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${toRem( moment any actual message exists. */}
64 {!canPaginateBack &&
)}`, rangeAtStart &&
}} liveTimelineLinked &&
> rangeAtEnd &&
<RoomIntro room={room} /> !hasRenderableEvent && <EmptyTimeline room={room} roomId={room.roomId} />}
</div>
)}
{(canPaginateBack || !rangeAtStart) && ( {(canPaginateBack || !rangeAtStart) && (
<> <>
<MessageBase> <MessageBase>

View file

@ -123,6 +123,34 @@ export const isBridgedRoom = (room: Room): boolean => {
return unstable.length > 0; return unstable.length > 0;
}; };
// Human-readable name of the network a room is bridged to (Telegram, WhatsApp,
// …), pulled from the MSC2346 `m.bridge` (or unstable `uk.half-shot.bridge`)
// `protocol.displayname`/`protocol.id`. Returns undefined when the room is not
// bridged or the bridge bot didn't advertise a protocol name. The `id` fallback
// is lower-case (`telegram`), so we capitalise the first letter for display.
export const getBridgeNetworkName = (room: Room): string | undefined => {
const events = [
...getStateEvents(room, StateEvent.RoomBridge),
...getStateEvents(room, StateEvent.RoomBridgeUnstable),
];
const names = events
.map((event) => {
// `m.bridge` content is bridge-bot-authored and untyped at the SDK
// boundary, so guard against non-string protocol fields before calling
// string methods — a malformed event must not throw out of the render.
const { protocol } = event.getContent<{
protocol?: { displayname?: unknown; id?: unknown };
}>();
const displayname =
typeof protocol?.displayname === 'string' ? protocol.displayname.trim() : '';
if (displayname) return displayname;
const id = typeof protocol?.id === 'string' ? protocol.id.trim() : '';
return id ? id.charAt(0).toUpperCase() + id.slice(1) : undefined;
})
.filter((name): name is string => !!name);
return names[0];
};
export function isValidChild(mEvent: MatrixEvent): boolean { export function isValidChild(mEvent: MatrixEvent): boolean {
return ( return (
mEvent.getType() === StateEvent.SpaceChild && mEvent.getType() === StateEvent.SpaceChild &&