redesign(p2): rebuild DM list panel with stream header, segmented tabs, self-row, new-chat row, footer status, and live timeline rerender

This commit is contained in:
heaven 2026-04-26 23:34:20 +03:00
parent 0c89e9fda0
commit ed3e5c0640
9 changed files with 890 additions and 177 deletions

View file

@ -370,6 +370,14 @@
"direct_message": "Direct Message",
"create_chat": "Create Chat",
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
"start_first_chat": "Start a chat",
"segment_dm": "DM",
"segment_channels": "Channels",
"segment_bots": "Bots",
"segment_coming_soon": "Coming soon",
"self_row_label": "You",
"self_row_preview": "Settings & profile",
"status_e2ee": "e2ee",
"chats": "Chats",
"username": "Username",
"username_placeholder": "username",

View file

@ -370,6 +370,14 @@
"direct_message": "Новый чат",
"create_chat": "Новый чат",
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
"start_first_chat": "Начать чат",
"segment_dm": "Личные",
"segment_channels": "Каналы",
"segment_bots": "Боты",
"segment_coming_soon": "Скоро",
"self_row_label": "Я",
"self_row_preview": "Настройки и профиль",
"status_e2ee": "e2ee",
"chats": "Чаты",
"username": "Имя пользователя",
"username_placeholder": "username",

View file

@ -5,6 +5,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = recipe({
variants: {
size: {
'500': {
width: toRem(320),
},
'400': {
width: toRem(256),
},

View file

@ -0,0 +1,513 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk/lib/models/room';
import {
Avatar,
Badge,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
color,
config,
toRem,
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../utils/notifications';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { TypingIndicator } from '../../components/typing-indicator';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
import { getViaServers } from '../../plugins/via-servers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import {
RoomNotificationMode,
getRoomNotificationModeIcon,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useRoomName } from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { timeHourMinute } from '../../utils/time';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
const ROW_MIN_HEIGHT = toRem(68);
type DmStreamRowMenuProps = {
room: Room;
requestClose: () => void;
notificationMode?: RoomNotificationMode;
};
const DmStreamRowMenu = forwardRef<HTMLDivElement, DmStreamRowMenuProps>(
({ room, requestClose, notificationMode }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleRoomSettings = () => {
openRoomSettings(room.roomId, space?.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.invite')}
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.copy_link')}
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.room_settings')}
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);
function CallChatToggle() {
const { t } = useTranslation();
const [chat, setChat] = useAtom(callChatAtom);
return (
<IconButton
onClick={() => setChat(!chat)}
aria-pressed={chat}
aria-label={t('Home.toggle_chat')}
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.Message} filled={chat} />
</IconButton>
);
}
type RoomPreview = { ts: number | undefined; text: string };
const deriveRoomPreview = (room: Room): RoomPreview => {
const events = room.getLiveTimeline().getEvents();
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i];
if (ev.getType() === 'm.room.message' && !ev.isRedacted()) {
const content = ev.getContent();
const body = typeof content?.body === 'string' ? content.body.trim() : '';
if (body) {
return { ts: ev.getTs(), text: body.replace(/\s+/g, ' ') };
}
}
}
return { ts: room.getLastActiveTimestamp() || undefined, text: '' };
};
const formatRowTime = (ts: number, hour24Clock: boolean): string => {
const now = Date.now();
const diff = now - ts;
const day = 24 * 60 * 60 * 1000;
const date = new Date(ts);
const today = new Date();
const sameDay =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
if (sameDay) return timeHourMinute(ts, hour24Clock);
if (diff < 7 * day) {
return date.toLocaleDateString(undefined, { weekday: 'short' });
}
return date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
};
type DmStreamRowProps = {
room: Room;
selected: boolean;
linkPath: string;
notificationMode?: RoomNotificationMode;
};
export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmStreamRowProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [hover, setHover] = useState(false);
const { hoverProps } = useHover({ onHoverChange: setHover });
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const typingMember = useRoomTypingMember(room.roomId).filter(
(receipt) => receipt.userId !== mx.getUserId()
);
const roomName = useRoomName(room);
const preview = deriveRoomPreview(room);
const previewText = preview.text;
const previewTs = preview.ts;
const timeLabel = previewTs ? formatRowTime(previewTs, hour24Clock) : '';
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
setMenuAnchor({
x: evt.clientX,
y: evt.clientY,
width: 0,
height: 0,
});
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const optionsVisible = hover || !!menuAnchor;
const callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession);
const startCall = useCallStart(true);
const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) {
return;
}
if (callEmbed) {
return;
}
if (selected) {
evt.preventDefault();
startCall(room, callPref);
}
};
return (
<NavItem
variant="Background"
radii="400"
highlight={unread !== undefined}
aria-selected={selected}
data-hover={!!menuAnchor}
onContextMenu={handleContextMenu}
style={{ minHeight: ROW_MIN_HEIGHT, boxSizing: 'border-box' }}
{...hoverProps}
{...focusWithinProps}
>
<NavLink to={linkPath} onClick={room.isCallRoom() ? handleStartCall : undefined}>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(8)} 0`,
}}
>
<Avatar size="300" radii="400">
<RoomAvatar
roomId={room.roomId}
src={getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Box as="span" alignItems="Baseline" gap="200" style={{ minWidth: 0 }}>
<Box
as="span"
grow="Yes"
alignItems="Baseline"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Text
as="span"
priority={unread ? '500' : '400'}
size="T300"
truncate
style={{ fontWeight: 600 }}
>
{roomName}
</Text>
</Box>
{timeLabel && !optionsVisible && (
<span
style={{
fontFamily: MONO_FONT,
fontSize: toRem(10.5),
color: color.Surface.OnContainer,
opacity: 0.55,
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
{timeLabel}
</span>
)}
</Box>
<Box as="span" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
<Box as="span" grow="Yes" style={{ minWidth: 0, overflow: 'hidden' }}>
{typingMember.length > 0 ? (
<Box as="span" alignItems="Center" gap="100">
<TypingIndicator size="300" disableAnimation />
</Box>
) : (
<Text
as="span"
size="T200"
truncate
style={{
opacity: unread ? 0.85 : 0.6,
fontWeight: unread ? 500 : 400,
}}
>
{previewText}
</Text>
)}
</Box>
{!optionsVisible && unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
{!optionsVisible && notificationMode !== RoomNotificationMode.Unset && (
<Icon
size="50"
src={getRoomNotificationModeIcon(notificationMode)}
aria-label={notificationMode}
/>
)}
{room.isCallRoom() && callMembers.length > 0 && (
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{t('Home.live_count', { count: callMembers.length })}
</Text>
</Badge>
)}
</Box>
</Box>
</Box>
</NavItemContent>
</NavLink>
{optionsVisible && (
<NavItemOptions>
{selected && (callEmbed?.roomId === room.roomId || room.isCallRoom()) && (
<CallChatToggle />
)}
<PopOut
id={`menu-${room.roomId}`}
aria-expanded={!!menuAnchor}
anchor={menuAnchor}
offset={menuAnchor?.width === 0 ? 0 : undefined}
alignOffset={menuAnchor?.width === 0 ? 0 : -5}
position="Bottom"
align={menuAnchor?.width === 0 ? 'Start' : 'End'}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DmStreamRowMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
notificationMode={notificationMode}
/>
</FocusTrap>
}
>
<IconButton
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`}
aria-label="More Options"
variant="Background"
fill="None"
size="300"
radii="300"
>
<Icon size="50" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</NavItemOptions>
)}
</NavItem>
);
}

View file

@ -1,2 +1,3 @@
export * from './RoomNavItem';
export * from './RoomNavCategoryButton';
export * from './DmStreamRow';

View file

@ -1,145 +1,30 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
} from '../../../components/nav';
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { VirtualTile } from '../../../components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { DmStreamRow } from '../../../features/room-nav';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { PageNav, PageNavContent } from '../../../components/page';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
import { DirectStreamHeader } from './DirectStreamHeader';
import { DirectNewChatRow } from './DirectNewChatRow';
import { DirectSelfRow } from './DirectSelfRow';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Direct.mark_as_read')}
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function DirectHeader() {
const { t } = useTranslation();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
{t('Direct.direct_messages')}
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
function DirectEmpty() {
const { t } = useTranslation();
@ -160,9 +45,9 @@ function DirectEmpty() {
</Text>
}
options={
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Button variant="Primary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
{t('Direct.direct_message')}
{t('Direct.start_first_chat')}
</Text>
</Button>
}
@ -171,78 +56,102 @@ function DirectEmpty() {
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
export function Direct() {
function DirectFooterStatus() {
const { t } = useTranslation();
return (
<Box
alignItems="Center"
gap="200"
style={{
padding: `${toRem(8)} ${toRem(14)}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
fontFamily: MONO_FONT,
fontSize: toRem(11),
color: color.Surface.OnContainer,
opacity: 0.45,
}}
>
<span
style={{
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
background: color.Success.Main,
display: 'inline-block',
}}
aria-hidden
/>
<span>vojo.chat</span>
<Box grow="Yes" />
<span>{t('Direct.status_e2ee')}</span>
</Box>
);
}
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
// roomToUnreadAtom only changes on read/unread transitions and ignores own
// events — covers incoming notifying messages but not own sends or muted
// incoming. Kept as a subscribe-only re-render trigger.
useAtomValue(roomToUnreadAtom);
const createDirectSelected = useDirectCreateSelected();
// Activity tick: bump on every live timeline event in any DM room so list
// order, preview text and time labels refresh on own sends, muted incoming,
// reactions — anything roomToUnread misses. Uses the 'Room.timeline'
// literal (= RoomEvent.Timeline) to avoid named imports from matrix-js-sdk
// that the project's moduleResolution flags as TS2614 (see
// docs/known-tech-debt-lint/).
const [, setActivityTick] = useState(0);
useEffect(() => {
const directsSet = new Set(directs);
const handleTimeline = (
_ev: unknown,
room: { roomId: string } | undefined,
_start: unknown,
_removed: unknown,
data: { liveEvent?: boolean } | undefined
) => {
if (!room || !data?.liveEvent) return;
if (!directsSet.has(room.roomId)) return;
setActivityTick((n) => n + 1);
};
const emitter = mx as unknown as {
on: (e: string, h: typeof handleTimeline) => void;
removeListener: (e: string, h: typeof handleTimeline) => void;
};
emitter.on('Room.timeline', handleTimeline);
return () => {
emitter.removeListener('Room.timeline', handleTimeline);
};
}, [mx, directs]);
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
// Sort each render — small list, getLastActiveTimestamp changes outside
// React's dep model so memoising would need a manual trigger anyway.
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
estimateSize: () => 68,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<DirectHeader />
<PageNav size="500">
<DirectStreamHeader />
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{t('Direct.create_chat')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
{t('Direct.chats')}
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
@ -261,11 +170,9 @@ export function Direct() {
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
<DmStreamRow
room={room}
selected={selected}
showAvatar
direct
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
@ -280,6 +187,9 @@ export function Direct() {
</Box>
</PageNavContent>
)}
<DirectNewChatRow />
<DirectSelfRow />
<DirectFooterStatus />
</PageNav>
);
}

View file

@ -0,0 +1,56 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { useNavigate } from 'react-router-dom';
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
import { getDirectCreatePath } from '../../pathUtils';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
const ROW_MIN_HEIGHT = toRem(56);
export function DirectNewChatRow() {
const { t } = useTranslation();
const navigate = useNavigate();
const selected = useDirectCreateSelected();
return (
<Box
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<NavItem
variant="Background"
radii="400"
aria-selected={selected}
style={{ minHeight: ROW_MIN_HEIGHT }}
>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(6)} 0`,
}}
>
<Avatar size="300" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes" style={{ minWidth: 0 }}>
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
{t('Direct.create_chat')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</Box>
);
}

View file

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { Settings } from '../../../features/settings';
import { Modal500 } from '../../../components/Modal500';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
const ROW_MIN_HEIGHT = toRem(68);
export function DirectSelfRow() {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = mx.getSafeUserId();
const profile = useUserProfile(userId);
const [open, setOpen] = useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<>
<Box
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
<NavButton onClick={handleOpen} aria-label={t('Direct.self_row_preview')}>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(8)} 0`,
}}
>
<Avatar size="300" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(displayName)}
</Text>
)}
/>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Box as="span" alignItems="Baseline" gap="100" style={{ minWidth: 0 }}>
<Text
as="span"
size="T300"
truncate
style={{ fontWeight: 600, flexShrink: 1 }}
>
{t('Direct.self_row_label')}
</Text>
<span
style={{
fontFamily: MONO_FONT,
fontSize: toRem(11),
color: color.Surface.OnContainer,
opacity: 0.65,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 1,
minWidth: 0,
}}
>
{userId}
</span>
</Box>
<Text
as="span"
size="T200"
truncate
style={{ opacity: 0.6 }}
>
{t('Direct.self_row_preview')}
</Text>
</Box>
<Icon
src={Icons.Setting}
size="100"
style={{
opacity: 0.55,
flexShrink: 0,
}}
/>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</Box>
{open && (
<Modal500 requestClose={handleClose}>
<Settings requestClose={handleClose} />
</Modal500>
)}
</>
);
}

View file

@ -0,0 +1,87 @@
import React, { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, Tooltip, TooltipProvider, color, toRem } from 'folds';
import { PageNavHeader } from '../../../components/page';
type SegmentProps = {
active: boolean;
disabled?: boolean;
label: string;
onClick?: () => void;
};
const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
({ active, disabled, label, onClick }, ref) => (
<button
ref={ref}
type="button"
onClick={disabled ? undefined : onClick}
aria-pressed={active}
aria-disabled={disabled || undefined}
style={{
appearance: 'none',
border: 'none',
background: active ? color.Background.ContainerActive : 'transparent',
color: color.Background.OnContainer,
opacity: disabled ? 0.45 : 1,
cursor: disabled ? 'default' : 'pointer',
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(6),
font: 'inherit',
fontWeight: active ? 600 : 500,
fontSize: toRem(13),
lineHeight: 1.2,
}}
>
{label}
</button>
)
);
export function DirectStreamHeader() {
const { t } = useTranslation();
const comingSoon = t('Direct.segment_coming_soon');
return (
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="100">
<Segment active label={t('Direct.segment_dm')} />
<TooltipProvider
delay={400}
position="Bottom"
tooltip={
<Tooltip>
<Text size="T200">{comingSoon}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Segment
ref={triggerRef as React.RefCallback<HTMLButtonElement>}
active={false}
disabled
label={t('Direct.segment_channels')}
/>
)}
</TooltipProvider>
<TooltipProvider
delay={400}
position="Bottom"
tooltip={
<Tooltip>
<Text size="T200">{comingSoon}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Segment
ref={triggerRef as React.RefCallback<HTMLButtonElement>}
active={false}
disabled
label={t('Direct.segment_bots')}
/>
)}
</TooltipProvider>
</Box>
</PageNavHeader>
);
}