add new create chat screen

This commit is contained in:
Ajay Bura 2025-08-24 14:10:07 +05:30
parent dfd9809ddd
commit 492b3e17a4
6 changed files with 228 additions and 58 deletions

View file

@ -1,7 +1,8 @@
import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds'; import { Box, Button, config, Icon, Icons, Text } from 'folds';
import React, { useCallback } from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero'; import { UserHero, UserHeroName } from './UserHero';
import { getDMRoomFor, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix'; import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -9,11 +10,6 @@ import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence'; import { useUserPresence } from '../../hooks/useUserPresence';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { createDM } from '../../../client/action/room';
import { hasDevices } from '../../../util/matrixUtil';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useAlive } from '../../hooks/useAlive';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip'; import { PowerChip } from './PowerChip';
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration'; import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
@ -24,6 +20,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip'; import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
type UserRoomProfileProps = { type UserRoomProfileProps = {
userId: string; userId: string;
@ -31,8 +29,7 @@ type UserRoomProfileProps = {
export function UserRoomProfile({ userId }: UserRoomProfileProps) { export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate(); const navigate = useNavigate();
const alive = useAlive();
const closeUserRoomProfile = useCloseUserRoomProfile(); const closeUserRoomProfile = useCloseUserRoomProfile();
const ignoredUsers = useIgnoredUsers(); const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId); const ignored = ignoredUsers.includes(userId);
@ -62,26 +59,12 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const presence = useUserPresence(userId); const presence = useUserPresence(userId);
const [directMessageState, directMessage] = useAsyncCallback<string, Error, []>(
useCallback(async () => {
const result = await createDM(mx, userId, await hasDevices(mx, userId));
return result.room_id as string;
}, [userId, mx])
);
const handleMessage = () => { const handleMessage = () => {
const dmRoomId = getDMRoomFor(mx, userId)?.roomId;
if (dmRoomId) {
navigateRoom(dmRoomId);
closeUserRoomProfile(); closeUserRoomProfile();
return; const directSearchParam: DirectCreateSearchParams = {
} userId,
directMessage().then((rId) => { };
if (alive()) { navigate(withSearchParam(getDirectCreatePath(), directSearchParam));
navigateRoom(rId);
closeUserRoomProfile();
}
});
}; };
return ( return (
@ -101,25 +84,13 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
variant="Primary" variant="Primary"
fill="Solid" fill="Solid"
radii="300" radii="300"
disabled={directMessageState.status === AsyncStatus.Loading} before={<Icon size="50" src={Icons.Message} filled />}
before={
directMessageState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Primary" fill="Solid" />
) : (
<Icon size="50" src={Icons.Message} filled />
)
}
onClick={handleMessage} onClick={handleMessage}
> >
<Text size="B300">Message</Text> <Text size="B300">Message</Text>
</Button> </Button>
</Box> </Box>
</Box> </Box>
{directMessageState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }}>
<b>{directMessageState.error.message}</b>
</Text>
)}
<Box alignItems="Center" gap="200" wrap="Wrap"> <Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />} {server && <ServerChip server={server} />}
<ShareChip userId={userId} /> <ShareChip userId={userId} />

View file

@ -0,0 +1,150 @@
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
import React, { FormEventHandler, useCallback, useState } from 'react';
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
import { useNavigate } from 'react-router-dom';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import { addRoomIdToMDirect, isUserId } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode';
import { millisecondsToMinutes } from '../../utils/common';
import { createRoomEncryptionState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils';
type CreateChatProps = {
defaultUserId?: string;
};
export function CreateChat({ defaultUserId }: CreateChatProps) {
const mx = useMatrixClient();
const alive = useAlive();
const navigate = useNavigate();
const [encryption, setEncryption] = useState(true);
const [invalidUserId, setInvalidUserId] = useState(false);
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [string, boolean]>(
useCallback(
async (userId, encrypted) => {
const initialState: ICreateRoomStateEvent[] = [];
if (encrypted) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({
is_direct: true,
invite: [userId],
visibility: Visibility.Private,
preset: Preset.TrustedPrivateChat,
initial_state: initialState,
});
addRoomIdToMDirect(mx, result.room_id, userId);
return result.room_id;
},
[mx]
)
);
const loading = createState.status === AsyncStatus.Loading;
const error = createState.status === AsyncStatus.Error ? createState.error : undefined;
const disabled = createState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setInvalidUserId(false);
const target = evt.target as HTMLFormElement | undefined;
const userIdInput = target?.userIdInput as HTMLInputElement | undefined;
const userId = userIdInput?.value.trim();
if (!userIdInput || !userId) return;
if (!isUserId(userId)) {
setInvalidUserId(true);
return;
}
create(userId, encryption).then((roomId) => {
if (alive()) {
userIdInput.value = '';
navigate(getDirectRoomPath(roomId));
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100">
<Text size="L400">User ID</Text>
<Input
defaultValue={defaultUserId}
placeholder="@john:server"
name="userIdInput"
variant="SurfaceVariant"
size="500"
radii="400"
required
autoFocus
autoComplete="off"
disabled={disabled}
/>
{invalidUserId && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" />
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Please enter a valid User ID.</b>
</Text>
</Box>
)}
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="End-to-End Encryption"
description="Once this feature is enabled, it can't be disabled after the room is created."
after={
<Switch
variant="Primary"
value={encryption}
onChange={setEncryption}
disabled={disabled}
/>
}
/>
</SequenceCard>
</Box>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
? `Server rate-limited your request for ${millisecondsToMinutes(
(error.data.retry_after_ms as number | undefined) ?? 0
)} minutes!`
: error.message}
</b>
</Text>
</Box>
)}
<Box shrink="No" direction="Column" gap="200">
<Button
type="submit"
size="500"
variant="Primary"
radii="400"
disabled={disabled}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">Create</Text>
</Button>
</Box>
</Box>
);
}

View file

@ -0,0 +1 @@
export * from './CreateChat';

View file

@ -17,6 +17,7 @@ import {
} from 'folds'; } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
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 {
@ -28,7 +29,7 @@ import {
NavItem, NavItem,
NavItemContent, NavItemContent,
} from '../../../components/nav'; } from '../../../components/nav';
import { 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';
@ -38,7 +39,6 @@ import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms'; import { useDirectRooms } from './useDirectRooms';
import { openInviteUser } from '../../../../client/action/navigation';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../../state/hooks/unread'; import { useRoomsUnread } from '../../../state/hooks/unread';
@ -50,6 +50,7 @@ import {
getRoomNotificationMode, getRoomNotificationMode,
useRoomsNotificationPreferencesContext, useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences'; } from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
type DirectMenuProps = { type DirectMenuProps = {
requestClose: () => void; requestClose: () => void;
@ -138,6 +139,8 @@ function DirectHeader() {
} }
function DirectEmpty() { function DirectEmpty() {
const navigate = useNavigate();
return ( return (
<NavEmptyCenter> <NavEmptyCenter>
<NavEmptyLayout <NavEmptyLayout
@ -153,7 +156,7 @@ function DirectEmpty() {
</Text> </Text>
} }
options={ options={
<Button variant="Secondary" size="300" onClick={() => openInviteUser()}> <Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate> <Text size="B300" truncate>
Direct Message Direct Message
</Text> </Text>
@ -172,6 +175,9 @@ export function Direct() {
const directs = useDirectRooms(); const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom); const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom(); const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0; const noRoomToDisplay = directs.length === 0;
@ -205,8 +211,8 @@ export function Direct() {
<PageNavContent scrollRef={scrollRef}> <PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
<NavCategory> <NavCategory>
<NavItem variant="Background" radii="400"> <NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
<NavButton onClick={() => openInviteUser()}> <NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent> <NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200"> <Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">

View file

@ -1,33 +1,75 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { WelcomePage } from '../WelcomePage'; import { Box, Icon, IconButton, Icons, Scroll } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getDirectCreateSearchParams } from '../../pathSearchParam'; import { getDirectCreateSearchParams } from '../../pathSearchParam';
import { getDirectPath, getDirectRoomPath } from '../../pathUtils'; import { getDirectRoomPath } from '../../pathUtils';
import { getDMRoomFor } from '../../../utils/matrix'; import { getDMRoomFor } from '../../../utils/matrix';
import { openInviteUser } from '../../../../client/action/navigation';
import { useDirectRooms } from './useDirectRooms'; import { useDirectRooms } from './useDirectRooms';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import {
Page,
PageContent,
PageContentCenter,
PageHeader,
PageHero,
PageHeroSection,
} from '../../../components/page';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { CreateChat } from '../../../features/create-chat';
export function DirectCreate() { export function DirectCreate() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { userId } = getDirectCreateSearchParams(searchParams); const { userId } = getDirectCreateSearchParams(searchParams);
const directs = useDirectRooms(); const directs = useDirectRooms();
useEffect(() => { useEffect(() => {
if (userId) { if (userId) {
const room = getDMRoomFor(mx, userId); const roomId = getDMRoomFor(mx, userId)?.roomId;
const { roomId } = room ?? {};
if (roomId && directs.includes(roomId)) { if (roomId && directs.includes(roomId)) {
navigate(getDirectRoomPath(roomId), { replace: true }); navigate(getDirectRoomPath(roomId), { replace: true });
} else {
openInviteUser(undefined, userId);
} }
} else {
navigate(getDirectPath(), { replace: true });
} }
}, [mx, navigate, directs, userId]); }, [mx, navigate, directs, userId]);
return <WelcomePage />; return (
<Page>
{screenSize === ScreenSize.Mobile && (
<PageHeader balance outlined={false}>
<Box grow="Yes" alignItems="Center" gap="200">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
</Box>
</PageHeader>
)}
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<PageHeroSection>
<Box direction="Column" gap="700">
<PageHero
icon={<Icon size="600" src={Icons.Mention} />}
title="Create Chat"
subTitle="Start a private, encrypted chat by entering a user ID."
/>
<CreateChat defaultUserId={userId} />
</Box>
</PageHeroSection>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
} }

View file

@ -42,7 +42,7 @@ export function HomeCreateRoom() {
<PageHero <PageHero
icon={<Icon size="600" src={Icons.Hash} />} icon={<Icon size="600" src={Icons.Hash} />}
title="Create Room" title="Create Room"
subTitle="Build a Room for Real-Time Conversations" subTitle="Build a Room for Real-Time Conversations."
/> />
<CreateRoomForm onCreate={navigateRoom} /> <CreateRoomForm onCreate={navigateRoom} />
</Box> </Box>