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
0c89e9fda0
commit
ed3e5c0640
9 changed files with 890 additions and 177 deletions
|
|
@ -370,6 +370,14 @@
|
||||||
"direct_message": "Direct Message",
|
"direct_message": "Direct Message",
|
||||||
"create_chat": "Create Chat",
|
"create_chat": "Create Chat",
|
||||||
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
"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",
|
"chats": "Chats",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"username_placeholder": "username",
|
"username_placeholder": "username",
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,14 @@
|
||||||
"direct_message": "Новый чат",
|
"direct_message": "Новый чат",
|
||||||
"create_chat": "Новый чат",
|
"create_chat": "Новый чат",
|
||||||
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
|
"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": "Чаты",
|
"chats": "Чаты",
|
||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"username_placeholder": "username",
|
"username_placeholder": "username",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
export const PageNav = recipe({
|
export const PageNav = recipe({
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
'500': {
|
||||||
|
width: toRem(320),
|
||||||
|
},
|
||||||
'400': {
|
'400': {
|
||||||
width: toRem(256),
|
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 './RoomNavItem';
|
||||||
export * from './RoomNavCategoryButton';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import {
|
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Icons,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
PopOut,
|
|
||||||
RectCords,
|
|
||||||
Text,
|
|
||||||
config,
|
|
||||||
toRem,
|
|
||||||
} from 'folds';
|
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { factoryRoomIdByActivity } from '../../../utils/sort';
|
import { factoryRoomIdByActivity } from '../../../utils/sort';
|
||||||
import {
|
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
||||||
NavButton,
|
|
||||||
NavCategory,
|
|
||||||
NavCategoryHeader,
|
|
||||||
NavEmptyCenter,
|
|
||||||
NavEmptyLayout,
|
|
||||||
NavItem,
|
|
||||||
NavItemContent,
|
|
||||||
} from '../../../components/nav';
|
|
||||||
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
|
import { DmStreamRow } from '../../../features/room-nav';
|
||||||
import { makeNavCategoryId } from '../../../state/closedNavCategories';
|
|
||||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||||
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
|
||||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||||
import { useDirectRooms } from './useDirectRooms';
|
import { useDirectRooms } from './useDirectRooms';
|
||||||
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
|
import { PageNav, PageNavContent } 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 {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
|
import { DirectStreamHeader } from './DirectStreamHeader';
|
||||||
|
import { DirectNewChatRow } from './DirectNewChatRow';
|
||||||
|
import { DirectSelfRow } from './DirectSelfRow';
|
||||||
|
|
||||||
type DirectMenuProps = {
|
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DirectEmpty() {
|
function DirectEmpty() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -160,9 +45,9 @@ function DirectEmpty() {
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
options={
|
options={
|
||||||
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
|
<Button variant="Primary" size="300" onClick={() => navigate(getDirectCreatePath())}>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
{t('Direct.direct_message')}
|
{t('Direct.start_first_chat')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|
@ -171,78 +56,102 @@ function DirectEmpty() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
function DirectFooterStatus() {
|
||||||
export function Direct() {
|
|
||||||
const { t } = useTranslation();
|
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();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('direct');
|
useNavToActivePathMapper('direct');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const directs = useDirectRooms();
|
const directs = useDirectRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
// roomToUnreadAtom only changes on read/unread transitions and ignores own
|
||||||
const navigate = useNavigate();
|
// 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 selectedRoomId = useSelectedRoom();
|
||||||
const noRoomToDisplay = directs.length === 0;
|
const noRoomToDisplay = directs.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
|
||||||
|
|
||||||
const sortedDirects = useMemo(() => {
|
// Sort each render — small list, getLastActiveTimestamp changes outside
|
||||||
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
// React's dep model so memoising would need a manual trigger anyway.
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
const sortedDirects = Array.from(directs).sort(factoryRoomIdByActivity(mx));
|
||||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: sortedDirects.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 68,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
|
||||||
closedCategories.has(categoryId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav>
|
<PageNav size="500">
|
||||||
<DirectHeader />
|
<DirectStreamHeader />
|
||||||
{noRoomToDisplay ? (
|
{noRoomToDisplay ? (
|
||||||
<DirectEmpty />
|
<DirectEmpty />
|
||||||
) : (
|
) : (
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<NavCategory>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
@ -261,11 +170,9 @@ export function Direct() {
|
||||||
key={vItem.index}
|
key={vItem.index}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
>
|
>
|
||||||
<RoomNavItem
|
<DmStreamRow
|
||||||
room={room}
|
room={room}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
showAvatar
|
|
||||||
direct
|
|
||||||
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
notificationMode={getRoomNotificationMode(
|
notificationMode={getRoomNotificationMode(
|
||||||
notificationPreferences,
|
notificationPreferences,
|
||||||
|
|
@ -280,6 +187,9 @@ export function Direct() {
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
|
<DirectNewChatRow />
|
||||||
|
<DirectSelfRow />
|
||||||
|
<DirectFooterStatus />
|
||||||
</PageNav>
|
</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