-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';
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';
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;
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);
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 (
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>
</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} />
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './CreateChat';
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';
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]: {
} 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 {
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';
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';
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
+import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
type DirectMenuProps = {
requestClose: () => void;
}
function DirectEmpty() {
+ const navigate = useNavigate();
+
return (
<NavEmptyCenter>
<NavEmptyLayout
</Text>
}
options={
- <Button variant="Secondary" size="300" onClick={() => openInviteUser()}>
+ <Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
Direct Message
</Text>
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
+ const navigate = useNavigate();
+
+ const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
<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">
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>
+ );
}
<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>
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)
}
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];
}
});
- await mx.setAccountData(AccountDataEvent.Direct, userIdToRoomIds);
+ await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
};
export const mxcUrlToHttp = (