diff --git a/public/locales/en.json b/public/locales/en.json index 6ce2dd45..82433816 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -407,6 +407,8 @@ "open_call_room": "Open call room" }, "Room": { + "drag_to_close": "Drag up to close", + "collapse_avatar": "Collapse avatar", "new_messages": "New Messages", "jump_to_unread": "Jump to Unread", "mark_as_read": "Mark as Read", @@ -911,5 +913,40 @@ }, "unknown_title": "Robot not found", "unknown_description": "This robot is not in the Vojo catalog." + }, + "User": { + "message": "Message", + "call": "Call", + "more": "More", + "block": "Block User", + "unblock": "Unblock User", + "blocked_title": "Blocked User", + "blocked_description": "You do not receive any messages or invites from this user.", + "profile_title": "Profile", + "presence_online": "Online", + "presence_unavailable": "Idle", + "presence_offline": "Offline", + "last_seen_just_now": "Last seen just now", + "last_seen_minutes_one": "Last seen {{count}} minute ago", + "last_seen_minutes_other": "Last seen {{count}} minutes ago", + "last_seen_hours_one": "Last seen {{count}} hour ago", + "last_seen_hours_other": "Last seen {{count}} hours ago", + "last_seen_yesterday": "Last seen yesterday at {{time}}", + "last_seen_date": "Last seen {{date}}", + "row_id": "id", + "row_server": "server", + "row_role": "role", + "row_mutual": "shared", + "row_mutual_loading": "Loading…", + "row_mutual_spaces": "Spaces", + "row_mutual_rooms": "Rooms", + "row_mutual_dms": "Direct messages", + "row_mutual_count_one": "{{count}} chat", + "row_mutual_count_other": "{{count}} chats", + "copy_user_id": "Copy user ID", + "copy_user_link": "Copy user link", + "copy_server": "Copy server", + "explore_community": "Explore community", + "open_in_browser": "Open in browser" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index d584bf4a..edaf42f0 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -409,6 +409,8 @@ "open_call_room": "Открыть чат звонка" }, "Room": { + "drag_to_close": "Потянуть вверх чтобы закрыть", + "collapse_avatar": "Свернуть аватар", "new_messages": "Новые сообщения", "jump_to_unread": "К непрочитанным", "mark_as_read": "Отметить прочитанным", @@ -915,5 +917,43 @@ }, "unknown_title": "Робот не найден", "unknown_description": "Этого робота нет в каталоге Vojo." + }, + "User": { + "message": "Написать", + "call": "Позвонить", + "more": "Ещё", + "block": "Заблокировать", + "unblock": "Разблокировать", + "blocked_title": "Заблокирован", + "blocked_description": "Сообщения и приглашения от этого пользователя не приходят.", + "profile_title": "Профиль", + "presence_online": "В сети", + "presence_unavailable": "Не активен", + "presence_offline": "Не в сети", + "last_seen_just_now": "Был в сети только что", + "last_seen_minutes_one": "Был в сети {{count}} минуту назад", + "last_seen_minutes_few": "Был в сети {{count}} минуты назад", + "last_seen_minutes_many": "Был в сети {{count}} минут назад", + "last_seen_hours_one": "Был в сети {{count}} час назад", + "last_seen_hours_few": "Был в сети {{count}} часа назад", + "last_seen_hours_many": "Был в сети {{count}} часов назад", + "last_seen_yesterday": "Был в сети вчера в {{time}}", + "last_seen_date": "Был в сети {{date}}", + "row_id": "id", + "row_server": "сервер", + "row_role": "роль", + "row_mutual": "общие", + "row_mutual_loading": "Загрузка…", + "row_mutual_spaces": "Пространства", + "row_mutual_rooms": "Чаты", + "row_mutual_dms": "Личные", + "row_mutual_count_one": "{{count}} чат", + "row_mutual_count_few": "{{count}} чата", + "row_mutual_count_many": "{{count}} чатов", + "copy_user_id": "Скопировать ID", + "copy_user_link": "Скопировать ссылку", + "copy_server": "Скопировать сервер", + "explore_community": "Открыть сервер", + "open_in_browser": "Открыть в браузере" } } diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx deleted file mode 100644 index ca7aa837..00000000 --- a/src/app/components/UserRoomProfileRenderer.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { Menu, PopOut, toRem } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile'; -import { UserRoomProfile } from './user-profile'; -import { UserRoomProfileState } from '../state/userRoomProfile'; -import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom'; -import { stopPropagation } from '../utils/keyboard'; -import { SpaceProvider } from '../hooks/useSpace'; -import { RoomProvider } from '../hooks/useRoom'; - -function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { - const { roomId, spaceId, userId, cords, position } = state; - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); - const room = getRoom(roomId); - const space = spaceId ? getRoom(spaceId) : undefined; - - const close = useCloseUserRoomProfile(); - - if (!room) return null; - - return ( - - - - - - - - - - } - /> - ); -} - -export function UserRoomProfileRenderer() { - const state = useUserRoomProfileState(); - - if (!state) return null; - return ; -} diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 581df34f..36f15f1d 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -1,439 +1,58 @@ -import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; -import { useAtomValue } from 'jotai'; -import { useNavigate } from 'react-router-dom'; +// Action surfaces rendered alongside the user-profile card after +// the Dawn redesign: +// +// - `UserActionsMenu` — single 3-dot pill anchored to the +// top-right of the card. Hosts every imperative action we have +// (start DM in groups, block / unblock, kick / ban / invite for +// mods). Replaces the old bottom actions row, which on mobile +// overflowed past the rail height — the user couldn't reach +// Block without discovering a hidden scroll. +// - `IgnoredUserAlert` — banner shown when the viewer has the +// profile target on their ignore list. Pure information, no +// action of its own. +// +// The legacy chip-strip components (Server / Share / MutualRooms) +// were lifted into `UserInfoRows.tsx` as Fleet-style attribute rows +// and are intentionally not re-exported here. + +import React, { MouseEventHandler, useCallback, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; -import { Room } from 'matrix-js-sdk'; import { - PopOut, + Box, + Icon, + IconButton, + Icons, + Line, Menu, MenuItem, - config, - Text, - Line, - Chip, - Icon, - Icons, + PopOut, RectCords, Spinner, + Text, + config, toRem, - Box, - Scroll, - Avatar, } from 'folds'; +import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMxIdServer } from '../../utils/matrix'; -import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { stopPropagation } from '../../utils/keyboard'; -import { copyToClipboard } from '../../utils/dom'; -import { getExploreServerPath } from '../../pages/pathUtils'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { factoryRoomIdByAtoZ } from '../../utils/sort'; -import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms'; -import { useRoomNavigate } from '../../hooks/useRoomNavigate'; -import { mDirectAtom } from '../../state/mDirectList'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { openExternalUrl } from '../../utils/capacitor'; -import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; -import { RoomAvatar, RoomIcon } from '../room-avatar'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; -import { nameInitials } from '../../utils/common'; -import { getMatrixToUser } from '../../plugins/matrix-to'; -import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; - -export function ServerChip({ server }: { server: string }) { - const mx = useMatrixClient(); - const myServer = getMxIdServer(mx.getSafeUserId()); - const navigate = useNavigate(); - const closeProfile = useCloseUserRoomProfile(); - const [copied, setCopied] = useTimeoutToggle(); - - const [cords, setCords] = useState(); - - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const close = () => setCords(undefined); - - return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - -
- { - copyToClipboard(server); - setCopied(); - close(); - }} - > - Copy Server - - { - navigate(getExploreServerPath(server)); - closeProfile(); - }} - > - Explore Community - -
- -
- { - openExternalUrl(`https://${server}`); - close(); - }} - > - Open in Browser - -
-
- - } - > - - ) : ( - - ) - } - onClick={open} - aria-pressed={!!cords} - > - - {server} - - -
- ); -} - -export function ShareChip({ userId }: { userId: string }) { - const [cords, setCords] = useState(); - - const [copied, setCopied] = useTimeoutToggle(); - - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const close = () => setCords(undefined); - - return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - -
- { - copyToClipboard(userId); - setCopied(); - close(); - }} - > - Copy User ID - - { - copyToClipboard(getMatrixToUser(userId)); - setCopied(); - close(); - }} - > - Copy User Link - -
-
- - } - > - - ) : ( - - ) - } - onClick={open} - aria-pressed={!!cords} - > - - Share - - -
- ); -} - -type MutualRoomsData = { - rooms: Room[]; - spaces: Room[]; - directs: Room[]; -}; - -export function MutualRoomsChip({ userId }: { userId: string }) { - const mx = useMatrixClient(); - const mutualRoomSupported = useMutualRoomsSupport(); - const mutualRoomsState = useMutualRooms(userId); - const { navigateRoom, navigateSpace } = useRoomNavigate(); - const closeUserRoomProfile = useCloseUserRoomProfile(); - // Read m.direct directly here, NOT useDirectRooms — after P3c the latter - // returns ALL non-space rooms (universal Direct list), which would collapse - // the «mutual DMs» vs «mutual rooms» split in this profile sheet. - const mDirects = useAtomValue(mDirectAtom); - const useAuthentication = useMediaAuthentication(); - - const allJoinedRooms = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allJoinedRooms); - - const [cords, setCords] = useState(); - - const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const close = () => setCords(undefined); - - const mutual: MutualRoomsData = useMemo(() => { - const data: MutualRoomsData = { - rooms: [], - spaces: [], - directs: [], - }; - - if (mutualRoomsState.status === AsyncStatus.Success) { - const mutualRooms = mutualRoomsState.data - .sort(factoryRoomIdByAtoZ(mx)) - .map(getRoom) - .filter((room) => !!room); - mutualRooms.forEach((room) => { - if (room.isSpaceRoom()) { - data.spaces.push(room); - return; - } - if (mDirects.has(room.roomId)) { - data.directs.push(room); - return; - } - data.rooms.push(room); - }); - } - return data; - }, [mutualRoomsState, getRoom, mDirects, mx]); - - if ( - userId === mx.getSafeUserId() || - !mutualRoomSupported || - mutualRoomsState.status === AsyncStatus.Error - ) { - return null; - } - - const renderItem = (room: Room) => { - const { roomId } = room; - const dm = mDirects.has(roomId); - - return ( - { - if (room.isSpaceRoom()) { - navigateSpace(roomId); - } else { - navigateRoom(roomId); - } - closeUserRoomProfile(); - }} - before={ - - {dm || room.isSpaceRoom() ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - - )} - - } - > - - {room.name} - - - ); - }; - - return ( - isKeyHotkey('arrowdown', evt), - isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), - }} - > - - - - - {mutual.spaces.length > 0 && ( - - - Spaces - - {mutual.spaces.map(renderItem)} - - )} - {mutual.rooms.length > 0 && ( - - - Rooms - - {mutual.rooms.map(renderItem)} - - )} - {mutual.directs.length > 0 && ( - - - Direct Messages - - {mutual.directs.map(renderItem)} - - )} - - - - - - ) : null - } - > - } - disabled={ - mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0 - } - onClick={open} - aria-pressed={!!cords} - > - - {mutualRoomsState.status === AsyncStatus.Success && - `${mutualRoomsState.data.length} Mutual Rooms`} - {mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'} - - - - ); -} +import { UserModeration } from './UserModeration'; export function IgnoredUserAlert() { + const { t } = useTranslation(); return ( - Blocked User + {t('User.blocked_title')} - You do not receive any messages or invites from this user. + {t('User.blocked_description')} @@ -441,14 +60,52 @@ export function IgnoredUserAlert() { ); } -export function OptionsChip({ userId }: { userId: string }) { +type UserActionsMenuProps = { + userId: string; + // Whether to surface «Написать» (start a private DM) — true only + // in group rooms when the target isn't the viewer themselves; in + // 1:1 the user is already in the conversation, so the action is a + // no-op. + showStartDm?: boolean; + onStartDm?: () => void; + // Room-moderation gates. Each is independently true/false; if all + // three are false the moderation block is suppressed entirely so + // the menu doesn't carry a dangling separator. + canKick?: boolean; + canBan?: boolean; + canInvite?: boolean; +}; + +// 3-dot pill containing every imperative action for the profile +// target. Anchored top-right of the card by the parent layout. +// +// Block / Unblock lives here too — earlier I tried promoting it to +// a standalone chip in a bottom actions row, but on mobile the rail +// is ~42vh and that row scrolled below the visible rail edge, so +// users couldn't reach it. Putting Block back into the menu keeps +// the action accessible at the same vertical position regardless of +// content length. +export function UserActionsMenu({ + userId, + showStartDm, + onStartDm, + canKick, + canBan, + canInvite, +}: UserActionsMenuProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const [cords, setCords] = useState(); const open: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); + // Anchor the popout to the trigger's TOP edge with zero height + // — `position="Bottom"` then drops the menu starting from that + // edge, so it visually covers the 3-dot button instead of + // floating below it. Looks tighter on mobile where space is + // tight; the trigger naturally re-emerges when the menu closes. + const rect = evt.currentTarget.getBoundingClientRect(); + setCords({ x: rect.x, y: rect.y, width: rect.width, height: 0 }); }; - const close = () => setCords(undefined); const ignoredUsers = useIgnoredUsers(); @@ -456,19 +113,24 @@ export function OptionsChip({ userId }: { userId: string }) { const [ignoreState, toggleIgnore] = useAsyncCallback( useCallback(async () => { - const users = ignoredUsers.filter((u) => u !== userId); - if (!ignored) users.push(userId); - await mx.setIgnoredUsers(users); + const next = ignoredUsers.filter((u) => u !== userId); + if (!ignored) next.push(userId); + await mx.setIgnoredUsers(next); }, [mx, ignoredUsers, userId, ignored]) ); const ignoring = ignoreState.status === AsyncStatus.Loading; + const moderationVisible = !!(canKick || canBan || canInvite); + return ( isKeyHotkey('arrowup', evt), }} > - + + {showStartDm && onStartDm && ( +
+ { + onStartDm(); + close(); + }} + before={} + > + {t('User.message')} + +
+ )} + {showStartDm && onStartDm && }
- {ignored ? 'Unblock User' : 'Block User'} + {ignored ? t('User.unblock') : t('User.block')}
+ {moderationVisible && ( + <> + +
+ +
+ + )}
} > - - {ignoring ? ( - - ) : ( - - )} - + + + ); } diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 0e7fb748..f10d1f73 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -1,120 +1,203 @@ -import React, { useState } from 'react'; -import { - Avatar, - Box, - Icon, - Icons, - Modal, - Overlay, - OverlayBackdrop, - OverlayCenter, - Text, -} from 'folds'; +// «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 FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import * as css from './styles.css'; import { UserAvatar } from '../user-avatar'; -import colorMXID from '../../../util/colorMXID'; import { getMxIdLocalPart } from '../../utils/matrix'; import { BreakWord, LineClamp3 } from '../../styles/Text.css'; -import { UserPresence } from '../../hooks/useUserPresence'; -import { AvatarPresence, PresenceBadge } from '../presence'; -import { ImageViewer } from '../image-viewer'; -import { stopPropagation } from '../../utils/keyboard'; +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; }; -export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { - const [viewAvatar, setViewAvatar] = useState(); - return ( - -
- {avatarUrl && ( - {userId} - )} -
-
- - } - > - setViewAvatar(avatarUrl) : undefined} - className={css.UserHeroAvatar} - size="500" - > - } - /> - - - {viewAvatar && ( - }> - - setViewAvatar(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - evt.stopPropagation()}> - setViewAvatar(undefined)} - /> - - - - - )} -
-
- ); -} - -type UserHeroNameProps = { - displayName?: string; - userId: string; -}; -export function UserHeroName({ displayName, userId }: UserHeroNameProps) { +export function UserHero({ + userId, + displayName, + avatarUrl, + presence, + encrypted, + onAvatarClick, +}: 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 && } + + ); + return ( - - + + {onAvatarClick ? ( + + ) : ( + avatarNode + )} + + {displayName ?? username ?? userId} - - - @{username} - - + + {(online || presenceLabel || encrypted) && ( + + {online ? ( + + {t('Room.status_online')} + + ) : ( + presenceLabel && ( + <> + + + {presenceLabel} + + + ) + )} + {encrypted && ( + <> + {(online || presenceLabel) && ( + + · + + )} + + + + {t('Room.encrypted_short')} + + + + )} + + )} ); } diff --git a/src/app/components/user-profile/UserInfoRows.tsx b/src/app/components/user-profile/UserInfoRows.tsx new file mode 100644 index 00000000..a69a1601 --- /dev/null +++ b/src/app/components/user-profile/UserInfoRows.tsx @@ -0,0 +1,480 @@ +// Fleet-style info section for the user-profile card. +// +// Each row is a single line: monospace label on the left in a fixed +// gutter, the actual value in the middle (truncates), and an optional +// trailing affordance (copy / open / chevron-menu) on the right. The +// visual is borrowed from the Dawn design bundle's IDE-attribute +// rows — the goal is to fill the card with structured information +// rather than a strip of horizontally-scattered chips. +// +// Each interactive row owns its own popout menu (re-using the +// menu content patterns the legacy `UserChips` exposed). The hosts +// (`UserRoomProfile`) just stitch them together; they don't know +// about menu state. + +import React, { MouseEventHandler, useMemo, useState } from 'react'; +import { + Avatar, + Box, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Scroll, + Spinner, + Text, + config, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { isKeyHotkey } from 'is-hotkey'; +import { Room } from 'matrix-js-sdk'; +import { useAtomValue } from 'jotai'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useTimeoutToggle } from '../../hooks/useTimeoutToggle'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { mDirectAtom } from '../../state/mDirectList'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { copyToClipboard } from '../../utils/dom'; +import { stopPropagation } from '../../utils/keyboard'; +import { factoryRoomIdByAtoZ } from '../../utils/sort'; +import { openExternalUrl } from '../../utils/capacitor'; +import { getMxIdServer } from '../../utils/matrix'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; +import { nameInitials } from '../../utils/common'; +import { getExploreServerPath } from '../../pages/pathUtils'; +import { getMatrixToUser } from '../../plugins/matrix-to'; +import { AsyncStatus } from '../../hooks/useAsyncCallback'; +import { RoomAvatar, RoomIcon } from '../room-avatar'; +import * as css from './styles.css'; + +// ── Generic row primitive ──────────────────────────────────────── + +type InfoRowProps = { + label: string; + value: React.ReactNode; + // Right-side affordance. Either an inline button (e.g. a copy + // glyph) or a popout-menu trigger pulled from one of the row + // variants below. + trailing?: React.ReactNode; + // Tooltip for the value (typically the full untruncated text). + title?: string; +}; + +export function InfoRow({ label, value, trailing, title }: InfoRowProps) { + return ( +
+ {label} + + {value} + + {trailing && {trailing}} +
+ ); +} + +// ── id (handle + share menu) ───────────────────────────────────── + +export function IdRow({ userId }: { userId: string }) { + const { t } = useTranslation(); + const [cords, setCords] = useState(); + const [copied, setCopied] = useTimeoutToggle(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + const close = () => setCords(undefined); + + const handle = userId.replace(/^@/, ''); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + copyToClipboard(userId); + setCopied(); + close(); + }} + > + {t('User.copy_user_id')} + + { + copyToClipboard(getMatrixToUser(userId)); + setCopied(); + close(); + }} + > + {t('User.copy_user_link')} + +
+
+ + } + > + + + + } + /> +
+ ); +} + +// ── server ─────────────────────────────────────────────────────── + +export function ServerRow({ userId }: { userId: string }) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const navigate = useNavigate(); + const closeProfile = useCloseUserRoomProfile(); + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + const close = () => setCords(undefined); + + const server = getMxIdServer(userId); + const myServer = getMxIdServer(mx.getSafeUserId()); + if (!server) return null; + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + +
+ { + copyToClipboard(server); + close(); + }} + > + {t('User.copy_server')} + + { + navigate(getExploreServerPath(server)); + closeProfile(); + }} + > + {t('User.explore_community')} + + { + openExternalUrl(`https://${server}`); + close(); + }} + > + {t('User.open_in_browser')} + +
+
+ + } + > + + + + } + /> +
+ ); +} + +// ── role (info-only) ───────────────────────────────────────────── + +type RoleRowProps = { + roleLabel: string; + // Render the role in an accent style — used for creators since + // they sit above the regular power-level ladder. + emphasis?: boolean; +}; + +// Role row — just the localised role label («Admin», «Moderator», +// «Member», или кастомный tag.name в комнатах со своей PL-таблицей). +// We intentionally don't surface the raw power-level number: «pl N» +// is Matrix protocol jargon that doesn't help end users, and +// duplicates what the role label already conveys for the standard +// 0 / 50 / 100 ladder. +export function RoleRow({ roleLabel, emphasis }: RoleRowProps) { + const { t } = useTranslation(); + const value = emphasis ? ( + + + {roleLabel} + + ) : ( + {roleLabel} + ); + return ; +} + +// ── mutual rooms ───────────────────────────────────────────────── + +type MutualRoomsData = { + rooms: Room[]; + spaces: Room[]; + directs: Room[]; +}; + +export function MutualRoomsRow({ userId }: { userId: string }) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const supported = useMutualRoomsSupport(); + const state = useMutualRooms(userId); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + const closeProfile = useCloseUserRoomProfile(); + // Read m.direct directly here, NOT useDirectRooms — the latter + // returns universal-Direct after P3c, which would collapse the + // mutual-DMs vs mutual-rooms split that this menu surfaces. + const mDirects = useAtomValue(mDirectAtom); + const useAuthentication = useMediaAuthentication(); + + const allJoined = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allJoined); + + const [cords, setCords] = useState(); + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + const close = () => setCords(undefined); + + const mutual: MutualRoomsData = useMemo(() => { + const data: MutualRoomsData = { rooms: [], spaces: [], directs: [] }; + if (state.status === AsyncStatus.Success) { + // Copy the array before sorting — `Array.prototype.sort` + // mutates in place, and `state.data` is the same reference + // returned by `useMutualRooms` to every caller. Sorting the + // backing store would silently reorder it for any other + // consumer of the same response. + [...state.data] + .sort(factoryRoomIdByAtoZ(mx)) + .map(getRoom) + .filter((r): r is Room => !!r) + .forEach((room) => { + if (room.isSpaceRoom()) data.spaces.push(room); + else if (mDirects.has(room.roomId)) data.directs.push(room); + else data.rooms.push(room); + }); + } + return data; + }, [state, getRoom, mDirects, mx]); + + if (!supported || state.status === AsyncStatus.Error) return null; + const total = state.status === AsyncStatus.Success ? state.data.length : 0; + const loading = state.status === AsyncStatus.Loading; + + const renderItem = (room: Room) => { + const { roomId } = room; + const dm = mDirects.has(roomId); + return ( + { + if (room.isSpaceRoom()) navigateSpace(roomId); + else navigateRoom(roomId); + closeProfile(); + }} + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + {room.name} + + + ); + }; + + const value = loading ? ( + + + {t('User.row_mutual_loading')} + + ) : ( + t('User.row_mutual_count', { count: total }) + ); + + return ( + isKeyHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), + }} + > + + + + + {mutual.spaces.length > 0 && ( + + + {t('User.row_mutual_spaces')} + + {mutual.spaces.map(renderItem)} + + )} + {mutual.rooms.length > 0 && ( + + + {t('User.row_mutual_rooms')} + + {mutual.rooms.map(renderItem)} + + )} + {mutual.directs.length > 0 && ( + + + {t('User.row_mutual_dms')} + + {mutual.directs.map(renderItem)} + + )} + + + + + + ) : null + } + > + + + + } + /> + + ); +} + diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78d201ec..1fff848d 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,32 +1,53 @@ -import { Box, Button, config, Icon, Icons, Text } from 'folds'; +// Dawn-evolved user profile card. Replaces the legacy chip-strip +// layout (large Message + Call buttons → horizontal chip strip) +// with a denser, IDE-style information card: +// +// • Hero — large gradient avatar, display name, monospaced +// handle, presence + last-seen, optional e2ee badge. +// • Info rows — Fleet-style attribute table: id / server / role / +// mutual chats. Each row has a trailing affordance +// (popout menu) when there's something useful to do. +// • Actions — single «Написать» chip in group rooms only +// (1:1 → user is already in the conversation), +// plus the three-dot moderation menu (`OptionsChip`). +// • Alerts — banner cards for ignored / banned / kicked / +// invited states. Same matrix-js-sdk source as +// before, just rendered after the redesigned +// surface. + import React from 'react'; +import { Box } from 'folds'; import { useNavigate } from 'react-router-dom'; -import { UserHero, UserHeroName } from './UserHero'; -import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; +import { UserHero } from './UserHero'; +import { IdRow, MutualRoomsRow, RoleRow, ServerRow } from './UserInfoRows'; +import { mxcUrlToHttp } from '../../utils/matrix'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePowerLevels } from '../../hooks/usePowerLevels'; -import { useRoom } from '../../hooks/useRoom'; +import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; -import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { IgnoredUserAlert, UserActionsMenu } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; -import { PowerChip } from './PowerChip'; -import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; +import { UserBanAlert, UserInviteAlert, UserKickAlert } from './UserModeration'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useMembership } from '../../hooks/useMembership'; -import { Membership } from '../../../types/matrix/room'; +import { Membership, StateEvent } from '../../../types/matrix/room'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; -import { CreatorChip } from './CreatorChip'; +import { useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; +import * as css from './styles.css'; type UserRoomProfileProps = { userId: string; + onAvatarClick?: () => void; }; -export function UserRoomProfile({ userId }: UserRoomProfileProps) { + +export function UserRoomProfile({ userId, onAvatarClick }: UserRoomProfileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const navigate = useNavigate(); @@ -40,6 +61,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const permissions = useRoomPermissions(creators, powerLevels); const { hasMorePower } = useMemberPowerCompare(creators, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); const myUserId = mx.getSafeUserId(); const creator = creators.has(userId); @@ -52,91 +74,107 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const member = room.getMember(userId); const membership = useMembership(room, userId); - const server = getMxIdServer(userId); const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + // Pass the raw SDK presence through. `UserHero` already guards on + // `lastActiveTs > 0` before formatting the last-seen line, so the + // «1970» misread is suppressed there. Filtering at this layer + // would also drop the *online* signal for users whose + // `lastActiveTs` happens to be 0 on a fresh sync — they'd lose + // their green dot and «онлайн» tag for no good reason. const presence = useUserPresence(userId); + const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); + const encrypted = !!encryptionEvent; + + const isOneOnOne = useIsOneOnOne(); + const showStartDmChip = !isOneOnOne && userId !== myUserId; + + // Power-tag label resolution: the room's power-level tags map a + // numeric pl to a localised role name (e.g. «admin», «moderator», + // «member»). The hook handles the creator → «Создатель» override + // internally via `useRoomCreatorsTag`, so we just take the tag's + // `.name` and display it. The raw `pl N` number is intentionally + // not surfaced — it's Matrix protocol jargon that's redundant + // with the role label for end users. + const tag = getMemberPowerTag(userId); + const roleLabel = tag.name; + const handleMessage = () => { closeUserRoomProfile(); - const directSearchParam: DirectCreateSearchParams = { - userId, - }; - navigate(withSearchParam(getDirectCreatePath(), directSearchParam)); + const params: DirectCreateSearchParams = { userId }; + navigate(withSearchParam(getDirectCreatePath(), params)); }; return ( - + + {/* Top-right action menu — absolute-positioned so it floats + above the hero without claiming a layout row. Hosts every + imperative action (Write / Block / Mod). On mobile the + rail is short enough that a bottom actions row scrolled + off-screen; floating it at a fixed corner of the card + fixes that. Suppressed on self-profile since none of the + items are meaningful there. */} + {userId !== myUserId && ( +
+ +
+ )} + - - - - - {userId !== myUserId && ( - - - - )} - - - {server && } - - {creator ? : } - {userId !== myUserId && } - {userId !== myUserId && } - - - {ignored && } - {member && membership === Membership.Ban && ( - - )} - {member && - membership === Membership.Leave && - member.events.member && - member.events.member.getSender() !== userId && ( - - )} - {member && membership === Membership.Invite && ( - - )} - + + + {userId !== myUserId && } + {userId !== myUserId && } + + + {ignored && } + {member && membership === Membership.Ban && ( + - + )} + {member && + membership === Membership.Leave && + member.events.member && + member.events.member.getSender() !== userId && ( + + )} + {member && membership === Membership.Invite && ( + + )}
); } diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index bcc59d50..1b74ccc2 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -1,61 +1,228 @@ import { style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; -export const UserHeader = style({ +// ── Card root ──────────────────────────────────────────────────── +// +// Establishes the positioning context for the floating top-right +// 3-dot actions trigger. The card content (hero / info / alerts) +// sits in normal flow; the trigger is absolute-positioned over it. +export const CardRoot = style({ + position: 'relative', +}); + +// 3-dot button anchor — top-right of the card. `right: 0` lines it +// up flush with the card content's right edge (the panel padding +// is supplied by the host, so we don't add inset here). +export const CardActionsAnchor = style({ position: 'absolute', top: 0, - left: 0, right: 0, zIndex: 1, - padding: config.space.S200, }); -export const UserHero = style({ - position: 'relative', -}); +// ── Hero ───────────────────────────────────────────────────────── -export const UserHeroCoverContainer = style({ - height: toRem(96), - overflow: 'hidden', -}); -export const UserHeroCover = style({ - height: '100%', +export const HeroRoot = style({ width: '100%', - objectFit: 'cover', - filter: 'blur(16px)', - transform: 'scale(2)', + paddingTop: toRem(8), }); -export const UserHeroAvatarContainer = style({ +// 96px round avatar wrapper. Uses `position: relative` so the +// online-dot can sit inset at bottom-right with a ring matching the +// surrounding background. Folds' `Avatar` is overridden globally to +// be circular, so we just size the wrapper and let the inner avatar +// fill it. +export const HeroAvatar = style({ position: 'relative', - height: toRem(29), -}); -export const UserAvatarContainer = style({ - position: 'absolute', - left: config.space.S400, - top: 0, - transform: 'translateY(-50%)', - backgroundColor: color.Surface.Container, - // Surface plate that punches through the cover banner — sits one DOM level - // above the folds ``, so the global circle override on `.UserAvatar` - // (commit ead1290) does not reach it. Round it explicitly to match. + display: 'inline-flex', + width: toRem(96), + height: toRem(96), borderRadius: '50%', + flexShrink: 0, + // Hairline ring sits on top of the gradient so we get the same + // «inset border» rhythm as the chat-list rows in the design bundle. + boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Background.Container}`, }); -export const UserHeroAvatar = style({ - // boxShadow rather than outline — `outline` ignores `border-radius`, so it - // would draw a square ring around the circular avatar (the global circle - // override lives in `user-avatar/UserAvatar.css.ts` since commit ead1290). - boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Surface.Container}`, + +// Linear gradient is set via inline style (so `colorMXID` can drive +// the base hue). This rule just makes the fallback span fill the +// avatar circle and centre its initial. +export const HeroAvatarFallback = style({ + width: '100%', + height: '100%', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#fff', + fontWeight: 600, + fontSize: toRem(36), + letterSpacing: '0.5px', + textTransform: 'uppercase', +}); + +// Online indicator dot — green pill inset bottom-right of the +// avatar with a ring matching the parent background so it punches +// out against the avatar circle, mirroring the design bundle's +// `.online` glyph. +export const HeroOnlineDot = style({ + position: 'absolute', + right: toRem(2), + bottom: toRem(2), + width: toRem(16), + height: toRem(16), + borderRadius: '50%', + backgroundColor: color.Success.Main, + boxShadow: `0 0 0 ${toRem(3)} ${color.Background.Container}`, +}); + +// Reset native button chrome for the tap-to-zoom avatar wrapper. +export const HeroAvatarButton = style({ + display: 'inline-flex', + background: 'transparent', + border: 'none', + padding: 0, + margin: 0, + cursor: 'pointer', +}); + +export const HeroIdentity = style({ + width: '100%', +}); + +export const HeroPresence = style({ + width: '100%', + justifyContent: 'center', +}); + +// Coloured «онлайн» tag — mirrors the chat header's `OnlineTag` +// style so the two surfaces use identical typography for the same +// signal. Dropping the green dot prefix here is intentional: the +// avatar already carries the green online indicator, so a second +// dot would just be visual noise. +export const HeroOnlineTag = style({ + color: color.Success.Main, + flexShrink: 0, + whiteSpace: 'nowrap', +}); + +export const HeroBullet = style({ + color: color.Surface.ContainerLine, +}); + +export const HeroE2ee = style({ + display: 'inline-flex', + alignItems: 'center', + gap: toRem(4), + color: color.Success.Main, +}); + +// Quiet «last activity» dot prefix used only on offline / idle +// presence labels. Online state has no dot here — the avatar's own +// green indicator serves that role. +export const PresenceDot = style({ + width: toRem(8), + height: toRem(8), + borderRadius: '50%', + backgroundColor: color.Surface.ContainerLine, + flexShrink: 0, +}); + +// ── Info section / rows (Fleet-style attribute table) ──────────── + +export const InfoSection = style({ + display: 'flex', + flexDirection: 'column', + gap: toRem(2), + paddingTop: toRem(8), + paddingBottom: toRem(8), + borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, +}); + +export const InfoRow = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(12), + minHeight: toRem(32), + padding: `${toRem(4)} ${toRem(2)}`, +}); + +// Fixed-width monospace label column. The `letter-spacing` and +// uppercase mirror Fleet's panel-attribute look. +export const InfoRowLabel = style({ + flexShrink: 0, + width: toRem(72), + fontFamily: 'ui-monospace, "JetBrains Mono", monospace', + fontSize: toRem(11), + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: color.Surface.OnContainer, + opacity: 0.55, +}); + +export const InfoRowValue = style({ + flex: 1, + minWidth: 0, + fontSize: toRem(13), + color: color.Surface.OnContainer, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +// Right-side affordance — copy / open / chevron-menu trigger. Looks +// like a tiny ghost icon button: transparent, hover-tints in the +// «hover surface» token, never grows past the row's intrinsic +// height. +export const InfoRowTrailing = style({ + flexShrink: 0, + display: 'inline-flex', + alignItems: 'center', +}); + +export const InfoRowAction = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: toRem(24), + height: toRem(24), + borderRadius: toRem(6), + background: 'transparent', + border: 'none', + color: color.Surface.OnContainer, + cursor: 'pointer', + opacity: 0.7, selectors: { - 'button&': { - cursor: 'pointer', + '&:hover': { + opacity: 1, + background: color.SurfaceVariant.Container, + }, + '&:disabled': { + opacity: 0.3, + cursor: 'default', + }, + '&[aria-pressed="true"]': { + opacity: 1, + background: color.SurfaceVariant.Container, }, }, }); -export const UserHeroAvatarImg = style({ - selectors: { - [`button${UserHeroAvatar}:hover &`]: { - filter: 'brightness(0.5)', - }, - }, + +// Variants used inside `InfoRowValue` — accent for creators (star +// badge), mixed (label + faint mono pl number side by side), mono +// (just the pl-N tag). +export const InfoRowAccent = style({ + display: 'inline-flex', + alignItems: 'center', + gap: toRem(6), + color: color.Warning.Main, + fontWeight: 600, }); + +export const InfoRowMixed = style({ + display: 'inline-flex', + alignItems: 'center', + gap: toRem(8), +}); + diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts index 8adc5a8f..dbe44beb 100644 --- a/src/app/features/call-status/styles.css.ts +++ b/src/app/features/call-status/styles.css.ts @@ -4,7 +4,7 @@ import { color, config, toRem } from 'folds'; // === Incoming-call row + active-call pill === // // Both live inside the bottom horseshoe rail owned by -// `CallSurfaceContainer`. The rail paints the rounded shell — the rows +// `HorseshoeContainer`. The rail paints the rounded shell — the rows // themselves stay flat, with hairline dividers between adjacent rows so // stacked entries (multiple concurrent rings, or a ring above the // active-call pill) read as separate items inside the same card. @@ -53,7 +53,7 @@ export const CallIdentityButton = style({ // like `${RingRow} + ${CallStatus}` from a single style block. // // Order assumption: in the rail, RingRow rows precede CallStatus (see -// `CallSurfaceContainer.tsx`). If a future contributor reorders these +// `HorseshoeContainer.tsx`). If a future contributor reorders these // — or introduces a new row type — re-audit the divider rules below. const rowDivider = `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`; diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 5a453cda..5a2472e1 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -18,6 +18,8 @@ import { CallView } from '../call/CallView'; import { RoomViewHeader } from './RoomViewHeader'; import { callChatAtom } from '../../state/callEmbed'; import { CallChatView } from './CallChatView'; +import { RoomViewProfilePanel } from './RoomViewProfilePanel'; +import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel'; type RoomProps = { renderRoomView?: (props: { eventId?: string }) => React.ReactNode; @@ -54,25 +56,34 @@ export function Room({ renderRoomView }: RoomProps) { ); const callView = room.isCallRoom(); + const isMobile = screenSize === ScreenSize.Mobile; return ( {callView && (screenSize === ScreenSize.Desktop || !chat) && ( - - - - + }> + + + + )} {!callView && ( - - {renderRoomView?.({ eventId }) ?? } + }> + {renderRoomView?.({ eventId }) ?? } + )} + {/* Tablet / Desktop: profile renders as a third pane to the + right of the chat. Mobile uses the top horseshoe inside + `RoomViewProfilePanel`, so we don't mount the side pane + there. */} + {!isMobile && } + {callView && chat && ( <> {screenSize === ScreenSize.Desktop && ( diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx index ebfed5f5..8373e51c 100644 --- a/src/app/features/room/RoomViewHeaderDm.tsx +++ b/src/app/features/room/RoomViewHeaderDm.tsx @@ -38,7 +38,7 @@ import { useStateEvent } from '../../hooks/useStateEvent'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; -import { useIsBridgedRoom } from '../../hooks/useIsBridgedRoom'; +import { useDmCallVisible } from '../../hooks/useDmCallVisible'; import { useRoomMemberCount } from '../../hooks/useRoomMemberCount'; import { Presence, useUserPresence } from '../../hooks/useUserPresence'; import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta'; @@ -61,7 +61,6 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { settingsAtom } from '../../state/settings'; -import { mDirectAtom } from '../../state/mDirectList'; import { callEmbedAtom } from '../../state/callEmbed'; import { searchModalAtom } from '../../state/searchModal'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; @@ -444,22 +443,21 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) { const isOneOnOne = useIsOneOnOne(); const isMobile = screenSize === ScreenSize.Mobile; - // Three-gate call surface: only strictly 1:1 + still m.direct-tagged + not - // bridged. Call lifecycle hooks (`useIncomingRtcNotifications`, - // `useCallerAutoHangup`) still gate ring delivery on `m.direct`, so the - // visibility guard mirrors them. MSC2346 `m.bridge` exclusion keeps the - // button hidden for mautrix-telegram puppet rooms even if the bridge config - // ever writes `m.direct`. See dm_1x1_redesign.md §6.8b. - // - // `useIsBridgedRoom` is reactive — a late-arriving `m.bridge` state event - // flips the gate without waiting for an unrelated rerender, which a static - // `isBridgedRoom(room)` call would miss. - const mDirects = useAtomValue(mDirectAtom); - const isBridged = useIsBridgedRoom(room); + // Call surface visibility is shared with the `Call` button inside + // `UserRoomProfile` via `useDmCallVisible` — the hook captures all + // four gates (1:1 + m.direct + !bridged + !bot-control) so the two + // call-entry surfaces can't drift apart. Header-specific extras + // here: hide in callView (we're already showing CallView). The + // hook itself documents the call-lifecycle reasoning. + const dmCallVisible = useDmCallVisible(room); + const callButtonVisible = !callView && dmCallVisible; + + // `isBotControlRoom` is also passed down to `RoomMenu` to gate the + // bot-config menu item — kept here as its own derivation because + // the hook above is purpose-built for the call gate, not for + // generic «is this a bot DM?» queries. const bots = useBotPresets(); const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots); - const callButtonVisible = - !callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged && !isBotControlRoom; const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptedRoom = !!encryptionEvent; diff --git a/src/app/features/room/RoomViewProfilePanel.css.ts b/src/app/features/room/RoomViewProfilePanel.css.ts new file mode 100644 index 00000000..c8a23a59 --- /dev/null +++ b/src/app/features/room/RoomViewProfilePanel.css.ts @@ -0,0 +1,155 @@ +import { style } from '@vanilla-extract/css'; +import { color, toRem } from 'folds'; + +// Shared with the call-horseshoe surface so both ends of the chat +// (top profile rail, bottom call rail) read with identical geometry. +export const HORSESHOE_RADIUS_PX = 24; +export const HORSESHOE_GAP_PX = 8; + +// Outer container — establishes the positioning context for the +// absolutely-positioned panel and the under-panel chat column. +// `overflow: hidden` clips the panel when it slides above the +// visible area (translateY(-railHeight)) so it disappears cleanly, +// and clips the chat's animated rounded top corners against the +// container's own edges. +export const container = style({ + position: 'relative', + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', +}); + +// === Panel === Mobile-only top horseshoe. +// +// Full-width sheet with both bottom corners rounded, mirroring the +// bottom call rail's rounded top corners. `position: absolute` lets +// it slide in from above (translateY) without disturbing the chat +// column's flex layout below. +export const panel = style({ + position: 'absolute', + top: 0, + left: 0, + zIndex: 2, + width: '100%', + // The bottom-corner radius is interpolated inline so the panel + // bottom stays flush against the chat-column top during drag (both + // square-cornered, zero gap), and only blossoms into the rounded + // horseshoe shape once the gesture commits and CSS transitions + // kick in alongside the gap. + overflow: 'hidden', + backgroundColor: color.Background.Container, + willChange: 'transform', + // Allow vertical pan inside the panel (Scroll content); the + // explicit drag-up handler will preventDefault on close gestures. + touchAction: 'pan-y', +}); + +export const panelInner = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', +}); + +// Functional overflow without a visible scrollbar. The card's +// content (hero + ~4 info rows + chip row) almost always fits +// inside the rail height, but moderation alerts can push it past +// — we keep the panel scrollable for that case while suppressing +// the scrollbar chrome (it's not a useful affordance on a 42vh +// rail and the user explicitly asked us to drop it). +export const panelScroll = style({ + flex: 1, + minHeight: 0, + overflow: 'auto', + scrollbarWidth: 'none', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +}); + +// Bottom drag handle — visible cue that the panel is draggable. The +// whole panel surface is also drag-sensitive (see TSX), but the +// handle stays as an obvious affordance for users who don't try the +// surface gesture. +export const panelHandle = style({ + flexShrink: 0, + height: toRem(20), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'grab', + touchAction: 'none', + selectors: { + '&:active': { cursor: 'grabbing' }, + }, +}); + +export const panelHandleBar = style({ + width: toRem(36), + height: toRem(4), + borderRadius: toRem(4), + backgroundColor: color.Surface.ContainerLine, +}); + +// Avatar full-view mode — fills the panel with the user's avatar at +// full size when the user taps the avatar inside the open panel. +// Click-to-revert switches back to the regular profile content. +export const avatarFullView = style({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + position: 'relative', +}); + +export const avatarFullImage = style({ + width: '100%', + height: '100%', + objectFit: 'cover', +}); + +export const avatarFullFallback = style({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: toRem(96), + fontWeight: 600, + color: color.Surface.Container, + textTransform: 'uppercase', +}); + +// === Under-panel chat column === +// +// `margin-top` is interpolated inline (panel height + 8px gap) so its +// rounded top edge sits right under the panel's rounded bottom. The +// rounded corners themselves are inline-styled on this same element. +export const chatColumn = style({ + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + willChange: 'margin-top', +}); + +// CSS-grid `1fr → 0fr` is animatable, unlike `height: auto`. The +// outer wrap holds the row track; the inner div is the actual +// scroll/clip surface. +export const headerWrap = style({ + display: 'grid', + flexShrink: 0, + willChange: 'grid-template-rows', +}); + +export const headerWrapInner = style({ + minHeight: 0, + overflow: 'hidden', +}); diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx new file mode 100644 index 00000000..be1e038a --- /dev/null +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -0,0 +1,410 @@ +// Wrapper around the room's chat column that adds the user-profile +// surface on top. +// +// Mobile (≤ 750): the top «horseshoe» rail. The chat header collapses +// to height 0 while the rail is open so the visible gap between the +// rail's rounded bottom and the chat's rounded top is exactly the +// shared `HORSESHOE_GAP` (8px), matching the bottom call horseshoe. +// +// Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin +// passthrough; the profile renders as a right-side pane via +// `RoomViewProfileSidePanel`, which `Room.tsx` mounts as a sibling +// of the chat column. + +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { config } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; +import { userRoomProfileAtom } from '../../state/userRoomProfile'; +import { + useCloseUserRoomProfile, + useOpenUserRoomProfile, +} from '../../state/hooks/userRoomProfile'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { getMemberAvatarMxc } from '../../utils/room'; +import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/matrix'; +import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile'; +import { stopPropagation } from '../../utils/keyboard'; +import colorMXID from '../../../util/colorMXID'; +import * as css from './RoomViewProfilePanel.css'; + +// Card height as a fraction of the viewport. +const RAIL_HEIGHT_FRACTION = 0.42; +// Past this many pixels of drag the gesture commits (open or close). +const COMMIT_THRESHOLD_PX = 80; +const ANIMATION_MS = 250; + +type RoomViewProfilePanelProps = { + header: ReactNode; + children: ReactNode; +}; + +type DragState = { + source: 'header' | 'panel'; + startY: number; + deltaY: number; +}; + +// Mobile-only top horseshoe. Slides down from above on drag-down on +// the chat header (1:1 DM only); slides up to close on drag-up +// anywhere on the panel. While the rail is open the chat header +// collapses to height 0 so there's no transparent ~50px void below +// the rail — the perceived gap matches the call horseshoe's 8px gap. +function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) { + const { t } = useTranslation(); + const profileState = useAtomValue(userRoomProfileAtom); + const close = useCloseUserRoomProfile(); + const openProfile = useOpenUserRoomProfile(); + const room = useRoom(); + const space = useSpaceOptionally(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const isOneOnOne = useIsOneOnOne(); + + const myUserId = mx.getSafeUserId(); + const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined; + const headerDragPeer = + peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined; + const headerDragEnabled = !!headerDragPeer; + + const [drag, setDrag] = useState(null); + const [avatarMode, setAvatarMode] = useState(false); + + const headerRef = useRef(null); + const panelRef = useRef(null); + + // Close profile when the room changes — atom is global state and + // would otherwise carry the previous room's userId into this room. + useEffect(() => () => close(), [room.roomId, close]); + + // Reset avatar-zoom mode whenever the rendered user changes. + useEffect(() => { + setAvatarMode(false); + }, [profileState?.userId]); + + const [railHeightPx, setRailHeightPx] = useState(() => { + if (typeof window === 'undefined') return 400; + return Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION); + }); + useEffect(() => { + const onResize = () => { + setRailHeightPx(Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION)); + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const open = !!profileState; + const baseExpanded = open ? railHeightPx : 0; + const expandedPx = drag + ? Math.max(0, Math.min(railHeightPx, baseExpanded + drag.deltaY)) + : baseExpanded; + const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; + const isDragging = drag !== null; + + const liveUserId = + profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined); + const lastUserIdRef = useRef(undefined); + if (liveUserId) lastUserIdRef.current = liveUserId; + const renderUserId = liveUserId ?? lastUserIdRef.current; + + const renderUserAvatarMxc = renderUserId + ? getMemberAvatarMxc(room, renderUserId) + : undefined; + const renderUserAvatarUrl = + (renderUserAvatarMxc && + mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ?? + undefined; + + const dragRef = useRef(null); + dragRef.current = drag; + const profileStateRef = useRef(profileState); + profileStateRef.current = profileState; + const openProfileRef = useRef(openProfile); + openProfileRef.current = openProfile; + const closeRef = useRef(close); + closeRef.current = close; + const roomIdRef = useRef(room.roomId); + roomIdRef.current = room.roomId; + const spaceIdRef = useRef(space?.roomId); + spaceIdRef.current = space?.roomId; + + useEffect(() => { + const headerEl = headerRef.current; + const panelEl = panelRef.current; + + const dragOpenCords = (): { x: number; y: number; width: number; height: number } => { + const rect = headerRef.current?.getBoundingClientRect(); + if (rect) return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; + return { x: 0, y: 0, width: 0, height: 0 }; + }; + + const onHeaderTouchStart = (e: TouchEvent) => { + if (profileStateRef.current) return; + if (!headerDragEnabled) return; + const touch = e.touches[0]; + setDrag({ source: 'header', startY: touch.clientY, deltaY: 0 }); + }; + + const onPanelTouchStart = (e: TouchEvent) => { + if (!profileStateRef.current) return; + const touch = e.touches[0]; + setDrag({ source: 'panel', startY: touch.clientY, deltaY: 0 }); + }; + + const onTouchMove = (e: TouchEvent) => { + const d = dragRef.current; + if (!d) return; + const touch = e.touches[0]; + const rawDelta = touch.clientY - d.startY; + let nextDelta = rawDelta; + if (d.source === 'header') { + nextDelta = Math.max(0, rawDelta); + } else { + if (rawDelta > 0) return; + nextDelta = rawDelta; + } + if (e.cancelable) e.preventDefault(); + setDrag({ ...d, deltaY: nextDelta }); + }; + + const onTouchEnd = () => { + const d = dragRef.current; + if (!d) return; + if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) { + if (headerDragPeer) { + openProfileRef.current( + roomIdRef.current, + spaceIdRef.current, + headerDragPeer, + dragOpenCords() + ); + } + } else if (d.source === 'panel' && -d.deltaY > COMMIT_THRESHOLD_PX) { + closeRef.current(); + } + setDrag(null); + }; + + if (headerEl) { + headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true }); + headerEl.addEventListener('touchmove', onTouchMove, { passive: false }); + headerEl.addEventListener('touchend', onTouchEnd, { passive: true }); + headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true }); + } + if (panelEl) { + panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true }); + panelEl.addEventListener('touchmove', onTouchMove, { passive: false }); + panelEl.addEventListener('touchend', onTouchEnd, { passive: true }); + panelEl.addEventListener('touchcancel', onTouchEnd, { passive: true }); + } + + return () => { + if (headerEl) { + headerEl.removeEventListener('touchstart', onHeaderTouchStart); + headerEl.removeEventListener('touchmove', onTouchMove); + headerEl.removeEventListener('touchend', onTouchEnd); + headerEl.removeEventListener('touchcancel', onTouchEnd); + } + if (panelEl) { + panelEl.removeEventListener('touchstart', onPanelTouchStart); + panelEl.removeEventListener('touchmove', onTouchMove); + panelEl.removeEventListener('touchend', onTouchEnd); + panelEl.removeEventListener('touchcancel', onTouchEnd); + } + }; + }, [headerDragEnabled, headerDragPeer]); + + const panelTransition = isDragging + ? 'none' + : `transform ${ANIMATION_MS}ms ease, border-bottom-left-radius ${ANIMATION_MS}ms ease, border-bottom-right-radius ${ANIMATION_MS}ms ease`; + const chatColumnTransition = isDragging + ? 'none' + : `margin-top ${ANIMATION_MS}ms ease, border-top-left-radius ${ANIMATION_MS}ms ease, border-top-right-radius ${ANIMATION_MS}ms ease`; + + const containerStyle: React.CSSProperties = { + backgroundColor: expandedPx > 0 ? '#090909' : undefined, + }; + + // The «two horseshoes» visual (8px gap + 24px bottom/top radii) + // is gated by an emerge curve that stays at 0 for most of the + // open progress and only ramps to 1 in the last 15%. Two reasons: + // + // 1) Drag-down to open — the chat header is still visible under + // the descending rail. A linear gap would show a black `#090909` + // strip between rail and header («экстра геп между старой + // маленьким хедером чата и карточкой-подковой»). With the + // curve the rail sits flush against the (collapsing) header + // until the very end, and the gap blossoms when the header + // has fully retracted. + // + // 2) Drag-up to close — touchstart begins at expandedFraction=1, + // so the gap is already 8px. As expandedFraction dips even + // slightly the curve drops sharply, so the gap closes + // smoothly with the user's gesture instead of jolting to 0 + // at touchstart. + const horseshoeEmerge = Math.max(0, (expandedFraction - 0.85) / 0.15); + const chatRadius = horseshoeEmerge * css.HORSESHOE_RADIUS_PX; + const chatGap = horseshoeEmerge * css.HORSESHOE_GAP_PX; + const panelBottomRadius = chatRadius; + + return ( +
+ true, + escapeDeactivates: stopPropagation, + onDeactivate: () => { + if (profileStateRef.current) close(); + }, + checkCanFocusTrap: () => Promise.resolve(), + }} + // Don't toggle `active` on drag — flipping it to false makes + // focus-trap-react fire `onDeactivate` synthetically, so a + // plain panel tap (deltaY=0) closes the rail. Trap stays + // active for the entire open lifetime. + active={open} + > +
0 ? 'visible' : 'hidden', + }} + > + {avatarMode ? ( + + ) : ( +
+ {/* No visible scrollbar — the card content (hero + + ~4 info rows + a chip row) almost always fits, and + on the rare overflow case (lots of moderation + alerts) we keep functional scrolling via the + `panelScroll` class which sets `overflow: auto` + + `scrollbar-width: none` / `::-webkit-scrollbar` + hidden. */} +
+
+ {renderUserId && ( + setAvatarMode(true)} + /> + )} +
+
+
+
+
+
+ )} +
+ + +
0 ? `${expandedPx + chatGap}px` : 0, + borderTopLeftRadius: chatRadius ? `${chatRadius}px` : undefined, + borderTopRightRadius: chatRadius ? `${chatRadius}px` : undefined, + // Clip the chat content to the rounded top corners. Folds + // popouts use Portals → escape this clip; the timeline + // keeps its own scroll container. + overflow: chatRadius > 0 ? 'hidden' : undefined, + transition: chatColumnTransition, + // Suppress browser pull-to-refresh / scroll-chaining so a + // header-drag-down doesn't fire pull-to-refresh briefly + // before the touchmove handler intercepts. + overscrollBehaviorY: 'contain', + }} + > +
+
{header}
+
+ {children} +
+
+ ); +} + +// Top-level router. On non-mobile we pass through — the side-pane +// surface is rendered by `Room.tsx` as a sibling, so the wrapper just +// hands `header` and `children` back to the parent. The mobile branch +// owns its own state machine in a sub-component so we don't run +// drag/avatar hooks on desktop renders. +export function RoomViewProfilePanel({ header, children }: RoomViewProfilePanelProps) { + const isMobile = useScreenSizeContext() === ScreenSize.Mobile; + if (!isMobile) { + return ( + <> + {header} + {children} + + ); + } + return {children}; +} diff --git a/src/app/features/room/RoomViewProfileSidePanel.css.ts b/src/app/features/room/RoomViewProfileSidePanel.css.ts new file mode 100644 index 00000000..33a79bfc --- /dev/null +++ b/src/app/features/room/RoomViewProfileSidePanel.css.ts @@ -0,0 +1,70 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Right-side profile pane sized like the members drawer family — +// wide enough for the identity card + chips without dominating the +// chat. Clamp keeps it readable on narrow desktops and prevents +// runaway width on ultra-wide displays. +export const panel = style({ + flexShrink: 0, + width: `clamp(${toRem(300)}, 25%, ${toRem(380)})`, + display: 'flex', + flexDirection: 'column', + backgroundColor: color.Surface.Container, + borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + minHeight: 0, +}); + +// Match the chat header's left gutter (RoomViewHeaderDm uses S200 +// override) so the title sits at the same x-offset as the chat +// header's avatar — keeps the two rows visually balanced. +export const header = style({ + paddingLeft: config.space.S300, +}); + +// Functional overflow without a visible scrollbar. The pane's +// content (hero + info rows + actions) almost always fits, but +// moderation alerts can push past — we keep scrolling working for +// that case while hiding the scrollbar chrome that the user asked +// us to drop. +export const scrollWrap = style({ + flex: 1, + minHeight: 0, + overflow: 'auto', + scrollbarWidth: 'none', + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +}); + +export const avatarFullView = style({ + flex: 1, + minHeight: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + background: 'transparent', + border: 'none', + padding: 0, +}); + +export const avatarFullImage = style({ + width: '100%', + height: '100%', + objectFit: 'cover', +}); + +export const avatarFullFallback = style({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: toRem(96), + fontWeight: 600, + color: color.Surface.Container, + textTransform: 'uppercase', +}); diff --git a/src/app/features/room/RoomViewProfileSidePanel.tsx b/src/app/features/room/RoomViewProfileSidePanel.tsx new file mode 100644 index 00000000..ea8adca8 --- /dev/null +++ b/src/app/features/room/RoomViewProfileSidePanel.tsx @@ -0,0 +1,135 @@ +// Desktop / tablet right-side profile pane. Renders the same +// `UserRoomProfile` content the mobile top horseshoe shows, but as a +// flex sibling next to the chat column instead of a slide-down rail. +// +// Mounted in `Room.tsx` only when the screen size is not Mobile — +// the mobile branch keeps using the top horseshoe via +// `RoomViewProfilePanel`. + +import React, { useEffect, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { Box, Icon, IconButton, Icons, Text, config } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; +import { userRoomProfileAtom } from '../../state/userRoomProfile'; +import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useRoom } from '../../hooks/useRoom'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { getMemberAvatarMxc } from '../../utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile'; +import { stopPropagation } from '../../utils/keyboard'; +import colorMXID from '../../../util/colorMXID'; +import { PageHeader } from '../../components/page'; +import { ContainerColor } from '../../styles/ContainerColor.css'; +import * as css from './RoomViewProfileSidePanel.css'; + +export function RoomViewProfileSidePanel() { + const { t } = useTranslation(); + const profileState = useAtomValue(userRoomProfileAtom); + const close = useCloseUserRoomProfile(); + const room = useRoom(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const open = !!profileState; + const [avatarMode, setAvatarMode] = useState(false); + + // Close profile when the room changes — atom is global state and + // would otherwise carry the previous room's userId into this room + // where chrome would render against the wrong member / power + // levels. + useEffect(() => () => close(), [room.roomId, close]); + + // Reset avatar-zoom mode whenever the rendered user changes. + useEffect(() => { + setAvatarMode(false); + }, [profileState?.userId]); + + const profileStateRef = useRef(profileState); + profileStateRef.current = profileState; + + const renderUserId = profileState?.userId; + const renderUserAvatarMxc = renderUserId + ? getMemberAvatarMxc(room, renderUserId) + : undefined; + const renderUserAvatarUrl = + (renderUserAvatarMxc && + mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ?? + undefined; + + if (!open || !renderUserId) return null; + + return ( + true, + escapeDeactivates: stopPropagation, + onDeactivate: () => { + if (profileStateRef.current) close(); + }, + checkCanFocusTrap: () => Promise.resolve(), + }} + active={open} + > +
+ {/* Use the same `PageHeader` (folds `Header size="600"`) the + chat header uses, so the bottom rule lines up with the + adjacent `RoomViewHeaderDm` row to the pixel. */} + + + + {t('User.profile_title')} + + + + + + + + + + {avatarMode ? ( + + ) : ( +
+
+ setAvatarMode(true)} + /> +
+
+ )} +
+
+ ); +} diff --git a/src/app/hooks/useDmCallVisible.ts b/src/app/hooks/useDmCallVisible.ts new file mode 100644 index 00000000..0cc4e7db --- /dev/null +++ b/src/app/hooks/useDmCallVisible.ts @@ -0,0 +1,41 @@ +// Single source of truth for «can the user start a Vojo DM voice call +// in this room?». Both `RoomViewHeaderDm`'s phone-icon button and the +// `Call` action inside `UserRoomProfile` consult this hook so the two +// surfaces can't drift apart — round-7 review caught a bug where the +// profile button skipped the bridged-room and bot-control gates and +// would have offered to start a Matrix-RTC call inside a +// mautrix-telegram puppet DM. +// +// Four-gate predicate, mirroring the call-lifecycle hooks +// (`useIncomingRtcNotifications`, `useCallerAutoHangup`) so the +// visibility guard matches the actual deliverability: +// • member-count = 2 (true 1:1 — read via `useIsOneOnOne`'s context +// so we follow the same reactive source the rest of the app uses) +// • `m.direct`-tagged — ring delivery still gates on this +// • not a bridged room — MSC2346 `m.bridge` exclusion keeps the +// surface hidden in mautrix-telegram puppet DMs (no Matrix-RTC +// equivalent) +// • not a bot-control room — bot DMs aren't human-callable +// +// `useIsOneOnOne` is route-context-bound; this hook is therefore only +// meaningful for the room currently rendered by `Room.tsx`. Callers +// outside that scope must compute their own member-count signal. + +import { Room } from 'matrix-js-sdk'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from './useMatrixClient'; +import { useIsOneOnOne } from './useRoom'; +import { useIsBridgedRoom } from './useIsBridgedRoom'; +import { useBotPresets } from '../features/bots/catalog'; +import { isCatalogBotControlRoom } from '../features/bots/room'; +import { mDirectAtom } from '../state/mDirectList'; + +export const useDmCallVisible = (room: Room): boolean => { + const mx = useMatrixClient(); + const isOneOnOne = useIsOneOnOne(); + const mDirects = useAtomValue(mDirectAtom); + const isBridged = useIsBridgedRoom(room); + const bots = useBotPresets(); + const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots); + return isOneOnOne && mDirects.has(room.roomId) && !isBridged && !isBotControlRoom; +}; diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx index 1c343bca..556aab49 100644 --- a/src/app/pages/CallStatusRenderer.tsx +++ b/src/app/pages/CallStatusRenderer.tsx @@ -5,7 +5,7 @@ import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; // Whether the active-call pill is *currently* drawing in the rail. The -// CallSurfaceContainer reuses this to decide whether the horseshoe shell +// HorseshoeContainer reuses this to decide whether the horseshoe shell // is justified — when the pill is suppressed (mobile + on call room + // video) the rail collapses to zero height, so painting the gap-color + // rounding the appShell would leave a dangling void below the UI. diff --git a/src/app/pages/CallSurfaceContainer.tsx b/src/app/pages/CallSurfaceContainer.tsx deleted file mode 100644 index 065b9e27..00000000 --- a/src/app/pages/CallSurfaceContainer.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Bottom-horseshoe call surface. -// -// Wraps `ClientLayout` plus the bottom call-rail (incoming-ring strips + -// active-call status pill) into a single flex column. While at least one -// ring is queued in `incomingCallsAtom` *or* an active call embed is -// mounted (`callEmbedAtom`), both halves get the «two horseshoes -// split-screen» treatment: -// -// • Top half (whole client UI) — rounded bottom corners, 8px margin -// pushing the rest of the app up. -// • Bottom half (call rail) — rounded top corners, single horseshoe -// containing every concurrent ring as a stacked row plus the -// active-call pill underneath. -// -// The void between paints `#090909` from the `surface` div. The -// «orbiting border» (a conic-gradient sweep around the rail's rim) only -// renders while a ring is *incoming* — it's the alarm cue. Once the -// user is in the call the orbit is replaced by the static rail. -// -// Lifting the whole UI (including the global SidebarNav) is intentional: -// the wrapper sits *above* `ClientLayout`, so the effect is -// route-agnostic. - -import React, { ReactNode } from 'react'; -import classNames from 'classnames'; -import { useAtomValue } from 'jotai'; -import { isRingingAtom } from '../state/incomingCalls'; -import { IncomingCallStripRenderer } from './IncomingCallStripRenderer'; -import { CallStatusRenderer, useCallStatusVisible } from './CallStatusRenderer'; -import * as css from './CallSurfaceContainer.css'; - -type CallSurfaceContainerProps = { - children: ReactNode; -}; - -export function CallSurfaceContainer({ children }: CallSurfaceContainerProps) { - const ringing = useAtomValue(isRingingAtom); - // Gate the horseshoe on whether the rail will *actually* paint pixels. - // A naive `callEmbed !== undefined` check would also flip true while - // the pill is suppressed (mobile + viewing the call room + video) — - // that would leave a black 8px shelf with no visible rail underneath. - // Reusing the same visibility predicate as the renderer keeps shell + - // content in sync. - const pillVisible = useCallStatusVisible(); - const callPresent = ringing || pillVisible; - - return ( -
-
- {children} -
-
- - - {ringing &&
} -
-
- ); -} diff --git a/src/app/pages/CallSurfaceContainer.css.ts b/src/app/pages/HorseshoeContainer.css.ts similarity index 50% rename from src/app/pages/CallSurfaceContainer.css.ts rename to src/app/pages/HorseshoeContainer.css.ts index 19d37bbc..fe1c43eb 100644 --- a/src/app/pages/CallSurfaceContainer.css.ts +++ b/src/app/pages/HorseshoeContainer.css.ts @@ -1,16 +1,17 @@ import { style } from '@vanilla-extract/css'; import { toRem } from 'folds'; -// Color of the «void» between the two horseshoes — fixed in design, not -// theme-driven. The surface paints this only while a call surface is -// present so the rest of the app retains its normal page background. +// Color of the «void» between the app shell and the bottom call +// rail — fixed in design, not theme-driven. Painted only when the +// call rail is mounted, so the rest of the app keeps its normal page +// background. const SURFACE_GAP_COLOR = '#090909'; const HORSESHOE_RADIUS = toRem(24); const HORSESHOE_GAP = toRem(8); -// Outer flex column that hosts the whole client UI plus the bottom call -// rail. Stays flex:1 with a height-bound (`min-height: 0`) so nested -// scroll containers inside ClientLayout can shrink correctly. +// Outer flex column hosting app shell + bottom call rail. +// `min-height: 0` is required for nested scroll containers inside +// ClientLayout to shrink correctly. export const surface = style({ display: 'flex', flexDirection: 'column', @@ -19,9 +20,15 @@ export const surface = style({ minHeight: 0, }); -// «Top horseshoe» — wraps the whole client UI (sidebar + outlet). The -// rounded bottom corners + bottom margin only kick in while a call -// surface is mounted, painting the gap-color through the resulting void. +export const surfaceActive = style({ + backgroundColor: SURFACE_GAP_COLOR, +}); + +// === App shell === +// +// Wraps the whole client UI. Gets rounded BOTTOM corners + 8px +// margin pushing the rest of the app up while a call surface is +// active. export const appShell = style({ display: 'flex', flex: 1, @@ -29,44 +36,37 @@ export const appShell = style({ minHeight: 0, }); -export const surfaceCallActive = style({ - backgroundColor: SURFACE_GAP_COLOR, -}); - -export const appShellCallActive = style({ +export const appShellBottomRound = style({ borderBottomLeftRadius: HORSESHOE_RADIUS, borderBottomRightRadius: HORSESHOE_RADIUS, overflow: 'hidden', marginBottom: HORSESHOE_GAP, }); -// «Bottom horseshoe» — the call rail. Rounded *top* corners only because -// it sits flush against the safe-area inset; the bottom is the screen -// edge. `position: relative` carries the absolute-positioned orbit -// border. +// === Bottom horseshoe (call rail) === +// +// Rounded *top* corners only because it sits flush against the +// safe-area inset; the bottom is the screen edge. `position: relative` +// carries the absolute-positioned orbit border. export const bottomRail = style({ display: 'flex', flexDirection: 'column', flexShrink: 0, }); -export const bottomRailCallActive = style({ +export const bottomRailActive = style({ position: 'relative', borderTopLeftRadius: HORSESHOE_RADIUS, borderTopRightRadius: HORSESHOE_RADIUS, overflow: 'hidden', }); -// Orbit border — a small green segment of a conic-gradient that runs -// around the rail's perimeter when a ring is incoming. The mask trick -// (content-box layer XOR full-box layer) cuts the inner area, so only -// the 2px rim shows the gradient. The rotating angle is driven by the -// `--vojo-orbit-angle` custom property, registered via `@property` in -// `src/index.css` (vanilla-extract has no native @property binding). -// -// Wide-and-short rails sweep faster across long edges and slower around -// corners — that's a property of the conic-gradient parametrization, and -// reads as a pleasant pulse rather than as a uniform marquee. +// Orbit border on the bottom rail — small green segment of a +// conic-gradient that runs around the rail's perimeter when a ring is +// incoming. The mask trick (content-box layer XOR full-box layer) +// cuts the inner area, so only the 2px rim shows the gradient. The +// angle is driven by `--vojo-orbit-angle`, registered via `@property` +// in `src/index.css`. export const ringOrbit = style({ position: 'absolute', inset: 0, diff --git a/src/app/pages/HorseshoeContainer.tsx b/src/app/pages/HorseshoeContainer.tsx new file mode 100644 index 00000000..951dd871 --- /dev/null +++ b/src/app/pages/HorseshoeContainer.tsx @@ -0,0 +1,50 @@ +// Bottom-horseshoe call surface. +// +// Wraps `ClientLayout` and the bottom call-rail (incoming-ring strips +// + active-call status pill) into a single flex column. While at least +// one ring is queued or an active call's pill is visible, the +// container paints `#090909` in the gap and pulls the app shell up +// with rounded bottom corners. +// +// The TOP horseshoe (user profile sheet) used to live here too, but +// was hoisted into `features/room/RoomViewProfilePanel.tsx` so it +// only affects the room/chat column — on desktop it no longer +// blankets the sidebar / nav rails. This wrapper now owns the call +// surface only. + +import React, { ReactNode } from 'react'; +import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; +import { isRingingAtom } from '../state/incomingCalls'; +import { IncomingCallStripRenderer } from './IncomingCallStripRenderer'; +import { CallStatusRenderer, useCallStatusVisible } from './CallStatusRenderer'; +import * as css from './HorseshoeContainer.css'; + +type HorseshoeContainerProps = { + children: ReactNode; +}; + +export function HorseshoeContainer({ children }: HorseshoeContainerProps) { + const ringing = useAtomValue(isRingingAtom); + // Gate the bottom horseshoe on whether the rail will *actually* paint + // pixels. A naive `callEmbed !== undefined` check would also flip + // true while the pill is suppressed (mobile + viewing the call room + // + video) — that would leave a black 8px shelf with no visible + // rail underneath. Reusing the same visibility predicate as the + // renderer keeps shell + content in sync. + const pillVisible = useCallStatusVisible(); + const callPresent = ringing || pillVisible; + + return ( +
+
+ {children} +
+
+ + + {ringing &&
} +
+
+ ); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 9ea496b3..4e5fa0dd 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -66,7 +66,6 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore'; import { RoomSettingsRenderer } from '../features/room-settings'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; import { SpaceSettingsRenderer } from '../features/space-settings'; -import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer'; import { CreateRoomModalRenderer } from '../features/create-room'; import { Create } from './client/create'; import { CreateSpaceModalRenderer } from '../features/create-space'; @@ -76,7 +75,7 @@ import { CallEmbedProvider } from '../components/CallEmbedProvider'; import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications'; import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup'; import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer'; -import { CallSurfaceContainer } from './CallSurfaceContainer'; +import { HorseshoeContainer } from './HorseshoeContainer'; import { useAppUrlOpen } from '../hooks/useAppUrlOpen'; function IncomingCallsFeature() { @@ -167,7 +166,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - + @@ -177,11 +176,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > - + -