vojo/src/app/components/user-profile/UserHero.tsx

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