From 297b55f69391107f32948dc12ea299e8a7741a17 Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 29 May 2026 00:07:18 +0300 Subject: [PATCH] feat(timeline): replace empty-chat RoomIntro with context-aware placeholder for 1:1, group and bridged rooms --- docs/ai/architecture.md | 5 +- public/locales/en.json | 18 ++- public/locales/ru.json | 18 ++- src/app/components/room-intro/RoomIntro.tsx | 126 -------------------- src/app/components/room-intro/index.ts | 1 - src/app/features/room/EmptyTimeline.tsx | 97 +++++++++++++++ src/app/features/room/RoomTimeline.tsx | 32 ++--- src/app/utils/room.ts | 28 +++++ 8 files changed, 170 insertions(+), 155 deletions(-) delete mode 100644 src/app/components/room-intro/RoomIntro.tsx delete mode 100644 src/app/components/room-intro/index.ts create mode 100644 src/app/features/room/EmptyTimeline.tsx diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 737f1cd7..78ee29e3 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -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. diff --git a/public/locales/en.json b/public/locales/en.json index bbfaaae1..309329e1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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 @{{creator}} 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}}", diff --git a/public/locales/ru.json b/public/locales/ru.json index 0e6c95a7..baa683d4 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -564,11 +564,19 @@ "thread_summary_highlight_many": "{{count}} упоминаний", "thread_summary_highlight_other": "{{count}} упоминания", "no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате", - "conversation_beginning": "Начало переписки.", - "created_by": "Комната создана @{{creator}} {{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}}", diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx deleted file mode 100644 index ab62dbcc..00000000 --- a/src/app/components/room-intro/RoomIntro.tsx +++ /dev/null @@ -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(); - 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 ( - - - - {nameInitials(name)}} - /> - - - - - - {name} - - - {typeof topic === 'string' ? topic : t('Room.conversation_beginning')} - - {creatorName && ts && ( - - }} - /> - - )} - - - - - {invitePrompt && ( - setInvitePrompt(false)} /> - )} - {typeof prevRoomId === 'string' && - (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? ( - - ) : ( - - ))} - - - - ); -}); diff --git a/src/app/components/room-intro/index.ts b/src/app/components/room-intro/index.ts deleted file mode 100644 index 7250c789..00000000 --- a/src/app/components/room-intro/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './RoomIntro'; diff --git a/src/app/features/room/EmptyTimeline.tsx b/src/app/features/room/EmptyTimeline.tsx new file mode 100644 index 00000000..38952b03 --- /dev/null +++ b/src/app/features/room/EmptyTimeline.tsx @@ -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 ( + + + {primary} + + {secondary && ( + + {secondary} + + )} + + ); +} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e394154a..6c8bc53f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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(); const after = new Map(); @@ -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 && ( -
- -
- )} + {/* 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 && } {(canPaginateBack || !rangeAtStart) && ( <> diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 94dcffa4..7a51095b 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -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 &&