219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
// «Dawn-evolved» hero used at the top of the user-profile card.
|
|
// Visual language is borrowed from the design bundle in
|
|
// `docs/design/new-direct-messages-design`: centred large gradient
|
|
// avatar with an inset green online dot, display name in display
|
|
// weight, monospaced handle in muted, presence row with coloured
|
|
// status dot + last-seen line. The optional e2ee chip mirrors the
|
|
// chat header's lock+«e2ee» token so the surfaces feel like one
|
|
// system rather than two design dialects.
|
|
|
|
import React from 'react';
|
|
import { Box, Icon, Icons, Text } from 'folds';
|
|
import classNames from 'classnames';
|
|
import { useTranslation } from 'react-i18next';
|
|
import * as css from './styles.css';
|
|
import { UserAvatar } from '../user-avatar';
|
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
|
import { Presence, UserPresence } from '../../hooks/useUserPresence';
|
|
import {
|
|
timeDayMonYear,
|
|
timeHourMinute,
|
|
today as isToday,
|
|
yesterday as isYesterday,
|
|
} from '../../utils/time';
|
|
import colorMXID from '../../../util/colorMXID';
|
|
|
|
// Shade a `#rrggbb` hex by clamping each channel by `amt`. Borrowed
|
|
// from the design bundle's `shared.jsx::shade` helper. We use it to
|
|
// build the avatar's 135° gradient bottom-stop without needing the
|
|
// CSS `color-mix()` function (which isn't widely enough supported
|
|
// on old Android WebView builds — see fallbackBg comment below).
|
|
const shadeHex = (hex: string, amt: number): string => {
|
|
const cleaned = hex.replace('#', '');
|
|
if (cleaned.length !== 6) return hex;
|
|
const r = parseInt(cleaned.slice(0, 2), 16);
|
|
const g = parseInt(cleaned.slice(2, 4), 16);
|
|
const b = parseInt(cleaned.slice(4, 6), 16);
|
|
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return hex;
|
|
const clamp = (v: number) => Math.max(0, Math.min(255, v + amt));
|
|
const toByte = (v: number) => clamp(v).toString(16).padStart(2, '0');
|
|
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
|
|
};
|
|
|
|
// Bucketed last-seen formatter. Each bucket has its own i18n key so
|
|
// translators can phrase «just now», «N minutes ago», «yesterday at
|
|
// 14:32» and an absolute date independently — i18next's plural
|
|
// suffixes (`_one` / `_few` / `_many` / `_other`) handle Russian
|
|
// number agreement automatically. We deliberately do not use
|
|
// `Intl.RelativeTimeFormat` here because it follows the *browser*
|
|
// locale, not the i18next-selected language; mixing those gives us
|
|
// English «5 minutes ago» under a Russian UI.
|
|
const formatLastSeen = (
|
|
ts: number,
|
|
t: ReturnType<typeof useTranslation>['t']
|
|
): string => {
|
|
const now = Date.now();
|
|
const diffMs = Math.max(0, now - ts);
|
|
const mins = Math.floor(diffMs / 60_000);
|
|
if (mins < 1) return t('User.last_seen_just_now');
|
|
if (mins < 60) return t('User.last_seen_minutes', { count: mins });
|
|
const hours = Math.floor(diffMs / 3_600_000);
|
|
if (hours < 24 && isToday(ts)) {
|
|
return t('User.last_seen_hours', { count: hours });
|
|
}
|
|
if (isYesterday(ts)) {
|
|
return t('User.last_seen_yesterday', { time: timeHourMinute(ts) });
|
|
}
|
|
return t('User.last_seen_date', { date: timeDayMonYear(ts) });
|
|
};
|
|
|
|
type UserHeroProps = {
|
|
userId: string;
|
|
displayName?: string;
|
|
avatarUrl?: string;
|
|
presence?: UserPresence;
|
|
encrypted?: boolean;
|
|
onAvatarClick?: () => void;
|
|
// Desktop side-pane only. When true the avatar stretches to fill
|
|
// the card width as a square; the rest of the hero (name / presence
|
|
// / e2ee chip) stays in flow below. The online dot is suppressed in
|
|
// this mode — it makes no sense as a tiny corner glyph on a
|
|
// ~340px avatar tile.
|
|
avatarExpanded?: boolean;
|
|
};
|
|
|
|
export function UserHero({
|
|
userId,
|
|
displayName,
|
|
avatarUrl,
|
|
presence,
|
|
encrypted,
|
|
onAvatarClick,
|
|
avatarExpanded,
|
|
}: UserHeroProps) {
|
|
const { t } = useTranslation();
|
|
const username = getMxIdLocalPart(userId);
|
|
|
|
const online = presence?.presence === Presence.Online;
|
|
|
|
// Offline / idle states get the formatted last-seen line (with the
|
|
// grey dot prefix as a quiet «last activity» cue). Online state is
|
|
// rendered separately as a coloured «онлайн» tag matching the chat
|
|
// header — no dot prefix because the avatar already carries the
|
|
// green online indicator, and a duplicated dot reads as visual noise.
|
|
let presenceLabel: string | undefined;
|
|
if (!online) {
|
|
if (presence?.lastActiveTs && presence.lastActiveTs > 0) {
|
|
// Both Unavailable and Offline get last-seen detail when we
|
|
// have a real timestamp to anchor it. The SDK sometimes
|
|
// reports `lastActiveTs === 0` for users we've never seen —
|
|
// those still fall through to the plain «Idle / Offline»
|
|
// labels so we don't claim «last seen 1970».
|
|
presenceLabel = formatLastSeen(presence.lastActiveTs, t);
|
|
} else if (presence?.presence === Presence.Unavailable) {
|
|
presenceLabel = t('User.presence_unavailable');
|
|
} else if (presence) {
|
|
presenceLabel = t('User.presence_offline');
|
|
}
|
|
}
|
|
|
|
const initial = (username?.[0] ?? userId.replace(/^@/, '')[0] ?? '?').toUpperCase();
|
|
// The Dawn mockups use a 135° gradient on the avatar fallback —
|
|
// base colour to a slightly darker shade of itself. We compute
|
|
// the darker stop in JS rather than via CSS `color-mix(in srgb,
|
|
// …)` because the latter only ships in Chrome 111+ / Safari 16.2+
|
|
// / Firefox 113+, and Capacitor's bundled WebView on stale
|
|
// Android devices can be older than that — a missing `color-mix`
|
|
// would parse the gradient as `none` and the fallback would
|
|
// render flat black.
|
|
const fallbackColor = colorMXID(userId);
|
|
const fallbackBg = `linear-gradient(135deg, ${fallbackColor}, ${shadeHex(fallbackColor, -22)})`;
|
|
|
|
const avatarNode = (
|
|
<span
|
|
className={classNames(css.HeroAvatar, avatarExpanded && css.HeroAvatarExpanded)}
|
|
aria-hidden={onAvatarClick ? undefined : true}
|
|
>
|
|
<UserAvatar
|
|
userId={userId}
|
|
src={avatarUrl}
|
|
alt={displayName ?? userId}
|
|
renderFallback={() => (
|
|
<span className={css.HeroAvatarFallback} style={{ backgroundImage: fallbackBg }}>
|
|
{initial}
|
|
</span>
|
|
)}
|
|
/>
|
|
{online && !avatarExpanded && <span className={css.HeroOnlineDot} aria-hidden />}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<Box direction="Column" alignItems="Center" gap="200" className={css.HeroRoot}>
|
|
{onAvatarClick ? (
|
|
<button
|
|
type="button"
|
|
onClick={onAvatarClick}
|
|
className={classNames(
|
|
css.HeroAvatarButton,
|
|
avatarExpanded && css.HeroAvatarButtonExpanded
|
|
)}
|
|
aria-label={t(
|
|
avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar',
|
|
{ defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar' }
|
|
)}
|
|
>
|
|
{avatarNode}
|
|
</button>
|
|
) : (
|
|
avatarNode
|
|
)}
|
|
|
|
<Box direction="Column" alignItems="Center" gap="100" className={css.HeroIdentity}>
|
|
<Text
|
|
size="H3"
|
|
align="Center"
|
|
className={classNames(BreakWord, LineClamp3)}
|
|
title={displayName ?? username ?? userId}
|
|
>
|
|
{displayName ?? username ?? userId}
|
|
</Text>
|
|
</Box>
|
|
|
|
{(online || presenceLabel || encrypted) && (
|
|
<Box alignItems="Center" gap="200" className={css.HeroPresence}>
|
|
{online ? (
|
|
<Text as="span" size="T200" className={css.HeroOnlineTag}>
|
|
{t('Room.status_online')}
|
|
</Text>
|
|
) : (
|
|
presenceLabel && (
|
|
<>
|
|
<span className={css.PresenceDot} aria-hidden />
|
|
<Text as="span" size="T200" priority="300">
|
|
{presenceLabel}
|
|
</Text>
|
|
</>
|
|
)
|
|
)}
|
|
{encrypted && (
|
|
<>
|
|
{(online || presenceLabel) && (
|
|
<span className={css.HeroBullet} aria-hidden>
|
|
·
|
|
</span>
|
|
)}
|
|
<span className={css.HeroE2ee}>
|
|
<Icon size="50" src={Icons.Lock} filled />
|
|
<Text as="span" size="T200">
|
|
{t('Room.encrypted_short')}
|
|
</Text>
|
|
</span>
|
|
</>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|