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 &&