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:
parent
61fdf06126
commit
9a9880d63c
5 changed files with 169 additions and 156 deletions
|
|
@ -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({
|
|||
</MessageBase>
|
||||
</>
|
||||
)}
|
||||
{liveTimelineLinked && rangeAtEnd && <RoomTimelineTyping room={room} />}
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
|
|
|
|||
91
src/app/features/room/RoomTimelineTyping.css.ts
Normal file
91
src/app/features/room/RoomTimelineTyping.css.ts
Normal 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,
|
||||
});
|
||||
76
src/app/features/room/RoomTimelineTyping.tsx
Normal file
76
src/app/features/room/RoomTimelineTyping.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue