Merge branch 'dev' into usability-tweaks

This commit is contained in:
Gimle Larpes 2025-08-05 16:24:09 +03:00 committed by GitHub
commit 8844b0ac4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1708 additions and 89 deletions

View file

@ -0,0 +1,118 @@
import React, {
FormEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { getMxIdServer } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { replaceSpaceWithDash } from '../../utils/common';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
import { useDebounce } from '../../hooks/useDebounce';
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const mx = useMatrixClient();
const aliasInputRef = useRef<HTMLInputElement>(null);
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
status: AsyncStatus.Idle,
});
useEffect(() => {
if (aliasAvail.status === AsyncStatus.Success && aliasInputRef.current?.value === '') {
setAliasAvail({ status: AsyncStatus.Idle });
}
}, [aliasAvail]);
const checkAliasAvail = useAsync(
useCallback(
async (aliasLocalPart: string) => {
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
try {
const result = await mx.getRoomIdForAlias(roomAlias);
return typeof result.room_id !== 'string';
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404) {
return true;
}
throw e;
}
},
[mx]
),
setAliasAvail
);
const aliasAvailable: boolean | undefined =
aliasAvail.status === AsyncStatus.Success ? aliasAvail.data : undefined;
const debounceCheckAliasAvail = useDebounce(checkAliasAvail, { wait: 500 });
const handleAliasChange: FormEventHandler<HTMLInputElement> = (evt) => {
const aliasInput = evt.currentTarget;
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
if (aliasLocalPart) {
aliasInput.value = aliasLocalPart;
debounceCheckAliasAvail(aliasLocalPart);
} else {
setAliasAvail({ status: AsyncStatus.Idle });
}
};
const handleAliasKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('enter', evt)) {
evt.preventDefault();
const aliasInput = evt.currentTarget;
const aliasLocalPart = replaceSpaceWithDash(aliasInput.value);
if (aliasLocalPart) {
checkAliasAvail(aliasLocalPart);
} else {
setAliasAvail({ status: AsyncStatus.Idle });
}
}
};
return (
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Address (Optional)</Text>
<Text size="T200" priority="300">
Pick an unique address to make it discoverable.
</Text>
<Input
ref={aliasInputRef}
onChange={handleAliasChange}
before={
aliasAvail.status === AsyncStatus.Loading ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={Icons.Hash} />
)
}
after={
<Text style={{ maxWidth: toRem(150) }} truncate>
:{getMxIdServer(mx.getSafeUserId())}
</Text>
}
onKeyDown={handleAliasKeyDown}
name="aliasInput"
size="500"
variant={aliasAvailable === true ? 'Success' : 'SurfaceVariant'}
radii="400"
autoComplete="off"
disabled={disabled}
/>
{aliasAvailable === false && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon src={Icons.Warning} filled size="50" />
<Text size="T200">
<b>This address is already taken. Please select a different one.</b>
</Text>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,94 @@
import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
export enum CreateRoomKind {
Private = 'private',
Restricted = 'restricted',
Public = 'public',
}
type CreateRoomKindSelectorProps = {
value?: CreateRoomKind;
onSelect: (value: CreateRoomKind) => void;
canRestrict?: boolean;
disabled?: boolean;
getIcon: (kind: CreateRoomKind) => IconSrc;
};
export function CreateRoomKindSelector({
value,
onSelect,
canRestrict,
disabled,
getIcon,
}: CreateRoomKindSelectorProps) {
return (
<Box shrink="No" direction="Column" gap="100">
{canRestrict && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Restricted}
onClick={() => onSelect(CreateRoomKind.Restricted)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
>
<Text size="H6">Restricted</Text>
<Text size="T300" priority="300">
Only member of parent space can join.
</Text>
</SettingTile>
</SequenceCard>
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Private}
onClick={() => onSelect(CreateRoomKind.Private)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
>
<Text size="H6">Private</Text>
<Text size="T300" priority="300">
Only people with invite can join.
</Text>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Public}
onClick={() => onSelect(CreateRoomKind.Public)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
>
<Text size="H6">Public</Text>
<Text size="T300" priority="300">
Anyone with the address can join.
</Text>
</SettingTile>
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,117 @@
import React, { MouseEventHandler, useState } from 'react';
import {
Box,
Button,
Chip,
config,
Icon,
Icons,
Menu,
PopOut,
RectCords,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SettingTile } from '../setting-tile';
import { SequenceCard } from '../sequence-card';
import { stopPropagation } from '../../utils/keyboard';
export function RoomVersionSelector({
versions,
value,
onChange,
disabled,
}: {
versions: string[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (version: string) => {
setMenuCords(undefined);
onChange(version);
};
return (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<SettingTile
title="Room Version"
after={
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box
direction="Column"
gap="200"
style={{ padding: config.space.S200, maxWidth: toRem(300) }}
>
<Text size="L400">Versions</Text>
<Box wrap="Wrap" gap="100">
{versions.map((version) => (
<Chip
key={version}
variant={value === version ? 'Primary' : 'SurfaceVariant'}
aria-pressed={value === version}
outlined={value === version}
radii="300"
onClick={() => handleSelect(version)}
type="button"
>
<Text truncate size="T300">
{version}
</Text>
</Chip>
))}
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Button
type="button"
onClick={handleMenu}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
aria-pressed={!!menuCords}
before={<Icon size="50" src={menuCords ? Icons.ChevronTop : Icons.ChevronBottom} />}
disabled={disabled}
>
<Text size="B300">{value}</Text>
</Button>
</PopOut>
}
/>
</SequenceCard>
);
}

View file

@ -0,0 +1,4 @@
export * from './CreateRoomKindSelector';
export * from './CreateRoomAliasInput';
export * from './RoomVersionSelector';
export * from './utils';

View file

@ -0,0 +1,131 @@
import {
ICreateRoomOpts,
ICreateRoomStateEvent,
JoinRule,
MatrixClient,
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { CreateRoomKind } from './CreateRoomKindSelector';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix';
export const createRoomCreationContent = (
type: RoomType | undefined,
allowFederation: boolean
): object => {
const content: Record<string, any> = {};
if (typeof type === 'string') {
content.type = type;
}
if (allowFederation === false) {
content['m.federate'] = false;
}
return content;
};
export const createRoomJoinRulesState = (
kind: CreateRoomKind,
parent: Room | undefined,
knock: boolean
) => {
let content: RoomJoinRulesEventContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
};
if (kind === CreateRoomKind.Public) {
content = {
join_rule: JoinRule.Public,
};
}
if (kind === CreateRoomKind.Restricted && parent) {
content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
allow: [
{
type: RestrictedAllowType.RoomMembership,
room_id: parent.roomId,
},
],
};
}
return {
type: StateEvent.RoomJoinRules,
state_key: '',
content,
};
};
export const createRoomParentState = (parent: Room) => ({
type: StateEvent.SpaceParent,
state_key: parent.roomId,
content: {
canonical: true,
via: getViaServers(parent),
},
});
export const createRoomEncryptionState = () => ({
type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
},
});
export type CreateRoomData = {
version: string;
type?: RoomType;
parent?: Room;
kind: CreateRoomKind;
name: string;
topic?: string;
aliasLocalPart?: string;
encryption?: boolean;
knock: boolean;
allowFederation: boolean;
};
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
if (data.encryption) {
initialState.push(createRoomEncryptionState());
}
if (data.parent) {
initialState.push(createRoomParentState(data.parent));
}
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
const options: ICreateRoomOpts = {
room_version: data.version,
name: data.name,
topic: data.topic,
room_alias_name: data.aliasLocalPart,
creation_content: createRoomCreationContent(data.type, data.allowFederation),
initial_state: initialState,
};
const result = await mx.createRoom(options);
if (data.parent) {
await mx.sendStateEvent(
data.parent.roomId,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
},
result.room_id
);
}
return result.room_id;
};

View file

@ -41,21 +41,21 @@ export const EditorTextarea = style([
},
]);
export const EditorPlaceholder = style([
export const EditorPlaceholderContainer = style([
DefaultReset,
{
position: 'absolute',
zIndex: 1,
width: '100%',
opacity: config.opacity.Placeholder,
pointerEvents: 'none',
userSelect: 'none',
},
]);
selectors: {
'&:not(:first-child)': {
display: 'none',
},
},
export const EditorPlaceholderTextVisual = style([
DefaultReset,
{
display: 'block',
paddingTop: toRem(13),
paddingLeft: toRem(1),
},
]);

View file

@ -106,22 +106,17 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
[editor, onKeyDown]
);
const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => {
// drop style attribute as we use our custom placeholder css.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { style, ...props } = attributes;
return (
<Text
as="span"
{...props}
className={css.EditorPlaceholder}
contentEditable={false}
truncate
>
{children}
</Text>
);
}, []);
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
</Text>
</span>
),
[]
);
return (
<div className={css.Editor} ref={ref}>

View file

@ -7,12 +7,31 @@ import * as css from './style.css';
export const SequenceCard = as<
'div',
ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
>(({ className, variant, firstChild, lastChild, outlined, ...props }, ref) => (
<Box
className={classNames(css.SequenceCard({ outlined }), ContainerColor({ variant }), className)}
data-first-child={firstChild}
data-last-child={lastChild}
{...props}
ref={ref}
/>
));
>(
(
{
as: AsSequenceCard = 'div',
className,
variant,
radii,
firstChild,
lastChild,
outlined,
...props
},
ref
) => (
<Box
as={AsSequenceCard}
className={classNames(
css.SequenceCard({ radii, outlined }),
ContainerColor({ variant }),
className
)}
data-first-child={firstChild}
data-last-child={lastChild}
{...props}
ref={ref}
/>
)
);

View file

@ -3,6 +3,7 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { config } from 'folds';
const outlinedWidth = createVar('0');
const radii = createVar(config.radii.R400);
export const SequenceCard = recipe({
base: {
vars: {
@ -13,33 +14,59 @@ export const SequenceCard = recipe({
borderBottomWidth: 0,
selectors: {
'&:first-child, :not(&) + &': {
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
borderTopLeftRadius: [radii],
borderTopRightRadius: [radii],
},
'&:last-child, &:not(:has(+&))': {
borderBottomLeftRadius: config.radii.R400,
borderBottomRightRadius: config.radii.R400,
borderBottomLeftRadius: [radii],
borderBottomRightRadius: [radii],
borderBottomWidth: outlinedWidth,
},
[`&[data-first-child="true"]`]: {
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
borderTopLeftRadius: [radii],
borderTopRightRadius: [radii],
},
[`&[data-first-child="false"]`]: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
[`&[data-last-child="true"]`]: {
borderBottomLeftRadius: config.radii.R400,
borderBottomRightRadius: config.radii.R400,
borderBottomLeftRadius: [radii],
borderBottomRightRadius: [radii],
},
[`&[data-last-child="false"]`]: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
'button&': {
cursor: 'pointer',
},
},
},
variants: {
radii: {
'0': {
vars: {
[radii]: config.radii.R0,
},
},
'300': {
vars: {
[radii]: config.radii.R300,
},
},
'400': {
vars: {
[radii]: config.radii.R400,
},
},
'500': {
vars: {
[radii]: config.radii.R500,
},
},
},
outlined: {
true: {
vars: {
@ -48,5 +75,8 @@ export const SequenceCard = recipe({
},
},
},
defaultVariants: {
radii: '400',
},
});
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;

View file

@ -4,7 +4,8 @@ import { UploadCard, UploadCardError, CompactUploadCardProgress } from './Upload
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
@ -19,10 +20,16 @@ export function CompactUploadCardRenderer({
onComplete,
}: CompactUploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
const allowSize = mediaConfig['m.upload.size'] || Infinity;
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
const fileSizeExceeded = file.size >= allowSize;
if (upload.status === UploadStatus.Idle) startUpload();
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
startUpload();
}
const removeUpload = () => {
cancelUpload();
@ -76,7 +83,7 @@ export function CompactUploadCardRenderer({
</>
) : (
<>
{upload.status === UploadStatus.Idle && (
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Loading && (
@ -87,6 +94,15 @@ export function CompactUploadCardRenderer({
<Text size="T200">{upload.error.message}</Text>
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
<UploadCardError>
<Text size="T200">
The file size exceeds the limit. Maximum allowed size is{' '}
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
<b>{bytesToSize(file.size)}</b>.
</Text>
</UploadCardError>
)}
</>
)}
</UploadCard>

View file

@ -4,13 +4,14 @@ import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { getFileTypeIcon } from '../../utils/common';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import {
roomUploadAtomFamily,
TUploadItem,
TUploadMetadata,
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
@ -75,12 +76,18 @@ export function UploadCardRenderer({
onComplete,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
const allowSize = mediaConfig['m.upload.size'] || Infinity;
const uploadAtom = roomUploadAtomFamily(fileItem.file);
const { metadata } = fileItem;
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
const fileSizeExceeded = file.size >= allowSize;
if (upload.status === UploadStatus.Idle) startUpload();
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
startUpload();
}
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
@ -131,7 +138,7 @@ export function UploadCardRenderer({
{fileItem.originalFile.type.startsWith('image') && (
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
)}
{upload.status === UploadStatus.Idle && (
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Loading && (
@ -142,6 +149,15 @@ export function UploadCardRenderer({
<Text size="T200">{upload.error.message}</Text>
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
<UploadCardError>
<Text size="T200">
The file size exceeds the limit. Maximum allowed size is{' '}
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
<b>{bytesToSize(file.size)}</b>.
</Text>
</UploadCardError>
)}
</>
}
>