From 9a9880d63c18b15ed0f0d774af59b439983f5d3b Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 28 May 2026 02:03:26 +0300 Subject: [PATCH] feat(typing): replace composer-covered overlay strip with inline shimmer system-line in the timeline, aligned to the composer's left horseshoe curve --- src/app/features/room/RoomTimeline.tsx | 2 + .../features/room/RoomTimelineTyping.css.ts | 91 +++++++++++++ src/app/features/room/RoomTimelineTyping.tsx | 76 +++++++++++ src/app/features/room/RoomViewTyping.css.ts | 33 ----- src/app/features/room/RoomViewTyping.tsx | 123 ------------------ 5 files changed, 169 insertions(+), 156 deletions(-) create mode 100644 src/app/features/room/RoomTimelineTyping.css.ts create mode 100644 src/app/features/room/RoomTimelineTyping.tsx delete mode 100644 src/app/features/room/RoomViewTyping.css.ts delete mode 100644 src/app/features/room/RoomViewTyping.tsx 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')} - - - )} - - - - - -
- ); - } -);