feat(timeline): replace empty-chat RoomIntro with context-aware placeholder for 1:1, group and bridged rooms
This commit is contained in:
parent
aa3dbc13ef
commit
297b55f693
8 changed files with 170 additions and 155 deletions
|
|
@ -121,7 +121,6 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group
|
|||
- `setting-tile/` — Settings list item pattern
|
||||
- `sequence-card/`, `cutout-card/` — Card layouts
|
||||
- `uia-stages/` — User-interactive auth stages (email, captcha, token)
|
||||
- `room-intro/` — Room introduction card
|
||||
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs
|
||||
- `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
|
||||
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal-
|
||||
Direct. Mechanical rename broke the split.
|
||||
- `mDirects.has(room.roomId)` in `RoomIntro`, `RoomSettings`, `RoomProfile`,
|
||||
`SpaceSettings` for peer-avatar fallback — needed member-count semantic
|
||||
- `mDirects.has(room.roomId)` in `RoomIntro` (since removed), `RoomSettings`,
|
||||
`RoomProfile`, `SpaceSettings` for peer-avatar fallback — needed member-count semantic
|
||||
consistent with `RoomViewHeader`. Mechanical preservation of m.direct
|
||||
diverged the chrome.
|
||||
|
||||
|
|
|
|||
|
|
@ -548,11 +548,19 @@
|
|||
"thread_summary_highlight_one": "{{count}} mention",
|
||||
"thread_summary_highlight_other": "{{count}} mentions",
|
||||
"no_post_permission": "You do not have permission to post in this room",
|
||||
"conversation_beginning": "This is the beginning of conversation.",
|
||||
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}",
|
||||
"invite_member": "Invite Member",
|
||||
"open_old_room": "Open Old Room",
|
||||
"join_old_room": "Join Old Room",
|
||||
"empty_dm": "The hardest part is the first message.",
|
||||
"empty_dm_alt_1": "You have to start somewhere.",
|
||||
"empty_dm_alt_2": "Someone has to go first.",
|
||||
"empty_dm_alt_3": "A blank canvas. Not a single typo — yet.",
|
||||
"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_confirm": "Are you sure you want to leave this room?",
|
||||
"leave_room_error": "Failed to leave room! {{error}}",
|
||||
|
|
|
|||
|
|
@ -564,11 +564,19 @@
|
|||
"thread_summary_highlight_many": "{{count}} упоминаний",
|
||||
"thread_summary_highlight_other": "{{count}} упоминания",
|
||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||
"conversation_beginning": "Начало переписки.",
|
||||
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
|
||||
"invite_member": "Пригласить",
|
||||
"open_old_room": "Открыть старую комнату",
|
||||
"join_old_room": "Войти в старую комнату",
|
||||
"empty_dm": "Самое сложное — первое сообщение.",
|
||||
"empty_dm_alt_1": "С чего-то надо начать.",
|
||||
"empty_dm_alt_2": "Кто-то должен написать первым.",
|
||||
"empty_dm_alt_3": "Чистый лист. Ни одной опечатки — пока.",
|
||||
"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_confirm": "Покинуть эту комнату?",
|
||||
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './RoomIntro';
|
||||
97
src/app/features/room/EmptyTimeline.tsx
Normal file
97
src/app/features/room/EmptyTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ import { Editor } from 'slate';
|
|||
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
||||
import to from 'await-to-js';
|
||||
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 { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -87,7 +87,7 @@ import {
|
|||
import { ThreadSummaryCard } from './ThreadSummaryCard';
|
||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||
import { RoomIntro } from '../../components/room-intro';
|
||||
import { EmptyTimeline } from './EmptyTimeline';
|
||||
import {
|
||||
getIntersectionObserverEntry,
|
||||
useIntersectionObserver,
|
||||
|
|
@ -2197,7 +2197,11 @@ export function RoomTimeline({
|
|||
// Crucially this looks at renderability, not at `isPrevRendered` — the
|
||||
// latter is mutated by reaction / edit / hidden service events and would
|
||||
// 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 after = new Map<number, boolean>();
|
||||
|
||||
|
|
@ -2219,7 +2223,7 @@ export function RoomTimeline({
|
|||
if (renderableFlags[index]) seenAfter = true;
|
||||
}
|
||||
|
||||
return { before, after };
|
||||
return { before, after, hasRenderable: renderableFlags.some(Boolean) };
|
||||
})();
|
||||
|
||||
const eventRenderer = (item: number) => {
|
||||
|
|
@ -2370,17 +2374,15 @@ export function RoomTimeline({
|
|||
paddingBottom: `calc(${config.space.S600} + ${bottomOverlayHeight}px)`,
|
||||
}}
|
||||
>
|
||||
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${toRem(
|
||||
64
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
<RoomIntro room={room} />
|
||||
</div>
|
||||
)}
|
||||
{/* No real message anywhere in the (fully-loaded) timeline — a
|
||||
brand-new room has only hidden membership/creation events, so we
|
||||
gate on renderable messages, not raw event count. Suppressed the
|
||||
moment any actual message exists. */}
|
||||
{!canPaginateBack &&
|
||||
rangeAtStart &&
|
||||
liveTimelineLinked &&
|
||||
rangeAtEnd &&
|
||||
!hasRenderableEvent && <EmptyTimeline room={room} roomId={room.roomId} />}
|
||||
{(canPaginateBack || !rangeAtStart) && (
|
||||
<>
|
||||
<MessageBase>
|
||||
|
|
|
|||
|
|
@ -123,6 +123,34 @@ export const isBridgedRoom = (room: Room): boolean => {
|
|||
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 {
|
||||
return (
|
||||
mEvent.getType() === StateEvent.SpaceChild &&
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue