New create chat screen (#2463)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 24 Aug 2025 12:40:44 +0000 (18:10 +0530)
committerGitHub <noreply@github.com>
Sun, 24 Aug 2025 12:40:44 +0000 (22:40 +1000)
* fix dm invite appears in home

* use migrated function for convert to dm/room commands

* add new create chat screen

src/app/components/user-profile/UserRoomProfile.tsx
src/app/features/create-chat/CreateChat.tsx [new file with mode: 0644]
src/app/features/create-chat/index.ts [new file with mode: 0644]
src/app/hooks/useCommands.ts
src/app/pages/client/direct/Direct.tsx
src/app/pages/client/direct/DirectCreate.tsx
src/app/pages/client/home/CreateRoom.tsx
src/app/utils/matrix.ts

index b8b34d788289cdea4c3fcefd546c7b74bf7af90f..78d201ec69902bd5439db4ffe1d8567383a1c977 100644 (file)
@@ -1,7 +1,8 @@
-import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds';
-import React, { useCallback } from 'react';
+import { Box, Button, config, Icon, Icons, Text } from 'folds';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
 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 { useMatrixClient } from '../../hooks/useMatrixClient';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -9,11 +10,6 @@ import { usePowerLevels } from '../../hooks/usePowerLevels';
 import { useRoom } from '../../hooks/useRoom';
 import { useUserPresence } from '../../hooks/useUserPresence';
 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 { PowerChip } from './PowerChip';
 import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
@@ -24,6 +20,8 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
 import { useRoomPermissions } from '../../hooks/useRoomPermissions';
 import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
 import { CreatorChip } from './CreatorChip';
+import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
+import { DirectCreateSearchParams } from '../../pages/paths';
 
 type UserRoomProfileProps = {
   userId: string;
@@ -31,8 +29,7 @@ type UserRoomProfileProps = {
 export function UserRoomProfile({ userId }: UserRoomProfileProps) {
   const mx = useMatrixClient();
   const useAuthentication = useMediaAuthentication();
-  const { navigateRoom } = useRoomNavigate();
-  const alive = useAlive();
+  const navigate = useNavigate();
   const closeUserRoomProfile = useCloseUserRoomProfile();
   const ignoredUsers = useIgnoredUsers();
   const ignored = ignoredUsers.includes(userId);
@@ -62,26 +59,12 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 
   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 dmRoomId = getDMRoomFor(mx, userId)?.roomId;
-    if (dmRoomId) {
-      navigateRoom(dmRoomId);
-      closeUserRoomProfile();
-      return;
-    }
-    directMessage().then((rId) => {
-      if (alive()) {
-        navigateRoom(rId);
-        closeUserRoomProfile();
-      }
-    });
+    closeUserRoomProfile();
+    const directSearchParam: DirectCreateSearchParams = {
+      userId,
+    };
+    navigate(withSearchParam(getDirectCreatePath(), directSearchParam));
   };
 
   return (
@@ -102,14 +85,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
                   variant="Primary"
                   fill="Solid"
                   radii="300"
-                  disabled={directMessageState.status === AsyncStatus.Loading}
-                  before={
-                    directMessageState.status === AsyncStatus.Loading ? (
-                      <Spinner size="50" variant="Primary" fill="Solid" />
-                    ) : (
-                      <Icon size="50" src={Icons.Message} filled />
-                    )
-                  }
+                  before={<Icon size="50" src={Icons.Message} filled />}
                   onClick={handleMessage}
                 >
                   <Text size="B300">Message</Text>
@@ -117,11 +93,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
               </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">
             {server && <ServerChip server={server} />}
             <ShareChip userId={userId} />
diff --git a/src/app/features/create-chat/CreateChat.tsx b/src/app/features/create-chat/CreateChat.tsx
new file mode 100644 (file)
index 0000000..eccd4e6
--- /dev/null
@@ -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>
+  );
+}
diff --git a/src/app/features/create-chat/index.ts b/src/app/features/create-chat/index.ts
new file mode 100644 (file)
index 0000000..ed988df
--- /dev/null
@@ -0,0 +1 @@
+export * from './CreateChat';
index c95142e80af7dae9fadb09e41f30275a99e1d8a6..4d70465d70418faf9d9120b46b40ff6f2355637c 100644 (file)
@@ -2,12 +2,15 @@ import { Direction, IContextResponse, MatrixClient, Method, Room, RoomMember } f
 import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
 import { useMemo } from 'react';
 import {
+  addRoomIdToMDirect,
   getDMRoomFor,
+  guessDmRoomUserId,
   isRoomAlias,
   isRoomId,
   isServerName,
   isUserId,
   rateLimitedActions,
+  removeRoomIdFromMDirect,
 } from '../utils/matrix';
 import { hasDevices } from '../../util/matrixUtil';
 import * as roomActions from '../../client/action/room';
@@ -348,14 +351,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
         name: Command.ConvertToDm,
         description: 'Convert room to direct message',
         exe: async () => {
-          roomActions.convertToDm(mx, room.roomId);
+          const dmUserId = guessDmRoomUserId(room, mx.getSafeUserId());
+          await addRoomIdToMDirect(mx, room.roomId, dmUserId);
         },
       },
       [Command.ConvertToRoom]: {
         name: Command.ConvertToRoom,
         description: 'Convert direct message to room',
         exe: async () => {
-          roomActions.convertToRoom(mx, room.roomId);
+          await removeRoomIdFromMDirect(mx, room.roomId);
         },
       },
       [Command.Delete]: {
index b6a8de1a0af9d326fc5e5163d7ddc644f7274f8d..51de39460a14befc333ff1b616792421a38d0fad 100644 (file)
@@ -17,6 +17,7 @@ import {
 } 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 {
@@ -28,7 +29,7 @@ import {
   NavItem,
   NavItemContent,
 } from '../../../components/nav';
-import { getDirectRoomPath } from '../../pathUtils';
+import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
 import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
 import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
 import { VirtualTile } from '../../../components/virtualizer';
@@ -38,7 +39,6 @@ import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
 import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
 import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
 import { useDirectRooms } from './useDirectRooms';
-import { openInviteUser } from '../../../../client/action/navigation';
 import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
 import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
 import { useRoomsUnread } from '../../../state/hooks/unread';
@@ -50,6 +50,7 @@ import {
   getRoomNotificationMode,
   useRoomsNotificationPreferencesContext,
 } from '../../../hooks/useRoomsNotificationPreferences';
+import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
 
 type DirectMenuProps = {
   requestClose: () => void;
@@ -138,6 +139,8 @@ function DirectHeader() {
 }
 
 function DirectEmpty() {
+  const navigate = useNavigate();
+
   return (
     <NavEmptyCenter>
       <NavEmptyLayout
@@ -153,7 +156,7 @@ function DirectEmpty() {
           </Text>
         }
         options={
-          <Button variant="Secondary" size="300" onClick={() => openInviteUser()}>
+          <Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
             <Text size="B300" truncate>
               Direct Message
             </Text>
@@ -172,6 +175,9 @@ export function Direct() {
   const directs = useDirectRooms();
   const notificationPreferences = useRoomsNotificationPreferencesContext();
   const roomToUnread = useAtomValue(roomToUnreadAtom);
+  const navigate = useNavigate();
+
+  const createDirectSelected = useDirectCreateSelected();
 
   const selectedRoomId = useSelectedRoom();
   const noRoomToDisplay = directs.length === 0;
@@ -205,8 +211,8 @@ export function Direct() {
         <PageNavContent scrollRef={scrollRef}>
           <Box direction="Column" gap="300">
             <NavCategory>
-              <NavItem variant="Background" radii="400">
-                <NavButton onClick={() => openInviteUser()}>
+              <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">
index 3affb9c17cdfca162c6233d196c342e562b02ae7..3deb0b6dd25f35756c04cf063084849a9b45e941 100644 (file)
@@ -1,33 +1,75 @@
 import React, { useEffect } from 'react';
 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 { getDirectCreateSearchParams } from '../../pathSearchParam';
-import { getDirectPath, getDirectRoomPath } from '../../pathUtils';
+import { getDirectRoomPath } from '../../pathUtils';
 import { getDMRoomFor } from '../../../utils/matrix';
-import { openInviteUser } from '../../../../client/action/navigation';
 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() {
   const mx = useMatrixClient();
+  const screenSize = useScreenSizeContext();
+
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
   const { userId } = getDirectCreateSearchParams(searchParams);
+
   const directs = useDirectRooms();
 
   useEffect(() => {
     if (userId) {
-      const room = getDMRoomFor(mx, userId);
-      const { roomId } = room ?? {};
+      const roomId = getDMRoomFor(mx, userId)?.roomId;
       if (roomId && directs.includes(roomId)) {
         navigate(getDirectRoomPath(roomId), { replace: true });
-      } else {
-        openInviteUser(undefined, userId);
       }
-    } else {
-      navigate(getDirectPath(), { replace: true });
     }
   }, [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>
+  );
 }
index 20c01baed8ce10ffd22f12d19c2b904ed8d89a50..fddd75aac1fc5b34b5a3203c0248587803430b11 100644 (file)
@@ -42,7 +42,7 @@ export function HomeCreateRoom() {
                   <PageHero
                     icon={<Icon size="600" src={Icons.Hash} />}
                     title="Create Room"
-                    subTitle="Build a Room for Real-Time Conversations"
+                    subTitle="Build a Room for Real-Time Conversations."
                   />
                   <CreateRoomForm onCreate={navigateRoom} />
                 </Box>
index a8031202360a889c810aed13d2a2549230f87f15..4c86c4e26fbc50480455289508f6c1e1610fbdd1 100644 (file)
@@ -230,8 +230,11 @@ export const addRoomIdToMDirect = async (
   roomId: string,
   userId: string
 ): Promise<void> => {
-  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct);
-  const userIdToRoomIds: Record<string, string[]> = mDirectsEvent?.getContent() ?? {};
+  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
+  let userIdToRoomIds: Record<string, string[]> = {};
+
+  if (typeof mDirectsEvent !== 'undefined')
+    userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
 
   // remove it from the lists of any others users
   // (it can only be a DM room for one person)
@@ -252,12 +255,15 @@ export const addRoomIdToMDirect = async (
   }
   userIdToRoomIds[userId] = roomIds;
 
-  await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+  await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
 };
 
 export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => {
-  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct);
-  const userIdToRoomIds: Record<string, string[]> = mDirectsEvent?.getContent() ?? {};
+  const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
+  let userIdToRoomIds: Record<string, string[]> = {};
+
+  if (typeof mDirectsEvent !== 'undefined')
+    userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
 
   Object.keys(userIdToRoomIds).forEach((targetUserId) => {
     const roomIds = userIdToRoomIds[targetUserId];
@@ -267,7 +273,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
     }
   });
 
-  await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+  await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
 };
 
 export const mxcUrlToHttp = (