feat(typing): replace composer-covered overlay strip with inline shimmer system-line in the timeline, aligned to the composer's left horseshoe curve

This commit is contained in:
heaven 2026-05-28 02:03:26 +03:00
parent 61fdf06126
commit 9a9880d63c
5 changed files with 169 additions and 156 deletions

View file

@ -124,6 +124,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { RoomTimelineTyping } from './RoomTimelineTyping';
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
@ -2406,6 +2407,7 @@ export function RoomTimeline({
</MessageBase> </MessageBase>
</> </>
)} )}
{liveTimelineLinked && rangeAtEnd && <RoomTimelineTyping room={room} />}
<span ref={atBottomAnchorRef} /> <span ref={atBottomAnchorRef} />
</Box> </Box>
</Scroll> </Scroll>

View file

@ -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,
});

View file

@ -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 = (
<>
<span className={css.TypingName}>{names[0]}</span>
{t('Room.is_typing')}
</>
);
} else if (names.length === 2) {
line = (
<>
<span className={css.TypingName}>{names[0]}</span>
{t('Room.and')}
<span className={css.TypingName}>{names[1]}</span>
{t('Room.are_typing')}
</>
);
} else if (names.length === 3) {
line = (
<>
<span className={css.TypingName}>{names[0]}</span>
{', '}
<span className={css.TypingName}>{names[1]}</span>
{t('Room.and')}
<span className={css.TypingName}>{names[2]}</span>
{t('Room.are_typing')}
</>
);
} else {
line = (
<>
<span className={css.TypingName}>{names[0]}</span>
{', '}
<span className={css.TypingName}>{names[1]}</span>
{', '}
<span className={css.TypingName}>{names[2]}</span>
{t('Room.and')}
<span className={css.TypingName}>
{t('Room.others_count', { count: names.length - 3 })}
</span>
{t('Room.are_typing')}
</>
);
}
return (
<div className={css.TypingRow} aria-live="polite">
{line}
</div>
);
}

View file

@ -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,
});

View file

@ -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 (
<div style={{ position: 'relative' }}>
<Box
className={classNames(css.RoomViewTyping, className)}
alignItems="Center"
gap="400"
{...props}
ref={ref}
>
<TypingIndicator />
<Text className={css.TypingText} size="T300" truncate>
{typingNames.length === 1 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.is_typing')}
</Text>
</>
)}
{typingNames.length === 2 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.and')}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.are_typing')}
</Text>
</>
)}
{typingNames.length === 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.and')}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.are_typing')}
</Text>
</>
)}
{typingNames.length > 3 && (
<>
<b>{typingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{typingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.and')}
</Text>
<b>{t('Room.others_count', { count: typingNames.length - 3 })}</b>
<Text as="span" size="Inherit" priority="300">
{t('Room.are_typing')}
</Text>
</>
)}
</Text>
<IconButton title={t('Room.drop_typing')} size="300" radii="Pill" onClick={handleDropAll}>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
</div>
);
}
);