// «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['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 = ( ( {initial} )} /> {online && !avatarExpanded && } ); return ( {onAvatarClick ? ( ) : ( avatarNode )} {displayName ?? username ?? userId} {(online || presenceLabel || encrypted) && ( {online ? ( {t('Room.status_online')} ) : ( presenceLabel && ( <> {presenceLabel} ) )} {encrypted && ( <> {(online || presenceLabel) && ( · )} {t('Room.encrypted_short')} )} )} ); }