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:
parent
b41fbfabec
commit
020c9355fb
9 changed files with 890 additions and 177 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
513
src/app/features/room-nav/DmStreamRow.tsx
Normal file
513
src/app/features/room-nav/DmStreamRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './RoomNavItem';
|
||||
export * from './RoomNavCategoryButton';
|
||||
export * from './DmStreamRow';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
src/app/pages/client/direct/DirectNewChatRow.tsx
Normal file
56
src/app/pages/client/direct/DirectNewChatRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/app/pages/client/direct/DirectSelfRow.tsx
Normal file
127
src/app/pages/client/direct/DirectSelfRow.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/app/pages/client/direct/DirectStreamHeader.tsx
Normal file
87
src/app/pages/client/direct/DirectStreamHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue