Split DM create form into separate username and server fields with smart defaults.

This commit is contained in:
v.lagerev 2026-04-25 15:41:29 +03:00
parent 00935aecff
commit 58ec12d42d
4 changed files with 90 additions and 34 deletions

View file

@ -369,11 +369,12 @@
"no_direct_messages_desc": "You do not have any direct messages yet.", "no_direct_messages_desc": "You do not have any direct messages yet.",
"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 user ID.", "create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
"chats": "Chats", "chats": "Chats",
"user_id": "User ID", "username": "Username",
"user_id_placeholder": "@username:server", "username_placeholder": "username",
"invalid_user_id": "Please enter a valid User ID.", "server": "Server",
"invalid_user_id": "Please enter a valid username and server.",
"options": "Options", "options": "Options",
"e2e_encryption": "End-to-End Encryption", "e2e_encryption": "End-to-End Encryption",
"e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.", "e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.",

View file

@ -369,11 +369,12 @@
"no_direct_messages_desc": "У вас ещё нет личных сообщений.", "no_direct_messages_desc": "У вас ещё нет личных сообщений.",
"direct_message": "Новый чат", "direct_message": "Новый чат",
"create_chat": "Новый чат", "create_chat": "Новый чат",
"create_chat_subtitle": "Начните приватный зашифрованный чат, указав ID пользователя.", "create_chat_subtitle": "Начните приватный зашифрованный чат, указав имя пользователя.",
"chats": "Чаты", "chats": "Чаты",
"user_id": "ID пользователя", "username": "Имя пользователя",
"user_id_placeholder": "@username:server", "username_placeholder": "username",
"invalid_user_id": "Введите корректный ID пользователя.", "server": "Сервер",
"invalid_user_id": "Введите корректные имя пользователя и сервер.",
"options": "Параметры", "options": "Параметры",
"e2e_encryption": "Сквозное шифрование", "e2e_encryption": "Сквозное шифрование",
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.", "e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",

View file

@ -1,11 +1,30 @@
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds'; import {
Box,
Button,
color,
config,
Icon,
Icons,
Input,
Spinner,
Switch,
Text,
toRem,
} from 'folds';
import React, { FormEventHandler, useCallback, useState } from 'react'; import React, { FormEventHandler, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk'; import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SettingTile } from '../../components/setting-tile'; import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { addRoomIdToMDirect, getDMRoomFor, isUserId } from '../../utils/matrix'; import {
addRoomIdToMDirect,
getDMRoomFor,
getMxIdLocalPart,
getMxIdServer,
isServerName,
isUserId,
} from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
@ -14,6 +33,8 @@ import { createRoomEncryptionState } from '../../components/create-room';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { getDirectRoomPath } from '../../pages/pathUtils'; import { getDirectRoomPath } from '../../pages/pathUtils';
const FALLBACK_SERVER = 'vojo.chat';
type CreateChatProps = { type CreateChatProps = {
defaultUserId?: string; defaultUserId?: string;
}; };
@ -23,6 +44,10 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
const alive = useAlive(); const alive = useAlive();
const navigate = useNavigate(); const navigate = useNavigate();
const userServer = getMxIdServer(mx.getSafeUserId()) ?? FALLBACK_SERVER;
const defaultUsername = defaultUserId ? getMxIdLocalPart(defaultUserId) : undefined;
const defaultServer = defaultUserId ? getMxIdServer(defaultUserId) : undefined;
const [encryption, setEncryption] = useState(false); const [encryption, setEncryption] = useState(false);
const [invalidUserId, setInvalidUserId] = useState(false); const [invalidUserId, setInvalidUserId] = useState(false);
@ -57,28 +82,42 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
setInvalidUserId(false); setInvalidUserId(false);
const target = evt.target as HTMLFormElement | undefined; const target = evt.target as HTMLFormElement | undefined;
const userIdInput = target?.userIdInput as HTMLInputElement | undefined; const usernameInput = target?.usernameInput as HTMLInputElement | undefined;
const userId = userIdInput?.value.trim(); const serverInput = target?.serverInput as HTMLInputElement | undefined;
let rawUsername = usernameInput?.value.trim() ?? '';
const server = serverInput?.value.trim() || userServer;
if (!userIdInput || !userId) return; if (!usernameInput || !rawUsername) return;
if (!isUserId(userId)) {
// If user pasted a full MXID like @alice:matrix.org, split it into fields
if (isUserId(rawUsername)) {
const parsedLocal = getMxIdLocalPart(rawUsername);
const parsedServer = getMxIdServer(rawUsername);
if (parsedLocal && parsedServer) {
rawUsername = parsedLocal;
usernameInput.value = parsedLocal;
if (serverInput) serverInput.value = parsedServer;
}
}
const username = rawUsername.replace(/^@/, '');
if (!username || /[@:\s]/.test(username) || !isServerName(server)) {
setInvalidUserId(true); setInvalidUserId(true);
return; return;
} }
// Route to an existing DM (encrypted or not) before creating a new room; const userId = `@${username}:${server}`;
// otherwise every submit from the card's "Message" button creates a new
// duplicate room even when a perfectly fine DM already exists.
const existing = getDMRoomFor(mx, userId); const existing = getDMRoomFor(mx, userId);
if (existing) { if (existing) {
userIdInput.value = ''; usernameInput.value = '';
navigate(getDirectRoomPath(existing.roomId)); navigate(getDirectRoomPath(existing.roomId));
return; return;
} }
create(userId, encryption).then((roomId) => { create(userId, encryption).then((roomId) => {
if (alive()) { if (alive()) {
userIdInput.value = ''; usernameInput.value = '';
navigate(getDirectRoomPath(roomId)); navigate(getDirectRoomPath(roomId));
} }
}); });
@ -87,19 +126,36 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
return ( return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500"> <Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Direct.user_id')}</Text> <Box direction="Row" wrap="Wrap" gap="200">
<Input <Box grow="Yes" style={{ minWidth: toRem(120) }} direction="Column" gap="100">
defaultValue={defaultUserId} <Text size="L400">{t('Direct.username')}</Text>
placeholder={t('Direct.user_id_placeholder')} <Input
name="userIdInput" defaultValue={defaultUsername}
variant="SurfaceVariant" placeholder={t('Direct.username_placeholder')}
size="500" name="usernameInput"
radii="400" variant="SurfaceVariant"
required size="500"
autoFocus radii="400"
autoComplete="off" required
disabled={disabled} autoFocus
/> autoComplete="off"
disabled={disabled}
/>
</Box>
<Box shrink="No" style={{ minWidth: toRem(150) }} direction="Column" gap="100">
<Text size="L400">{t('Direct.server')}</Text>
<Input
defaultValue={defaultServer && defaultServer !== userServer ? defaultServer : undefined}
placeholder={userServer}
name="serverInput"
variant="SurfaceVariant"
size="500"
radii="400"
autoComplete="off"
disabled={disabled}
/>
</Box>
</Box>
{invalidUserId && ( {invalidUserId && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100"> <Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" /> <Icon src={Icons.Warning} filled size="50" />

View file

@ -61,9 +61,7 @@ export function DirectCreate() {
<PageHeroSection> <PageHeroSection>
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<PageHero <PageHero
icon={<Icon size="600" src={Icons.Mention} />}
title={t('Direct.create_chat')} title={t('Direct.create_chat')}
subTitle={t('Direct.create_chat_subtitle')}
/> />
<CreateChat defaultUserId={userId} /> <CreateChat defaultUserId={userId} />
</Box> </Box>