diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index f9c2c9a1..877a68a4 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -124,6 +124,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
+import { RoomTimelineTyping } from './RoomTimelineTyping';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@@ -2406,6 +2407,7 @@ export function RoomTimeline({
>
)}
+ {liveTimelineLinked && rangeAtEnd && }
diff --git a/src/app/features/room/RoomTimelineTyping.css.ts b/src/app/features/room/RoomTimelineTyping.css.ts
new file mode 100644
index 00000000..4c817d02
--- /dev/null
+++ b/src/app/features/room/RoomTimelineTyping.css.ts
@@ -0,0 +1,91 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
+
+// VS Code-style inline event line («Interrupted»-look). No rail, no dot,
+// no bubble — just a muted italic line that reads as a system note in
+// the timeline. Lives inside the scrolled timeline column, above the
+// composer overlay's reserved bottom padding, so there's no overlap with
+// the composer on native.
+//
+// Left padding intentionally lines up with the X where the composer
+// card's bottom-left rounded corner ends — i.e. composer outer gap +
+// horseshoe radius. Same geometry on mobile and desktop.
+const TYPING_LEFT_PAD_PX = VOJO_HORSESHOE_GAP_PX + VOJO_HORSESHOE_RADIUS_PX;
+
+// Shimmer-sweep: a bright band travels left → right over the line via
+// background-position. `background-clip: text` makes the gradient paint
+// only inside the glyphs, so the line reads as text with a subtle wave
+// of brightness — no extra dots, no UI widget.
+const Shimmer = keyframes({
+ from: { backgroundPosition: '100% 0' },
+ to: { backgroundPosition: '-100% 0' },
+});
+
+// Two colour stops: `dim` is the base muted-gray the text sits at most
+// of the time; `bright` is the highlight that sweeps across. Both
+// derived from the surface ink token via color-mix so they reshade
+// correctly across the dark / light themes.
+const dim = `color-mix(in srgb, ${color.Surface.OnContainer} 45%, transparent)`;
+const bright = color.Surface.OnContainer;
+
+export const TypingRow = style({
+ // Plain block — children are inline text/spans so the i18n strings'
+ // leading/trailing whitespace flows naturally. `display: flex` would
+ // wrap each text node in an anonymous flex item and strip the leading
+ // space in « печатает...», eating the gap between the name and verb.
+ display: 'block',
+ fontStyle: 'italic',
+ fontSize: toRem(13),
+ lineHeight: toRem(18),
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ paddingLeft: toRem(TYPING_LEFT_PAD_PX),
+ paddingRight: config.space.S400,
+ paddingTop: toRem(2),
+ paddingBottom: toRem(2),
+
+ backgroundImage: `linear-gradient(
+ 90deg,
+ ${dim} 0%,
+ ${dim} 35%,
+ ${bright} 50%,
+ ${dim} 65%,
+ ${dim} 100%
+ )`,
+ backgroundSize: '200% 100%',
+ // MUST stay `repeat` (default). Animation slides bg-position by one
+ // full pattern period (2× element width) per cycle. With `no-repeat`
+ // the gradient leaves the element at the end of each cycle — text
+ // goes fully transparent (color: transparent), then the keyframe
+ // teleports back to the start position, which reads as a hard flicker.
+ // With `repeat` the next pattern tile enters exactly as the previous
+ // exits → seamless loop.
+ backgroundRepeat: 'repeat',
+ // Paint the gradient only inside the text glyphs. Both prefixed and
+ // standard properties for cross-browser support — Safari still needs
+ // the -webkit- pair as of 2026.
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ color: 'transparent',
+ WebkitTextFillColor: 'transparent',
+ animation: `${Shimmer} 2.4s linear infinite`,
+
+ // Respect users who opted out of motion: drop the animation and the
+ // gradient, fall back to a plain muted line.
+ '@media': {
+ '(prefers-reduced-motion: reduce)': {
+ animation: 'none',
+ backgroundImage: 'none',
+ color: color.Surface.OnContainer,
+ opacity: 0.6,
+ WebkitTextFillColor: 'currentColor',
+ },
+ },
+});
+
+export const TypingName = style({
+ fontStyle: 'normal',
+ fontWeight: 500,
+});
diff --git a/src/app/features/room/RoomTimelineTyping.tsx b/src/app/features/room/RoomTimelineTyping.tsx
new file mode 100644
index 00000000..91c78774
--- /dev/null
+++ b/src/app/features/room/RoomTimelineTyping.tsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { Room } from 'matrix-js-sdk';
+import { useTranslation } from 'react-i18next';
+import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomTimelineTyping.css';
+
+export type RoomTimelineTypingProps = {
+ room: Room;
+};
+
+export function RoomTimelineTyping({ room }: RoomTimelineTypingProps) {
+ const { t } = useTranslation();
+ const mx = useMatrixClient();
+ const typing = useRoomTypingMember(room.roomId);
+
+ const myUserId = mx.getUserId();
+ const names = typing
+ .filter((r) => r.userId !== myUserId)
+ .map((r) => getMemberDisplayName(room, r.userId) ?? getMxIdLocalPart(r.userId) ?? r.userId);
+
+ if (names.length === 0) return null;
+
+ let line: React.ReactNode;
+ if (names.length === 1) {
+ line = (
+ <>
+ {names[0]}
+ {t('Room.is_typing')}
+ >
+ );
+ } else if (names.length === 2) {
+ line = (
+ <>
+ {names[0]}
+ {t('Room.and')}
+ {names[1]}
+ {t('Room.are_typing')}
+ >
+ );
+ } else if (names.length === 3) {
+ line = (
+ <>
+ {names[0]}
+ {', '}
+ {names[1]}
+ {t('Room.and')}
+ {names[2]}
+ {t('Room.are_typing')}
+ >
+ );
+ } else {
+ line = (
+ <>
+ {names[0]}
+ {', '}
+ {names[1]}
+ {', '}
+ {names[2]}
+ {t('Room.and')}
+
+ {t('Room.others_count', { count: names.length - 3 })}
+
+ {t('Room.are_typing')}
+ >
+ );
+ }
+
+ return (
+
+ {line}
+
+ );
+}
diff --git a/src/app/features/room/RoomViewTyping.css.ts b/src/app/features/room/RoomViewTyping.css.ts
deleted file mode 100644
index 467aa514..00000000
--- a/src/app/features/room/RoomViewTyping.css.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { keyframes, style } from '@vanilla-extract/css';
-import { DefaultReset, color, config } from 'folds';
-
-const SlideUpAnime = keyframes({
- from: {
- transform: 'translateY(100%)',
- },
- to: {
- transform: 'translateY(0)',
- },
-});
-
-export const RoomViewTyping = style([
- DefaultReset,
- {
- padding: `0 ${config.space.S500}`,
- // Lift typing text above the Android 3-button nav for the
- // composer-hidden window (thread drawer open / scroll-hide). The
- // main composer sits at `bottom: 0` with its own inset and fully
- // covers this strip at rest, so the duplicate inset is invisible
- // in normal flow.
- paddingBottom: 'env(safe-area-inset-bottom, 0px)',
- width: '100%',
- backgroundColor: color.SurfaceVariant.Container,
- color: color.Surface.OnContainer,
- position: 'absolute',
- bottom: 0,
- animation: `${SlideUpAnime} 100ms ease-in-out`,
- },
-]);
-export const TypingText = style({
- flexGrow: 1,
-});
diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx
deleted file mode 100644
index 41962ac4..00000000
--- a/src/app/features/room/RoomViewTyping.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import React from 'react';
-import { Box, Icon, IconButton, Icons, Text, as } from 'folds';
-import { Room } from 'matrix-js-sdk';
-import classNames from 'classnames';
-import { useTranslation } from 'react-i18next';
-import { useSetAtom } from 'jotai';
-import { roomIdToTypingMembersAtom } from '../../state/typingMembers';
-import { TypingIndicator } from '../../components/typing-indicator';
-import { getMemberDisplayName } from '../../utils/room';
-import { getMxIdLocalPart } from '../../utils/matrix';
-import * as css from './RoomViewTyping.css';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
-
-export type RoomViewTypingProps = {
- room: Room;
-};
-export const RoomViewTyping = as<'div', RoomViewTypingProps>(
- ({ className, room, ...props }, ref) => {
- const { t } = useTranslation();
- const setTypingMembers = useSetAtom(roomIdToTypingMembersAtom);
- const mx = useMatrixClient();
- const typingMembers = useRoomTypingMember(room.roomId);
-
- const typingNames = typingMembers
- .filter((receipt) => receipt.userId !== mx.getUserId())
- .map(
- (receipt) => getMemberDisplayName(room, receipt.userId) ?? getMxIdLocalPart(receipt.userId)
- )
- .reverse();
-
- if (typingNames.length === 0) {
- return null;
- }
-
- const handleDropAll = () => {
- // some homeserver does not timeout typing status
- // we have given option so user can drop their typing status
- typingMembers.forEach((receipt) =>
- setTypingMembers({
- type: 'DELETE',
- roomId: room.roomId,
- userId: receipt.userId,
- })
- );
- };
-
- return (
-
-
-
-
- {typingNames.length === 1 && (
- <>
- {typingNames[0]}
-
- {t('Room.is_typing')}
-
- >
- )}
- {typingNames.length === 2 && (
- <>
- {typingNames[0]}
-
- {t('Room.and')}
-
- {typingNames[1]}
-
- {t('Room.are_typing')}
-
- >
- )}
- {typingNames.length === 3 && (
- <>
- {typingNames[0]}
-
- {', '}
-
- {typingNames[1]}
-
- {t('Room.and')}
-
- {typingNames[2]}
-
- {t('Room.are_typing')}
-
- >
- )}
- {typingNames.length > 3 && (
- <>
- {typingNames[0]}
-
- {', '}
-
- {typingNames[1]}
-
- {', '}
-
- {typingNames[2]}
-
- {t('Room.and')}
-
- {t('Room.others_count', { count: typingNames.length - 3 })}
-
- {t('Room.are_typing')}
-
- >
- )}
-
-
-
-
-
-
- );
- }
-);