mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-05 06:50:28 +03:00
Merge branch 'dev' into support-room-v12
This commit is contained in:
commit
e977abca07
91 changed files with 4561 additions and 253 deletions
6
.github/workflows/prod-deploy.yml
vendored
6
.github/workflows/prod-deploy.yml
vendored
|
|
@ -72,19 +72,19 @@ jobs:
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.10.0
|
uses: docker/setup-buildx-action@v3.10.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3.4.0
|
uses: docker/login-action@v3.5.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to the Container registry
|
- name: Login to the Container registry
|
||||||
uses: docker/login-action@v3.4.0
|
uses: docker/login-action@v3.5.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5.7.0
|
uses: docker/metadata-action@v5.8.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,25 @@ import PropTypes from 'prop-types';
|
||||||
import dateFormat from 'dateformat';
|
import dateFormat from 'dateformat';
|
||||||
import { isInSameDay } from '../../../util/common';
|
import { isInSameDay } from '../../../util/common';
|
||||||
|
|
||||||
function Time({ timestamp, fullTime }) {
|
/**
|
||||||
|
* Renders a formatted timestamp.
|
||||||
|
*
|
||||||
|
* Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true.
|
||||||
|
* For older messages, it shows the date and time.
|
||||||
|
*
|
||||||
|
* @param {number} timestamp - The timestamp to display.
|
||||||
|
* @param {boolean} [fullTime=false] - If true, always show the full date and time.
|
||||||
|
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||||
|
* @param {string} dateFormatString - Format string for the date part.
|
||||||
|
* @returns {JSX.Element} A <time> element with the formatted date/time.
|
||||||
|
*/
|
||||||
|
function Time({ timestamp, fullTime, hour24Clock, dateFormatString }) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
|
const formattedFullTime = dateFormat(
|
||||||
|
date,
|
||||||
|
hour24Clock ? 'dd mmmm yyyy, HH:MM' : 'dd mmmm yyyy, hh:MM TT'
|
||||||
|
);
|
||||||
let formattedDate = formattedFullTime;
|
let formattedDate = formattedFullTime;
|
||||||
|
|
||||||
if (!fullTime) {
|
if (!fullTime) {
|
||||||
|
|
@ -16,17 +31,19 @@ function Time({ timestamp, fullTime }) {
|
||||||
compareDate.setDate(compareDate.getDate() - 1);
|
compareDate.setDate(compareDate.getDate() - 1);
|
||||||
const isYesterday = isInSameDay(date, compareDate);
|
const isYesterday = isInSameDay(date, compareDate);
|
||||||
|
|
||||||
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
|
const timeFormat = hour24Clock ? 'HH:MM' : 'hh:MM TT';
|
||||||
|
|
||||||
|
formattedDate = dateFormat(
|
||||||
|
date,
|
||||||
|
isToday || isYesterday ? timeFormat : dateFormatString.toLowerCase()
|
||||||
|
);
|
||||||
if (isYesterday) {
|
if (isYesterday) {
|
||||||
formattedDate = `Yesterday, ${formattedDate}`;
|
formattedDate = `Yesterday, ${formattedDate}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time
|
<time dateTime={date.toISOString()} title={formattedFullTime}>
|
||||||
dateTime={date.toISOString()}
|
|
||||||
title={formattedFullTime}
|
|
||||||
>
|
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</time>
|
</time>
|
||||||
);
|
);
|
||||||
|
|
@ -39,6 +56,8 @@ Time.defaultProps = {
|
||||||
Time.propTypes = {
|
Time.propTypes = {
|
||||||
timestamp: PropTypes.number.isRequired,
|
timestamp: PropTypes.number.isRequired,
|
||||||
fullTime: PropTypes.bool,
|
fullTime: PropTypes.bool,
|
||||||
|
hour24Clock: PropTypes.bool.isRequired,
|
||||||
|
dateFormatString: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Time;
|
export default Time;
|
||||||
|
|
|
||||||
55
src/app/components/UserRoomProfileRenderer.tsx
Normal file
55
src/app/components/UserRoomProfileRenderer.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Menu, PopOut, toRem } from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
||||||
|
import { UserRoomProfile } from './user-profile';
|
||||||
|
import { UserRoomProfileState } from '../state/userRoomProfile';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { SpaceProvider } from '../hooks/useSpace';
|
||||||
|
import { RoomProvider } from '../hooks/useRoom';
|
||||||
|
|
||||||
|
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
|
||||||
|
const { roomId, spaceId, userId, cords, position } = state;
|
||||||
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||||
|
|
||||||
|
const close = useCloseUserRoomProfile();
|
||||||
|
|
||||||
|
if (!room) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position={position ?? 'Top'}
|
||||||
|
align="Start"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ width: toRem(340) }}>
|
||||||
|
<SpaceProvider value={space ?? null}>
|
||||||
|
<RoomProvider value={room}>
|
||||||
|
<UserRoomProfile userId={userId} />
|
||||||
|
</RoomProvider>
|
||||||
|
</SpaceProvider>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRoomProfileRenderer() {
|
||||||
|
const state = useUserRoomProfileState();
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
return <UserRoomProfileContextMenu state={state} />;
|
||||||
|
}
|
||||||
118
src/app/components/create-room/CreateRoomAliasInput.tsx
Normal file
118
src/app/components/create-room/CreateRoomAliasInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/components/create-room/CreateRoomKindSelector.tsx
Normal file
94
src/app/components/create-room/CreateRoomKindSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/components/create-room/RoomVersionSelector.tsx
Normal file
117
src/app/components/create-room/RoomVersionSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/app/components/create-room/index.ts
Normal file
4
src/app/components/create-room/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './CreateRoomKindSelector';
|
||||||
|
export * from './CreateRoomAliasInput';
|
||||||
|
export * from './RoomVersionSelector';
|
||||||
|
export * from './utils';
|
||||||
131
src/app/components/create-room/utils.ts
Normal file
131
src/app/components/create-room/utils.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -41,21 +41,21 @@ export const EditorTextarea = style([
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const EditorPlaceholder = style([
|
export const EditorPlaceholderContainer = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
position: 'absolute',
|
|
||||||
zIndex: 1,
|
|
||||||
width: '100%',
|
|
||||||
opacity: config.opacity.Placeholder,
|
opacity: config.opacity.Placeholder,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
selectors: {
|
export const EditorPlaceholderTextVisual = style([
|
||||||
'&:not(:first-child)': {
|
DefaultReset,
|
||||||
display: 'none',
|
{
|
||||||
},
|
display: 'block',
|
||||||
},
|
paddingTop: toRem(13),
|
||||||
|
paddingLeft: toRem(1),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,22 +106,17 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
[editor, onKeyDown]
|
[editor, onKeyDown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => {
|
const renderPlaceholder = useCallback(
|
||||||
// drop style attribute as we use our custom placeholder css.
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
const { style, ...props } = attributes;
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
return (
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
<Text
|
{children}
|
||||||
as="span"
|
</Text>
|
||||||
{...props}
|
</span>
|
||||||
className={css.EditorPlaceholder}
|
),
|
||||||
contentEditable={false}
|
[]
|
||||||
truncate
|
);
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.Editor} ref={ref}>
|
<div className={css.Editor} ref={ref}>
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
|
||||||
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
||||||
<Scroll
|
<Scroll
|
||||||
direction="Horizontal"
|
direction="Horizontal"
|
||||||
variant="Secondary"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@ export function Toolbar() {
|
||||||
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
align="End"
|
align="End"
|
||||||
tooltip={<BtnTooltip text="Toggle Markdown" />}
|
tooltip={<BtnTooltip text={isMarkdown ? 'Disable Markdown' : 'Enable Markdown'} />}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import * as css from './EventReaders.css';
|
import * as css from './EventReaders.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
|
||||||
export type EventReadersProps = {
|
export type EventReadersProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -33,6 +34,8 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const latestEventReaders = useRoomEventReaders(room, eventId);
|
const latestEventReaders = useRoomEventReaders(room, eventId);
|
||||||
|
const openProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const getName = (userId: string) =>
|
const getName = (userId: string) =>
|
||||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
|
@ -57,19 +60,32 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||||
<Box className={css.Content} direction="Column">
|
<Box className={css.Content} direction="Column">
|
||||||
{latestEventReaders.map((readerId) => {
|
{latestEventReaders.map((readerId) => {
|
||||||
const name = getName(readerId);
|
const name = getName(readerId);
|
||||||
const avatarMxcUrl = room
|
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||||
.getMember(readerId)
|
const avatarUrl = avatarMxcUrl
|
||||||
?.getMxcAvatarUrl();
|
? mx.mxcUrlToHttp(
|
||||||
const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) : undefined;
|
avatarMxcUrl,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
'crop',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
useAuthentication
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={readerId}
|
key={readerId}
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
style={{ padding: `0 ${config.space.S200}` }}
|
||||||
radii="400"
|
radii="400"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
requestClose();
|
openProfile(
|
||||||
openProfileViewer(readerId, room.roomId);
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
readerId,
|
||||||
|
event.currentTarget.getBoundingClientRect(),
|
||||||
|
'Bottom'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export const ReplyBend = style({
|
||||||
|
|
||||||
export const ThreadIndicator = style({
|
export const ThreadIndicator = style({
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
gap: toRem(2),
|
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
|
|
@ -19,11 +18,6 @@ export const ThreadIndicator = style({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ThreadIndicatorIcon = style({
|
|
||||||
width: toRem(14),
|
|
||||||
height: toRem(14),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Reply = style({
|
export const Reply = style({
|
||||||
marginBottom: toRem(1),
|
marginBottom: toRem(1),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||||
<Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}>
|
<Box
|
||||||
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
|
shrink="No"
|
||||||
<Text size="T200">Threaded reply</Text>
|
className={css.ThreadIndicator}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Thread} />
|
||||||
|
<Text size="L400">Thread</Text>
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -97,7 +104,7 @@ export const Reply = as<'div', ReplyProps>(
|
||||||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" alignItems="Start" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,35 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
|
||||||
export type TimeProps = {
|
export type TimeProps = {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a formatted timestamp, supporting compact and full display modes.
|
||||||
|
*
|
||||||
|
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
|
||||||
|
* For older messages, it shows the date and time.
|
||||||
|
*
|
||||||
|
* @param {number} ts - The timestamp to display.
|
||||||
|
* @param {boolean} [compact=false] - If true, always show only the time.
|
||||||
|
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||||
|
* @param {string} dateFormatString - Format string for the date part.
|
||||||
|
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
|
||||||
|
*/
|
||||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
||||||
({ compact, ts, ...props }, ref) => {
|
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
|
||||||
|
const formattedTime = timeHourMinute(ts, hour24Clock);
|
||||||
|
|
||||||
let time = '';
|
let time = '';
|
||||||
if (compact) {
|
if (compact) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (today(ts)) {
|
} else if (today(ts)) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (yesterday(ts)) {
|
} else if (yesterday(ts)) {
|
||||||
time = `Yesterday ${timeHourMinute(ts)}`;
|
time = `Yesterday ${formattedTime}`;
|
||||||
} else {
|
} else {
|
||||||
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
|
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export const AvatarBase = style({
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: `translateY(${toRem(-4)})`,
|
transform: `translateY(${toRem(-2)})`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
80
src/app/components/presence/Presence.tsx
Normal file
80
src/app/components/presence/Presence.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import {
|
||||||
|
as,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
color,
|
||||||
|
ContainerColor,
|
||||||
|
MainColor,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { ReactNode, useId } from 'react';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
|
const PresenceToColor: Record<Presence, MainColor> = {
|
||||||
|
[Presence.Online]: 'Success',
|
||||||
|
[Presence.Unavailable]: 'Warning',
|
||||||
|
[Presence.Offline]: 'Secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
type PresenceBadgeProps = {
|
||||||
|
presence: Presence;
|
||||||
|
status?: string;
|
||||||
|
size?: '200' | '300' | '400' | '500';
|
||||||
|
};
|
||||||
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
|
const label = usePresenceLabel();
|
||||||
|
const badgeLabelId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider
|
||||||
|
position="Right"
|
||||||
|
align="Center"
|
||||||
|
offset={4}
|
||||||
|
delay={200}
|
||||||
|
tooltip={
|
||||||
|
<Tooltip id={badgeLabelId}>
|
||||||
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
|
<Text size="L400">{label[presence]}</Text>
|
||||||
|
{status && <Text size="T200">•</Text>}
|
||||||
|
{status && <Text size="T200">{status}</Text>}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<Badge
|
||||||
|
aria-labelledby={badgeLabelId}
|
||||||
|
ref={triggerRef}
|
||||||
|
size={size}
|
||||||
|
variant={PresenceToColor[presence]}
|
||||||
|
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||||
|
radii="Pill"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarPresenceProps = {
|
||||||
|
badge: ReactNode;
|
||||||
|
variant?: ContainerColor;
|
||||||
|
};
|
||||||
|
export const AvatarPresence = as<'div', AvatarPresenceProps>(
|
||||||
|
({ as: AsAvatarPresence, badge, variant = 'Surface', children, ...props }, ref) => (
|
||||||
|
<Box as={AsAvatarPresence} className={css.AvatarPresence} {...props} ref={ref}>
|
||||||
|
{badge && (
|
||||||
|
<div
|
||||||
|
className={css.AvatarPresenceBadge}
|
||||||
|
style={{ backgroundColor: color[variant].Container }}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
);
|
||||||
1
src/app/components/presence/index.ts
Normal file
1
src/app/components/presence/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './Presence';
|
||||||
22
src/app/components/presence/styles.css.ts
Normal file
22
src/app/components/presence/styles.css.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config } from 'folds';
|
||||||
|
|
||||||
|
export const AvatarPresence = style({
|
||||||
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AvatarPresenceBadge = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: 'translate(25%, 25%)',
|
||||||
|
zIndex: 1,
|
||||||
|
|
||||||
|
display: 'flex',
|
||||||
|
padding: config.borderWidth.B600,
|
||||||
|
backgroundColor: 'inherit',
|
||||||
|
borderRadius: config.radii.Pill,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,8 @@ import { nameInitials } from '../../utils/common';
|
||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
export type RoomIntroProps = {
|
export type RoomIntroProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -43,6 +45,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -67,7 +71,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
{'Created by '}
|
{'Created by '}
|
||||||
<b>@{creatorName}</b>
|
<b>@{creatorName}</b>
|
||||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
|
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,31 @@ import * as css from './style.css';
|
||||||
export const SequenceCard = as<
|
export const SequenceCard = as<
|
||||||
'div',
|
'div',
|
||||||
ComponentProps<typeof Box> & ContainerColorVariants & css.SequenceCardVariants
|
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}
|
as: AsSequenceCard = 'div',
|
||||||
data-last-child={lastChild}
|
className,
|
||||||
{...props}
|
variant,
|
||||||
ref={ref}
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||||
import { config } from 'folds';
|
import { config } from 'folds';
|
||||||
|
|
||||||
const outlinedWidth = createVar('0');
|
const outlinedWidth = createVar('0');
|
||||||
|
const radii = createVar(config.radii.R400);
|
||||||
export const SequenceCard = recipe({
|
export const SequenceCard = recipe({
|
||||||
base: {
|
base: {
|
||||||
vars: {
|
vars: {
|
||||||
|
|
@ -13,33 +14,59 @@ export const SequenceCard = recipe({
|
||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:first-child, :not(&) + &': {
|
'&:first-child, :not(&) + &': {
|
||||||
borderTopLeftRadius: config.radii.R400,
|
borderTopLeftRadius: [radii],
|
||||||
borderTopRightRadius: config.radii.R400,
|
borderTopRightRadius: [radii],
|
||||||
},
|
},
|
||||||
'&:last-child, &:not(:has(+&))': {
|
'&:last-child, &:not(:has(+&))': {
|
||||||
borderBottomLeftRadius: config.radii.R400,
|
borderBottomLeftRadius: [radii],
|
||||||
borderBottomRightRadius: config.radii.R400,
|
borderBottomRightRadius: [radii],
|
||||||
borderBottomWidth: outlinedWidth,
|
borderBottomWidth: outlinedWidth,
|
||||||
},
|
},
|
||||||
[`&[data-first-child="true"]`]: {
|
[`&[data-first-child="true"]`]: {
|
||||||
borderTopLeftRadius: config.radii.R400,
|
borderTopLeftRadius: [radii],
|
||||||
borderTopRightRadius: config.radii.R400,
|
borderTopRightRadius: [radii],
|
||||||
},
|
},
|
||||||
[`&[data-first-child="false"]`]: {
|
[`&[data-first-child="false"]`]: {
|
||||||
borderTopLeftRadius: 0,
|
borderTopLeftRadius: 0,
|
||||||
borderTopRightRadius: 0,
|
borderTopRightRadius: 0,
|
||||||
},
|
},
|
||||||
[`&[data-last-child="true"]`]: {
|
[`&[data-last-child="true"]`]: {
|
||||||
borderBottomLeftRadius: config.radii.R400,
|
borderBottomLeftRadius: [radii],
|
||||||
borderBottomRightRadius: config.radii.R400,
|
borderBottomRightRadius: [radii],
|
||||||
},
|
},
|
||||||
[`&[data-last-child="false"]`]: {
|
[`&[data-last-child="false"]`]: {
|
||||||
borderBottomLeftRadius: 0,
|
borderBottomLeftRadius: 0,
|
||||||
borderBottomRightRadius: 0,
|
borderBottomRightRadius: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'button&': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
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: {
|
outlined: {
|
||||||
true: {
|
true: {
|
||||||
vars: {
|
vars: {
|
||||||
|
|
@ -48,5 +75,8 @@ export const SequenceCard = recipe({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
radii: '400',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
|
export type SequenceCardVariants = RecipeVariants<typeof SequenceCard>;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import dayjs from 'dayjs';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { PickerColumn } from './PickerColumn';
|
import { PickerColumn } from './PickerColumn';
|
||||||
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
|
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
type TimePickerProps = {
|
type TimePickerProps = {
|
||||||
min: number;
|
min: number;
|
||||||
|
|
@ -13,9 +15,11 @@ type TimePickerProps = {
|
||||||
};
|
};
|
||||||
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||||
({ min, max, value, onChange }, ref) => {
|
({ min, max, value, onChange }, ref) => {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
const hour24 = dayjs(value).hour();
|
const hour24 = dayjs(value).hour();
|
||||||
|
|
||||||
const selectedHour = hour24to12(hour24);
|
const selectedHour = hour24Clock ? hour24 : hour24to12(hour24);
|
||||||
const selectedMinute = dayjs(value).minute();
|
const selectedMinute = dayjs(value).minute();
|
||||||
const selectedPM = hour24 >= 12;
|
const selectedPM = hour24 >= 12;
|
||||||
|
|
||||||
|
|
@ -24,7 +28,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHour = (hour: number) => {
|
const handleHour = (hour: number) => {
|
||||||
const seconds = hoursToMs(hour12to24(hour, selectedPM));
|
const seconds = hoursToMs(hour24Clock ? hour : hour12to24(hour, selectedPM));
|
||||||
const lastSeconds = hoursToMs(hour24);
|
const lastSeconds = hoursToMs(hour24);
|
||||||
const newValue = value + (seconds - lastSeconds);
|
const newValue = value + (seconds - lastSeconds);
|
||||||
handleSubmit(newValue);
|
handleSubmit(newValue);
|
||||||
|
|
@ -59,28 +63,43 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||||
<Menu className={css.PickerMenu} ref={ref}>
|
<Menu className={css.PickerMenu} ref={ref}>
|
||||||
<Box direction="Row" gap="200" className={css.PickerContainer}>
|
<Box direction="Row" gap="200" className={css.PickerContainer}>
|
||||||
<PickerColumn title="Hour">
|
<PickerColumn title="Hour">
|
||||||
{Array.from(Array(12).keys())
|
{hour24Clock
|
||||||
.map((i) => {
|
? Array.from(Array(24).keys()).map((hour) => (
|
||||||
if (i === 0) return 12;
|
<Chip
|
||||||
return i;
|
key={hour}
|
||||||
})
|
size="500"
|
||||||
.map((hour) => (
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
<Chip
|
fill="None"
|
||||||
key={hour}
|
radii="300"
|
||||||
size="500"
|
aria-selected={hour === selectedHour}
|
||||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
onClick={() => handleHour(hour)}
|
||||||
fill="None"
|
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||||
radii="300"
|
>
|
||||||
aria-selected={hour === selectedHour}
|
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
||||||
onClick={() => handleHour(hour)}
|
</Chip>
|
||||||
disabled={
|
))
|
||||||
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
: Array.from(Array(12).keys())
|
||||||
(maxDay && hour12to24(hour, selectedPM) > maxHour24)
|
.map((i) => {
|
||||||
}
|
if (i === 0) return 12;
|
||||||
>
|
return i;
|
||||||
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
})
|
||||||
</Chip>
|
.map((hour) => (
|
||||||
))}
|
<Chip
|
||||||
|
key={hour}
|
||||||
|
size="500"
|
||||||
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={hour === selectedHour}
|
||||||
|
onClick={() => handleHour(hour)}
|
||||||
|
disabled={
|
||||||
|
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||||
|
(maxDay && hour12to24(hour, selectedPM) > maxHour24)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
</PickerColumn>
|
</PickerColumn>
|
||||||
<PickerColumn title="Minutes">
|
<PickerColumn title="Minutes">
|
||||||
{Array.from(Array(60).keys()).map((minute) => (
|
{Array.from(Array(60).keys()).map((minute) => (
|
||||||
|
|
@ -101,30 +120,32 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</PickerColumn>
|
</PickerColumn>
|
||||||
<PickerColumn title="Period">
|
{!hour24Clock && (
|
||||||
<Chip
|
<PickerColumn title="Period">
|
||||||
size="500"
|
<Chip
|
||||||
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
size="500"
|
||||||
fill="None"
|
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
radii="300"
|
fill="None"
|
||||||
aria-selected={!selectedPM}
|
radii="300"
|
||||||
onClick={() => handlePeriod(false)}
|
aria-selected={!selectedPM}
|
||||||
disabled={minDay && minPM}
|
onClick={() => handlePeriod(false)}
|
||||||
>
|
disabled={minDay && minPM}
|
||||||
<Text size="T300">AM</Text>
|
>
|
||||||
</Chip>
|
<Text size="T300">AM</Text>
|
||||||
<Chip
|
</Chip>
|
||||||
size="500"
|
<Chip
|
||||||
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
size="500"
|
||||||
fill="None"
|
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
radii="300"
|
fill="None"
|
||||||
aria-selected={selectedPM}
|
radii="300"
|
||||||
onClick={() => handlePeriod(true)}
|
aria-selected={selectedPM}
|
||||||
disabled={maxDay && !maxPM}
|
onClick={() => handlePeriod(true)}
|
||||||
>
|
disabled={maxDay && !maxPM}
|
||||||
<Text size="T300">PM</Text>
|
>
|
||||||
</Chip>
|
<Text size="T300">PM</Text>
|
||||||
</PickerColumn>
|
</Chip>
|
||||||
|
</PickerColumn>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { UploadCard, UploadCardError, CompactUploadCardProgress } from './Upload
|
||||||
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
import { TUploadAtom, UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
import { getFileTypeIcon } from '../../utils/common';
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||||
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
|
||||||
type CompactUploadCardRendererProps = {
|
type CompactUploadCardRendererProps = {
|
||||||
isEncrypted?: boolean;
|
isEncrypted?: boolean;
|
||||||
|
|
@ -19,10 +20,16 @@ export function CompactUploadCardRenderer({
|
||||||
onComplete,
|
onComplete,
|
||||||
}: CompactUploadCardRendererProps) {
|
}: CompactUploadCardRendererProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const mediaConfig = useMediaConfig();
|
||||||
|
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||||
|
|
||||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||||
const { file } = upload;
|
const { file } = upload;
|
||||||
|
const fileSizeExceeded = file.size >= allowSize;
|
||||||
|
|
||||||
if (upload.status === UploadStatus.Idle) startUpload();
|
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
||||||
|
startUpload();
|
||||||
|
}
|
||||||
|
|
||||||
const removeUpload = () => {
|
const removeUpload = () => {
|
||||||
cancelUpload();
|
cancelUpload();
|
||||||
|
|
@ -76,7 +83,7 @@ export function CompactUploadCardRenderer({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{upload.status === UploadStatus.Idle && (
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<CompactUploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
)}
|
)}
|
||||||
{upload.status === UploadStatus.Loading && (
|
{upload.status === UploadStatus.Loading && (
|
||||||
|
|
@ -87,6 +94,15 @@ export function CompactUploadCardRenderer({
|
||||||
<Text size="T200">{upload.error.message}</Text>
|
<Text size="T200">{upload.error.message}</Text>
|
||||||
</UploadCardError>
|
</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>
|
</UploadCard>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
import { getFileTypeIcon } from '../../utils/common';
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||||
import {
|
import {
|
||||||
roomUploadAtomFamily,
|
roomUploadAtomFamily,
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
TUploadMetadata,
|
TUploadMetadata,
|
||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
|
||||||
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
|
type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
|
||||||
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
|
||||||
|
|
@ -75,12 +76,18 @@ export function UploadCardRenderer({
|
||||||
onComplete,
|
onComplete,
|
||||||
}: UploadCardRendererProps) {
|
}: UploadCardRendererProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const mediaConfig = useMediaConfig();
|
||||||
|
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||||
|
|
||||||
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
||||||
const { metadata } = fileItem;
|
const { metadata } = fileItem;
|
||||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||||
const { file } = upload;
|
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) => {
|
const handleSpoiler = (marked: boolean) => {
|
||||||
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
||||||
|
|
@ -131,7 +138,7 @@ export function UploadCardRenderer({
|
||||||
{fileItem.originalFile.type.startsWith('image') && (
|
{fileItem.originalFile.type.startsWith('image') && (
|
||||||
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
|
<ImagePreview fileItem={fileItem} onSpoiler={handleSpoiler} />
|
||||||
)}
|
)}
|
||||||
{upload.status === UploadStatus.Idle && (
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
)}
|
)}
|
||||||
{upload.status === UploadStatus.Loading && (
|
{upload.status === UploadStatus.Loading && (
|
||||||
|
|
@ -142,6 +149,15 @@ export function UploadCardRenderer({
|
||||||
<Text size="T200">{upload.error.message}</Text>
|
<Text size="T200">{upload.error.message}</Text>
|
||||||
</UploadCardError>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
344
src/app/components/user-profile/PowerChip.tsx
Normal file
344
src/app/components/user-profile/PowerChip.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Line,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { PowerColorBadge, PowerIcon } from '../power';
|
||||||
|
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
||||||
|
import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||||
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
|
||||||
|
type SelfDemoteAlertProps = {
|
||||||
|
power: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onChange: (power: number) => void;
|
||||||
|
};
|
||||||
|
function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Self Demotion</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
You are about to demote yourself! You will not be able to regain this power
|
||||||
|
yourself. Are you sure?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button type="submit" variant="Warning" onClick={() => onChange(power)}>
|
||||||
|
<Text size="B400">Demote</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedPowerAlertProps = {
|
||||||
|
power: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onChange: (power: number) => void;
|
||||||
|
};
|
||||||
|
function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) {
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Shared Power</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
You are promoting the user to have the same power as yourself! You will not be
|
||||||
|
able to change their power afterward. Are you sure?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button type="submit" variant="Warning" onClick={() => onChange(power)}>
|
||||||
|
<Text size="B400">Promote</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PowerChip({ userId }: { userId: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const openRoomSettings = useOpenRoomSettings();
|
||||||
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
|
||||||
|
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
|
||||||
|
const myPower = getPowerLevel(mx.getSafeUserId());
|
||||||
|
const userPower = getPowerLevel(userId);
|
||||||
|
const canChangePowers =
|
||||||
|
canSendStateEvent(StateEvent.RoomPowerLevels, myPower) &&
|
||||||
|
(mx.getSafeUserId() === userId ? true : myPower > userPower);
|
||||||
|
|
||||||
|
const tag = getPowerLevelTag(userPower);
|
||||||
|
const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const [powerState, changePower] = useAsyncCallback<undefined, Error, [number]>(
|
||||||
|
useCallback(
|
||||||
|
async (power: number) => {
|
||||||
|
await mx.setPowerLevel(room.roomId, userId, power);
|
||||||
|
},
|
||||||
|
[mx, userId, room]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const changing = powerState.status === AsyncStatus.Loading;
|
||||||
|
const error = powerState.status === AsyncStatus.Error;
|
||||||
|
const [selfDemote, setSelfDemote] = useState<number>();
|
||||||
|
const [sharedPower, setSharedPower] = useState<number>();
|
||||||
|
|
||||||
|
const handlePowerSelect = (power: number): void => {
|
||||||
|
close();
|
||||||
|
if (!canChangePowers) return;
|
||||||
|
if (power === userPower) return;
|
||||||
|
|
||||||
|
if (userId === mx.getSafeUserId()) {
|
||||||
|
setSelfDemote(power);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (power === myPower) {
|
||||||
|
setSharedPower(power);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePower(power);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelfDemote = (power: number) => {
|
||||||
|
setSelfDemote(undefined);
|
||||||
|
changePower(power);
|
||||||
|
};
|
||||||
|
const handleSharedPower = (power: number) => {
|
||||||
|
setSharedPower(undefined);
|
||||||
|
changePower(power);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ padding: config.space.S100, maxWidth: toRem(200) }}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<Text size="L400">Error: {powerState.error.name}</Text>
|
||||||
|
<Text className={BreakWord} size="T200">
|
||||||
|
{powerState.error.message}
|
||||||
|
</Text>
|
||||||
|
</CutoutCard>
|
||||||
|
)}
|
||||||
|
{getPowers(powerLevelTags).map((power) => {
|
||||||
|
const powerTag = powerLevelTags[power];
|
||||||
|
const powerTagIconSrc =
|
||||||
|
powerTag.icon && getTagIconSrc(mx, useAuthentication, powerTag.icon);
|
||||||
|
|
||||||
|
const canAssignPower = power <= myPower;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={power}
|
||||||
|
variant={userPower === power ? 'Primary' : 'Surface'}
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-disabled={changing || !canChangePowers || !canAssignPower}
|
||||||
|
aria-pressed={userPower === power}
|
||||||
|
before={<PowerColorBadge color={powerTag.color} />}
|
||||||
|
after={
|
||||||
|
powerTagIconSrc ? (
|
||||||
|
<PowerIcon size="50" iconSrc={powerTagIconSrc} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={
|
||||||
|
canChangePowers && canAssignPower
|
||||||
|
? () => handlePowerSelect(power)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">{powerTag.name}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Line size="300" />
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
openSpaceSettings(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
SpaceSettingsPage.PermissionsPage
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
openRoomSettings(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
RoomSettingsPage.PermissionsPage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Manage Powers</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={error ? 'Critical' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!changing && <PowerColorBadge color={tag.color} />}
|
||||||
|
{changing && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
{typeof selfDemote === 'number' ? (
|
||||||
|
<SelfDemoteAlert
|
||||||
|
power={selfDemote}
|
||||||
|
onCancel={() => setSelfDemote(undefined)}
|
||||||
|
onChange={handleSelfDemote}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{typeof sharedPower === 'number' ? (
|
||||||
|
<SharedPowerAlert
|
||||||
|
power={sharedPower}
|
||||||
|
onCancel={() => setSharedPower(undefined)}
|
||||||
|
onChange={handleSharedPower}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
514
src/app/components/user-profile/UserChips.tsx
Normal file
514
src/app/components/user-profile/UserChips.tsx
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
PopOut,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
config,
|
||||||
|
Text,
|
||||||
|
Line,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
RectCords,
|
||||||
|
Spinner,
|
||||||
|
toRem,
|
||||||
|
Box,
|
||||||
|
Scroll,
|
||||||
|
Avatar,
|
||||||
|
} from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { getMxIdServer } from '../../utils/matrix';
|
||||||
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
|
import { getExploreServerPath } from '../../pages/pathUtils';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||||
|
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
import { RoomAvatar, RoomIcon } from '../room-avatar';
|
||||||
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||||
|
import { nameInitials } from '../../utils/common';
|
||||||
|
import { getMatrixToUser } from '../../plugins/matrix-to';
|
||||||
|
import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
|
||||||
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
|
||||||
|
export function ServerChip({ server }: { server: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const myServer = getMxIdServer(mx.getSafeUserId());
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const closeProfile = useCloseUserRoomProfile();
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(server);
|
||||||
|
setCopied();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy Server</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
navigate(getExploreServerPath(server));
|
||||||
|
closeProfile();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Explore Community</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
<Line size="300" />
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant={myServer === server ? 'Surface' : 'Critical'}
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`https://${server}`, '_blank');
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Open in Browser</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={myServer === server ? 'SurfaceVariant' : 'Warning'}
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={copied ? Icons.Check : Icons.Server} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{server}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareChip({ userId }: { userId: string }) {
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(userId);
|
||||||
|
setCopied();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy User ID</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(getMatrixToUser(userId));
|
||||||
|
setCopied();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Copy User Link</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant={copied ? 'Success' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? (
|
||||||
|
<Icon size="50" src={Icons.ChevronBottom} />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={copied ? Icons.Check : Icons.Link} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Share
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutualRoomsData = {
|
||||||
|
rooms: Room[];
|
||||||
|
spaces: Room[];
|
||||||
|
directs: Room[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const mutualRoomSupported = useMutualRoomsSupport();
|
||||||
|
const mutualRoomsState = useMutualRooms(userId);
|
||||||
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
|
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||||
|
const directs = useDirectRooms();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const mutual: MutualRoomsData = useMemo(() => {
|
||||||
|
const data: MutualRoomsData = {
|
||||||
|
rooms: [],
|
||||||
|
spaces: [],
|
||||||
|
directs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mutualRoomsState.status === AsyncStatus.Success) {
|
||||||
|
const mutualRooms = mutualRoomsState.data
|
||||||
|
.sort(factoryRoomIdByAtoZ(mx))
|
||||||
|
.map(getRoom)
|
||||||
|
.filter((room) => !!room);
|
||||||
|
mutualRooms.forEach((room) => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
data.spaces.push(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (directs.includes(room.roomId)) {
|
||||||
|
data.directs.push(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.rooms.push(room);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, [mutualRoomsState, getRoom, directs, mx]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
userId === mx.getSafeUserId() ||
|
||||||
|
!mutualRoomSupported ||
|
||||||
|
mutualRoomsState.status === AsyncStatus.Error
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = (room: Room) => {
|
||||||
|
const { roomId } = room;
|
||||||
|
const dm = directs.includes(roomId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={roomId}
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
style={{ paddingLeft: config.space.S100 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (room.isSpaceRoom()) {
|
||||||
|
navigateSpace(roomId);
|
||||||
|
} else {
|
||||||
|
navigateRoom(roomId);
|
||||||
|
}
|
||||||
|
closeUserRoomProfile();
|
||||||
|
}}
|
||||||
|
before={
|
||||||
|
<Avatar size="200" radii={dm ? '400' : '300'}>
|
||||||
|
{dm || room.isSpaceRoom() ? (
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={room.roomId}
|
||||||
|
src={
|
||||||
|
dm
|
||||||
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
}
|
||||||
|
alt={room.name}
|
||||||
|
renderFallback={() => (
|
||||||
|
<Text as="span" size="H6">
|
||||||
|
{nameInitials(room.name)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RoomIcon size="100" joinRule={room.getJoinRule()} />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{room.name}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
mutualRoomsState.status === AsyncStatus.Success ? (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
maxWidth: toRem(200),
|
||||||
|
maxHeight: '80vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll size="300" hideTrack>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
style={{ padding: config.space.S200, paddingRight: 0 }}
|
||||||
|
>
|
||||||
|
{mutual.spaces.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
|
||||||
|
Spaces
|
||||||
|
</Text>
|
||||||
|
{mutual.spaces.map(renderItem)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{mutual.rooms.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
|
||||||
|
Rooms
|
||||||
|
</Text>
|
||||||
|
{mutual.rooms.map(renderItem)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{mutual.directs.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
|
||||||
|
Direct Messages
|
||||||
|
</Text>
|
||||||
|
{mutual.directs.map(renderItem)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />}
|
||||||
|
disabled={
|
||||||
|
mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0
|
||||||
|
}
|
||||||
|
onClick={open}
|
||||||
|
aria-pressed={!!cords}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{mutualRoomsState.status === AsyncStatus.Success &&
|
||||||
|
`${mutualRoomsState.data.length} Mutual Rooms`}
|
||||||
|
{mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IgnoredUserAlert() {
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Blocked User</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
<Text size="T200">You do not receive any messages or invites from this user.</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionsChip({ userId }: { userId: string }) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const ignoredUsers = useIgnoredUsers();
|
||||||
|
const ignored = ignoredUsers.includes(userId);
|
||||||
|
|
||||||
|
const [ignoreState, toggleIgnore] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const users = ignoredUsers.filter((u) => u !== userId);
|
||||||
|
if (!ignored) users.push(userId);
|
||||||
|
await mx.setIgnoredUsers(users);
|
||||||
|
}, [mx, ignoredUsers, userId, ignored])
|
||||||
|
);
|
||||||
|
const ignoring = ignoreState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Bottom"
|
||||||
|
align="Start"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<div style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
toggleIgnore();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
before={
|
||||||
|
ignoring ? (
|
||||||
|
<Spinner variant="Critical" size="50" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Prohibited} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={ignoring}
|
||||||
|
>
|
||||||
|
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
|
||||||
|
{ignoring ? (
|
||||||
|
<Spinner variant="Secondary" size="50" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.HorizontalDots} />
|
||||||
|
)}
|
||||||
|
</Chip>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/app/components/user-profile/UserHero.tsx
Normal file
75
src/app/components/user-profile/UserHero.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { UserAvatar } from '../user-avatar';
|
||||||
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||||
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
|
|
||||||
|
type UserHeroProps = {
|
||||||
|
userId: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
presence?: UserPresence;
|
||||||
|
};
|
||||||
|
export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" className={css.UserHero}>
|
||||||
|
<div
|
||||||
|
className={css.UserHeroCoverContainer}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorMXID(userId),
|
||||||
|
filter: avatarUrl ? undefined : 'brightness(50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatarUrl && <img className={css.UserHeroCover} src={avatarUrl} alt={userId} />}
|
||||||
|
</div>
|
||||||
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
|
<AvatarPresence
|
||||||
|
className={css.UserAvatarContainer}
|
||||||
|
badge={
|
||||||
|
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar className={css.UserHeroAvatar} size="500">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={userId}
|
||||||
|
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</AvatarPresence>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserHeroNameProps = {
|
||||||
|
displayName?: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
|
||||||
|
const username = getMxIdLocalPart(userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box grow="Yes" direction="Column" gap="0">
|
||||||
|
<Box alignItems="Baseline" gap="200" wrap="Wrap">
|
||||||
|
<Text
|
||||||
|
size="H4"
|
||||||
|
className={classNames(BreakWord, LineClamp3)}
|
||||||
|
title={displayName ?? username}
|
||||||
|
>
|
||||||
|
{displayName ?? username ?? userId}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||||
|
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
|
||||||
|
@{username}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
src/app/components/user-profile/UserModeration.tsx
Normal file
349
src/app/components/user-profile/UserModeration.tsx
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
|
||||||
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import { SettingTile } from '../setting-tile';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
||||||
|
|
||||||
|
type UserKickAlertProps = {
|
||||||
|
reason?: string;
|
||||||
|
kickedBy?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||||
|
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Kicked User</Text>
|
||||||
|
{time && date && (
|
||||||
|
<Text size="T200">
|
||||||
|
{date} {time}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
{kickedBy && (
|
||||||
|
<Text size="T200">
|
||||||
|
Kicked by: <b>{kickedBy}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200">
|
||||||
|
{reason ? (
|
||||||
|
<>
|
||||||
|
Reason: <b>{reason}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Reason Provided.</i>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserBanAlertProps = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
canUnban?: boolean;
|
||||||
|
bannedBy?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||||
|
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||||
|
|
||||||
|
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.unban(room.roomId, userId);
|
||||||
|
}, [mx, room, userId])
|
||||||
|
);
|
||||||
|
const banning = unbanState.status === AsyncStatus.Loading;
|
||||||
|
const error = unbanState.status === AsyncStatus.Error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Banned User</Text>
|
||||||
|
{time && date && (
|
||||||
|
<Text size="T200">
|
||||||
|
{date} {time}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
{bannedBy && (
|
||||||
|
<Text size="T200">
|
||||||
|
Banned by: <b>{bannedBy}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200">
|
||||||
|
{reason ? (
|
||||||
|
<>
|
||||||
|
Reason: <b>{reason}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Reason Provided.</i>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{unbanState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{canUnban && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
radii="300"
|
||||||
|
onClick={unban}
|
||||||
|
before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
|
||||||
|
disabled={banning}
|
||||||
|
>
|
||||||
|
<Text size="B300">Unban</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInviteAlertProps = {
|
||||||
|
userId: string;
|
||||||
|
reason?: string;
|
||||||
|
canKick?: boolean;
|
||||||
|
invitedBy?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||||
|
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||||
|
|
||||||
|
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.kick(room.roomId, userId);
|
||||||
|
}, [mx, room, userId])
|
||||||
|
);
|
||||||
|
const kicking = kickState.status === AsyncStatus.Loading;
|
||||||
|
const error = kickState.status === AsyncStatus.Error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CutoutCard style={{ padding: config.space.S200 }} variant="Success">
|
||||||
|
<SettingTile>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
|
<Text size="L400">Invited User</Text>
|
||||||
|
{time && date && (
|
||||||
|
<Text size="T200">
|
||||||
|
{date} {time}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column">
|
||||||
|
{invitedBy && (
|
||||||
|
<Text size="T200">
|
||||||
|
Invited by: <b>{invitedBy}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200">
|
||||||
|
{reason ? (
|
||||||
|
<>
|
||||||
|
Reason: <b>{reason}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Reason Provided.</i>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
<b>{kickState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{canKick && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
onClick={kick}
|
||||||
|
before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
|
||||||
|
disabled={kicking}
|
||||||
|
>
|
||||||
|
<Text size="B300">Cancel Invite</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
</CutoutCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModerationProps = {
|
||||||
|
userId: string;
|
||||||
|
canKick: boolean;
|
||||||
|
canBan: boolean;
|
||||||
|
canInvite: boolean;
|
||||||
|
};
|
||||||
|
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const reasonInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const getReason = useCallback((): string | undefined => {
|
||||||
|
const reason = reasonInputRef.current?.value.trim() || undefined;
|
||||||
|
if (reasonInputRef.current) {
|
||||||
|
reasonInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.kick(room.roomId, userId, getReason());
|
||||||
|
}, [mx, room, userId, getReason])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [banState, ban] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.ban(room.roomId, userId, getReason());
|
||||||
|
}, [mx, room, userId, getReason])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [inviteState, invite] = useAsyncCallback<undefined, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await mx.invite(room.roomId, userId, getReason());
|
||||||
|
}, [mx, room, userId, getReason])
|
||||||
|
);
|
||||||
|
|
||||||
|
const disabled =
|
||||||
|
kickState.status === AsyncStatus.Loading ||
|
||||||
|
banState.status === AsyncStatus.Loading ||
|
||||||
|
inviteState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
if (!canBan && !canKick && !canInvite) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Moderation</Text>
|
||||||
|
<Input
|
||||||
|
ref={reasonInputRef}
|
||||||
|
placeholder="Reason"
|
||||||
|
size="300"
|
||||||
|
variant="Background"
|
||||||
|
radii="300"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{kickState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
||||||
|
<b>{kickState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{banState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
||||||
|
<b>{banState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{inviteState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
|
||||||
|
<b>{inviteState.error.message}</b>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
{canInvite && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
inviteState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Secondary" fill="Soft" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.ArrowRight} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={invite}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Invite</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canKick && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
kickState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Critical" fill="Soft" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={kick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Kick</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canBan && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
banState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner size="50" variant="Critical" fill="Solid" />
|
||||||
|
) : (
|
||||||
|
<Icon size="50" src={Icons.Prohibited} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={ban}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text size="B300">Ban</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/app/components/user-profile/UserRoomProfile.tsx
Normal file
159
src/app/components/user-profile/UserRoomProfile.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { Box, Button, color, config, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { UserHero, UserHeroName } from './UserHero';
|
||||||
|
import { getDMRoomFor, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { usePowerLevels, usePowerLevelsAPI } 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, ignore } 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 { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
|
import { useMembership } from '../../hooks/useMembership';
|
||||||
|
import { Membership } from '../../../types/matrix/room';
|
||||||
|
|
||||||
|
type UserRoomProfileProps = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const alive = useAlive();
|
||||||
|
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||||
|
const ignoredUsers = useIgnoredUsers();
|
||||||
|
const ignored = ignoredUsers.includes(userId);
|
||||||
|
|
||||||
|
const room = useRoom();
|
||||||
|
const powerlevels = usePowerLevels(room);
|
||||||
|
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerlevels);
|
||||||
|
const myPowerLevel = getPowerLevel(mx.getSafeUserId());
|
||||||
|
const userPowerLevel = getPowerLevel(userId);
|
||||||
|
const canKick = canDoAction('kick', myPowerLevel) && myPowerLevel > userPowerLevel;
|
||||||
|
const canBan = canDoAction('ban', myPowerLevel) && myPowerLevel > userPowerLevel;
|
||||||
|
const canInvite = canDoAction('invite', myPowerLevel);
|
||||||
|
|
||||||
|
const member = room.getMember(userId);
|
||||||
|
const membership = useMembership(room, userId);
|
||||||
|
|
||||||
|
const server = getMxIdServer(userId);
|
||||||
|
const displayName = getMemberDisplayName(room, userId);
|
||||||
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
|
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column">
|
||||||
|
<UserHero
|
||||||
|
userId={userId}
|
||||||
|
avatarUrl={avatarUrl}
|
||||||
|
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
|
||||||
|
<Box direction="Column" gap="400">
|
||||||
|
<Box gap="400" alignItems="Start">
|
||||||
|
<UserHeroName displayName={displayName} userId={userId} />
|
||||||
|
<Box shrink="No">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
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 />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleMessage}
|
||||||
|
>
|
||||||
|
<Text size="B300">Message</Text>
|
||||||
|
</Button>
|
||||||
|
</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} />
|
||||||
|
<PowerChip userId={userId} />
|
||||||
|
<MutualRoomsChip userId={userId} />
|
||||||
|
<OptionsChip userId={userId} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{ignored && <IgnoredUserAlert />}
|
||||||
|
{member && membership === Membership.Ban && (
|
||||||
|
<UserBanAlert
|
||||||
|
userId={userId}
|
||||||
|
reason={member.events.member?.getContent().reason}
|
||||||
|
canUnban={canBan}
|
||||||
|
bannedBy={member.events.member?.getSender()}
|
||||||
|
ts={member.events.member?.getTs()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{member &&
|
||||||
|
membership === Membership.Leave &&
|
||||||
|
member.events.member &&
|
||||||
|
member.events.member.getSender() !== userId && (
|
||||||
|
<UserKickAlert
|
||||||
|
reason={member.events.member?.getContent().reason}
|
||||||
|
kickedBy={member.events.member?.getSender()}
|
||||||
|
ts={member.events.member?.getTs()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{member && membership === Membership.Invite && (
|
||||||
|
<UserInviteAlert
|
||||||
|
userId={userId}
|
||||||
|
reason={member.events.member?.getContent().reason}
|
||||||
|
canKick={canKick}
|
||||||
|
invitedBy={member.events.member?.getSender()}
|
||||||
|
ts={member.events.member?.getTs()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<UserModeration
|
||||||
|
userId={userId}
|
||||||
|
canInvite={canInvite && membership === Membership.Leave}
|
||||||
|
canKick={canKick && membership === Membership.Join}
|
||||||
|
canBan={canBan && membership !== Membership.Ban}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/user-profile/index.ts
Normal file
1
src/app/components/user-profile/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './UserRoomProfile';
|
||||||
42
src/app/components/user-profile/styles.css.ts
Normal file
42
src/app/components/user-profile/styles.css.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const UserHeader = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHero = style({
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroCoverContainer = style({
|
||||||
|
height: toRem(96),
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
export const UserHeroCover = style({
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
filter: 'blur(16px)',
|
||||||
|
transform: 'scale(2)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserHeroAvatarContainer = style({
|
||||||
|
position: 'relative',
|
||||||
|
height: toRem(29),
|
||||||
|
});
|
||||||
|
export const UserAvatarContainer = style({
|
||||||
|
position: 'absolute',
|
||||||
|
left: config.space.S400,
|
||||||
|
top: 0,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
});
|
||||||
|
export const UserHeroAvatar = style({
|
||||||
|
outline: `${config.borderWidth.B600} solid ${color.Surface.Container}`,
|
||||||
|
});
|
||||||
|
|
@ -27,6 +27,11 @@ import {
|
||||||
} from '../../../state/hooks/roomList';
|
} from '../../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
|
import {
|
||||||
|
knockRestrictedSupported,
|
||||||
|
knockSupported,
|
||||||
|
restrictedSupported,
|
||||||
|
} from '../../../utils/matrix';
|
||||||
|
|
||||||
type RestrictedRoomAllowContent = {
|
type RestrictedRoomAllowContent = {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
|
|
@ -39,10 +44,9 @@ type RoomJoinRulesProps = {
|
||||||
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const roomVersion = parseInt(room.getVersion(), 10);
|
const allowKnockRestricted = knockRestrictedSupported(room.getVersion());
|
||||||
const allowKnockRestricted = roomVersion >= 10;
|
const allowRestricted = restrictedSupported(room.getVersion());
|
||||||
const allowRestricted = roomVersion >= 8;
|
const allowKnock = knockSupported(room.getVersion());
|
||||||
const allowKnock = roomVersion >= 7;
|
|
||||||
|
|
||||||
const roomIdToParents = useAtomValue(roomToParentsAtom);
|
const roomIdToParents = useAtomValue(roomToParentsAtom);
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, color, Spinner, Switch, Text } from 'folds';
|
import { Box, color, Spinner, Switch, Text } from 'folds';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { JoinRule, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
|
@ -10,6 +11,8 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||||
import { StateEvent } from '../../../../types/matrix/room';
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { ExtendedJoinRules } from '../../../components/JoinRulesSwitcher';
|
||||||
|
|
||||||
type RoomPublishProps = {
|
type RoomPublishProps = {
|
||||||
powerLevels: IPowerLevels;
|
powerLevels: IPowerLevels;
|
||||||
|
|
@ -23,6 +26,9 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
|
||||||
StateEvent.RoomCanonicalAlias,
|
StateEvent.RoomCanonicalAlias,
|
||||||
userPowerLevel
|
userPowerLevel
|
||||||
);
|
);
|
||||||
|
const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
|
||||||
|
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
|
||||||
|
const rule: ExtendedJoinRules = (content?.join_rule as ExtendedJoinRules) ?? JoinRule.Invite;
|
||||||
|
|
||||||
const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
|
const { visibilityState, setVisibility } = useRoomDirectoryVisibility(room.roomId);
|
||||||
|
|
||||||
|
|
@ -30,6 +36,8 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
|
||||||
|
|
||||||
const loading =
|
const loading =
|
||||||
visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
|
visibilityState.status === AsyncStatus.Loading || toggleState.status === AsyncStatus.Loading;
|
||||||
|
const validRule =
|
||||||
|
rule === JoinRule.Public || rule === JoinRule.Knock || rule === 'knock_restricted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
|
|
@ -39,7 +47,12 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
|
||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Publish To Directory"
|
title="Publish to Directory"
|
||||||
|
description={
|
||||||
|
room.isSpaceRoom()
|
||||||
|
? 'List the space in the public directory to make it discoverable by others.'
|
||||||
|
: 'List the room in the public directory to make it discoverable by others.'
|
||||||
|
}
|
||||||
after={
|
after={
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
{loading && <Spinner variant="Secondary" />}
|
{loading && <Spinner variant="Secondary" />}
|
||||||
|
|
@ -47,7 +60,7 @@ export function RoomPublish({ powerLevels }: RoomPublishProps) {
|
||||||
<Switch
|
<Switch
|
||||||
value={visibilityState.data}
|
value={visibilityState.data}
|
||||||
onChange={toggleVisibility}
|
onChange={toggleVisibility}
|
||||||
disabled={!canEditCanonical}
|
disabled={!canEditCanonical || !validRule}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ import { MemberTile } from '../../../components/member-tile';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
|
||||||
import { ServerBadge } from '../../../components/server-badge';
|
import { ServerBadge } from '../../../components/server-badge';
|
||||||
import { openProfileViewer } from '../../../../client/action/navigation';
|
|
||||||
import { useDebounce } from '../../../hooks/useDebounce';
|
import { useDebounce } from '../../../hooks/useDebounce';
|
||||||
import {
|
import {
|
||||||
SearchItemStrGetter,
|
SearchItemStrGetter,
|
||||||
|
|
@ -53,6 +52,11 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||||
import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
|
import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
|
||||||
import { MemberSortMenu } from '../../../components/MemberSortMenu';
|
import { MemberSortMenu } from '../../../components/MemberSortMenu';
|
||||||
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
||||||
|
import {
|
||||||
|
useOpenUserRoomProfile,
|
||||||
|
useUserRoomProfileState,
|
||||||
|
} from '../../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
|
|
@ -77,6 +81,9 @@ export function Members({ requestClose }: MembersProps) {
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
||||||
|
const openProfile = useOpenUserRoomProfile();
|
||||||
|
const profileUser = useUserRoomProfileState();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
|
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
|
||||||
|
|
@ -142,8 +149,9 @@ export function Members({ requestClose }: MembersProps) {
|
||||||
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
const btn = evt.currentTarget as HTMLButtonElement;
|
const btn = evt.currentTarget as HTMLButtonElement;
|
||||||
const userId = btn.getAttribute('data-user-id');
|
const userId = btn.getAttribute('data-user-id');
|
||||||
openProfileViewer(userId, room.roomId);
|
if (userId) {
|
||||||
requestClose();
|
openProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -317,6 +325,7 @@ export function Members({ requestClose }: MembersProps) {
|
||||||
<MemberTile
|
<MemberTile
|
||||||
data-user-id={tagOrMember.userId}
|
data-user-id={tagOrMember.userId}
|
||||||
onClick={handleMemberClick}
|
onClick={handleMemberClick}
|
||||||
|
aria-pressed={profileUser?.userId === tagOrMember.userId}
|
||||||
mx={mx}
|
mx={mx}
|
||||||
room={room}
|
room={room}
|
||||||
member={tagOrMember}
|
member={tagOrMember}
|
||||||
|
|
|
||||||
277
src/app/features/create-room/CreateRoom.tsx
Normal file
277
src/app/features/create-room/CreateRoom.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||||
|
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextArea,
|
||||||
|
} from 'folds';
|
||||||
|
import { SettingTile } from '../../components/setting-tile';
|
||||||
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
|
import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useCapabilities } from '../../hooks/useCapabilities';
|
||||||
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
|
import {
|
||||||
|
createRoom,
|
||||||
|
CreateRoomAliasInput,
|
||||||
|
CreateRoomData,
|
||||||
|
CreateRoomKind,
|
||||||
|
CreateRoomKindSelector,
|
||||||
|
RoomVersionSelector,
|
||||||
|
} from '../../components/create-room';
|
||||||
|
|
||||||
|
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
|
||||||
|
if (kind === CreateRoomKind.Private) return Icons.HashLock;
|
||||||
|
if (kind === CreateRoomKind.Restricted) return Icons.Hash;
|
||||||
|
return Icons.HashGlobe;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateRoomFormProps = {
|
||||||
|
defaultKind?: CreateRoomKind;
|
||||||
|
space?: Room;
|
||||||
|
onCreate?: (roomId: string) => void;
|
||||||
|
};
|
||||||
|
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const capabilities = useCapabilities();
|
||||||
|
const roomVersions = capabilities['m.room_versions'];
|
||||||
|
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
|
||||||
|
|
||||||
|
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
|
const [kind, setKind] = useState(
|
||||||
|
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
|
||||||
|
);
|
||||||
|
const [federation, setFederation] = useState(true);
|
||||||
|
const [encryption, setEncryption] = useState(false);
|
||||||
|
const [knock, setKnock] = useState(false);
|
||||||
|
const [advance, setAdvance] = useState(false);
|
||||||
|
|
||||||
|
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
||||||
|
const allowKnockRestricted =
|
||||||
|
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
|
const handleRoomVersionChange = (version: string) => {
|
||||||
|
if (!restrictedSupported(version)) {
|
||||||
|
setKind(CreateRoomKind.Private);
|
||||||
|
}
|
||||||
|
selectRoomVersion(version);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
|
||||||
|
useCallback((data) => createRoom(mx, data), [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();
|
||||||
|
if (disabled) return;
|
||||||
|
const form = evt.currentTarget;
|
||||||
|
|
||||||
|
const nameInput = form.nameInput as HTMLInputElement | undefined;
|
||||||
|
const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
|
||||||
|
const aliasInput = form.aliasInput as HTMLInputElement | undefined;
|
||||||
|
const roomName = nameInput?.value.trim();
|
||||||
|
const roomTopic = topicTextArea?.value.trim();
|
||||||
|
const aliasLocalPart =
|
||||||
|
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
||||||
|
|
||||||
|
if (!roomName) return;
|
||||||
|
const publicRoom = kind === CreateRoomKind.Public;
|
||||||
|
let roomKnock = false;
|
||||||
|
if (allowKnock && kind === CreateRoomKind.Private) {
|
||||||
|
roomKnock = knock;
|
||||||
|
}
|
||||||
|
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
|
||||||
|
roomKnock = knock;
|
||||||
|
}
|
||||||
|
|
||||||
|
create({
|
||||||
|
version: selectedRoomVersion,
|
||||||
|
parent: space,
|
||||||
|
kind,
|
||||||
|
name: roomName,
|
||||||
|
topic: roomTopic || undefined,
|
||||||
|
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
||||||
|
encryption: publicRoom ? false : encryption,
|
||||||
|
knock: roomKnock,
|
||||||
|
allowFederation: federation,
|
||||||
|
}).then((roomId) => {
|
||||||
|
if (alive()) {
|
||||||
|
onCreate?.(roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Access</Text>
|
||||||
|
<CreateRoomKindSelector
|
||||||
|
value={kind}
|
||||||
|
onSelect={setKind}
|
||||||
|
canRestrict={allowRestricted}
|
||||||
|
disabled={disabled}
|
||||||
|
getIcon={getCreateRoomKindToIcon}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Name</Text>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
before={<Icon size="100" src={getCreateRoomKindToIcon(kind)} />}
|
||||||
|
name="nameInput"
|
||||||
|
autoFocus
|
||||||
|
size="500"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="400"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Topic (Optional)</Text>
|
||||||
|
<TextArea
|
||||||
|
name="topicTextAria"
|
||||||
|
size="500"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="400"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
|
||||||
|
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Box gap="200" alignItems="End">
|
||||||
|
<Text size="L400">Options</Text>
|
||||||
|
<Box grow="Yes" justifyContent="End">
|
||||||
|
<Chip
|
||||||
|
radii="Pill"
|
||||||
|
before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
||||||
|
onClick={() => setAdvance(!advance)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Text size="T200">Advance Options</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{kind !== CreateRoomKind.Public && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
{advance && (allowKnock || allowKnockRestricted) && (
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Knock to Join"
|
||||||
|
description="Anyone can send request to join this room."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={knock}
|
||||||
|
onChange={setKnock}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Federation"
|
||||||
|
description="Users from other servers can join."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={federation}
|
||||||
|
onChange={setFederation}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
{advance && (
|
||||||
|
<RoomVersionSelector
|
||||||
|
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
|
||||||
|
value={selectedRoomVersion}
|
||||||
|
onChange={handleRoomVersionChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/features/create-room/CreateRoomModal.tsx
Normal file
95
src/app/features/create-room/CreateRoomModal.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
import { SpaceProvider } from '../../hooks/useSpace';
|
||||||
|
import { CreateRoomForm } from './CreateRoom';
|
||||||
|
import {
|
||||||
|
useCloseCreateRoomModal,
|
||||||
|
useCreateRoomModalState,
|
||||||
|
} from '../../state/hooks/createRoomModal';
|
||||||
|
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type CreateRoomModalProps = {
|
||||||
|
state: CreateRoomModalState;
|
||||||
|
};
|
||||||
|
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||||
|
const { spaceId } = state;
|
||||||
|
const closeDialog = useCloseCreateRoomModal();
|
||||||
|
|
||||||
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpaceProvider value={space ?? null}>
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
onDeactivate: closeDialog,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal size="300" flexHeight>
|
||||||
|
<Box direction="Column">
|
||||||
|
<Header
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingLeft: config.space.S400,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">New Room</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Scroll size="300" hideTrack>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingRight: config.space.S200,
|
||||||
|
}}
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<CreateRoomForm space={space} onCreate={closeDialog} />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</SpaceProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateRoomModalRenderer() {
|
||||||
|
const state = useCreateRoomModalState();
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
return <CreateRoomModal state={state} />;
|
||||||
|
}
|
||||||
2
src/app/features/create-room/index.ts
Normal file
2
src/app/features/create-room/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './CreateRoom';
|
||||||
|
export * from './CreateRoomModal';
|
||||||
249
src/app/features/create-space/CreateSpace.tsx
Normal file
249
src/app/features/create-space/CreateSpace.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||||
|
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextArea,
|
||||||
|
} from 'folds';
|
||||||
|
import { SettingTile } from '../../components/setting-tile';
|
||||||
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
|
import { knockRestrictedSupported, knockSupported, restrictedSupported } from '../../utils/matrix';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { millisecondsToMinutes, replaceSpaceWithDash } from '../../utils/common';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useCapabilities } from '../../hooks/useCapabilities';
|
||||||
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
|
import {
|
||||||
|
createRoom,
|
||||||
|
CreateRoomAliasInput,
|
||||||
|
CreateRoomData,
|
||||||
|
CreateRoomKind,
|
||||||
|
CreateRoomKindSelector,
|
||||||
|
RoomVersionSelector,
|
||||||
|
} from '../../components/create-room';
|
||||||
|
import { RoomType } from '../../../types/matrix/room';
|
||||||
|
|
||||||
|
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
|
||||||
|
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
|
||||||
|
if (kind === CreateRoomKind.Restricted) return Icons.Space;
|
||||||
|
return Icons.SpaceGlobe;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateSpaceFormProps = {
|
||||||
|
defaultKind?: CreateRoomKind;
|
||||||
|
space?: Room;
|
||||||
|
onCreate?: (roomId: string) => void;
|
||||||
|
};
|
||||||
|
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
|
const capabilities = useCapabilities();
|
||||||
|
const roomVersions = capabilities['m.room_versions'];
|
||||||
|
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
|
||||||
|
|
||||||
|
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
|
const [kind, setKind] = useState(
|
||||||
|
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
|
||||||
|
);
|
||||||
|
const [federation, setFederation] = useState(true);
|
||||||
|
const [knock, setKnock] = useState(false);
|
||||||
|
const [advance, setAdvance] = useState(false);
|
||||||
|
|
||||||
|
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
|
||||||
|
const allowKnockRestricted =
|
||||||
|
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
|
||||||
|
|
||||||
|
const handleRoomVersionChange = (version: string) => {
|
||||||
|
if (!restrictedSupported(version)) {
|
||||||
|
setKind(CreateRoomKind.Private);
|
||||||
|
}
|
||||||
|
selectRoomVersion(version);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [createState, create] = useAsyncCallback<string, Error | MatrixError, [CreateRoomData]>(
|
||||||
|
useCallback((data) => createRoom(mx, data), [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();
|
||||||
|
if (disabled) return;
|
||||||
|
const form = evt.currentTarget;
|
||||||
|
|
||||||
|
const nameInput = form.nameInput as HTMLInputElement | undefined;
|
||||||
|
const topicTextArea = form.topicTextAria as HTMLTextAreaElement | undefined;
|
||||||
|
const aliasInput = form.aliasInput as HTMLInputElement | undefined;
|
||||||
|
const roomName = nameInput?.value.trim();
|
||||||
|
const roomTopic = topicTextArea?.value.trim();
|
||||||
|
const aliasLocalPart =
|
||||||
|
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
|
||||||
|
|
||||||
|
if (!roomName) return;
|
||||||
|
const publicRoom = kind === CreateRoomKind.Public;
|
||||||
|
let roomKnock = false;
|
||||||
|
if (allowKnock && kind === CreateRoomKind.Private) {
|
||||||
|
roomKnock = knock;
|
||||||
|
}
|
||||||
|
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
|
||||||
|
roomKnock = knock;
|
||||||
|
}
|
||||||
|
|
||||||
|
create({
|
||||||
|
version: selectedRoomVersion,
|
||||||
|
type: RoomType.Space,
|
||||||
|
parent: space,
|
||||||
|
kind,
|
||||||
|
name: roomName,
|
||||||
|
topic: roomTopic || undefined,
|
||||||
|
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
|
||||||
|
knock: roomKnock,
|
||||||
|
allowFederation: federation,
|
||||||
|
}).then((roomId) => {
|
||||||
|
if (alive()) {
|
||||||
|
onCreate?.(roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Access</Text>
|
||||||
|
<CreateRoomKindSelector
|
||||||
|
value={kind}
|
||||||
|
onSelect={setKind}
|
||||||
|
canRestrict={allowRestricted}
|
||||||
|
disabled={disabled}
|
||||||
|
getIcon={getCreateSpaceKindToIcon}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Name</Text>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />}
|
||||||
|
name="nameInput"
|
||||||
|
autoFocus
|
||||||
|
size="500"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="400"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Text size="L400">Topic (Optional)</Text>
|
||||||
|
<TextArea
|
||||||
|
name="topicTextAria"
|
||||||
|
size="500"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="400"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
|
||||||
|
|
||||||
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
|
<Box gap="200" alignItems="End">
|
||||||
|
<Text size="L400">Options</Text>
|
||||||
|
<Box grow="Yes" justifyContent="End">
|
||||||
|
<Chip
|
||||||
|
radii="Pill"
|
||||||
|
before={<Icon src={advance ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
||||||
|
onClick={() => setAdvance(!advance)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Text size="T200">Advance Options</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Knock to Join"
|
||||||
|
description="Anyone can send request to join this space."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={knock} onChange={setKnock} disabled={disabled} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Federation"
|
||||||
|
description="Users from other servers can join."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={federation}
|
||||||
|
onChange={setFederation}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
{advance && (
|
||||||
|
<RoomVersionSelector
|
||||||
|
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
|
||||||
|
value={selectedRoomVersion}
|
||||||
|
onChange={handleRoomVersionChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/features/create-space/CreateSpaceModal.tsx
Normal file
95
src/app/features/create-space/CreateSpaceModal.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
import { SpaceProvider } from '../../hooks/useSpace';
|
||||||
|
import { CreateSpaceForm } from './CreateSpace';
|
||||||
|
import {
|
||||||
|
useCloseCreateSpaceModal,
|
||||||
|
useCreateSpaceModalState,
|
||||||
|
} from '../../state/hooks/createSpaceModal';
|
||||||
|
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type CreateSpaceModalProps = {
|
||||||
|
state: CreateSpaceModalState;
|
||||||
|
};
|
||||||
|
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
||||||
|
const { spaceId } = state;
|
||||||
|
const closeDialog = useCloseCreateSpaceModal();
|
||||||
|
|
||||||
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpaceProvider value={space ?? null}>
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
onDeactivate: closeDialog,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal size="300" flexHeight>
|
||||||
|
<Box direction="Column">
|
||||||
|
<Header
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingLeft: config.space.S400,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">New Space</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Scroll size="300" hideTrack>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingRight: config.space.S200,
|
||||||
|
}}
|
||||||
|
direction="Column"
|
||||||
|
gap="500"
|
||||||
|
>
|
||||||
|
<CreateSpaceForm space={space} onCreate={closeDialog} />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</SpaceProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateSpaceModalRenderer() {
|
||||||
|
const state = useCreateSpaceModalState();
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
return <CreateSpaceModal state={state} />;
|
||||||
|
}
|
||||||
2
src/app/features/create-space/index.ts
Normal file
2
src/app/features/create-space/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './CreateSpace';
|
||||||
|
export * from './CreateSpaceModal';
|
||||||
|
|
@ -220,14 +220,12 @@ export function Lobby() {
|
||||||
() =>
|
() =>
|
||||||
hierarchy
|
hierarchy
|
||||||
.flatMap((i) => {
|
.flatMap((i) => {
|
||||||
const childRooms = Array.isArray(i.rooms)
|
const childRooms = Array.isArray(i.rooms) ? i.rooms.map((r) => getRoom(r.roomId)) : [];
|
||||||
? i.rooms.map((r) => mx.getRoom(r.roomId))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return [mx.getRoom(i.space.roomId), ...childRooms];
|
return [getRoom(i.space.roomId), ...childRooms];
|
||||||
})
|
})
|
||||||
.filter((r) => !!r) as Room[],
|
.filter((r) => !!r) as Room[],
|
||||||
[mx, hierarchy]
|
[hierarchy, getRoom]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import * as css from './SpaceItem.css';
|
import * as css from './SpaceItem.css';
|
||||||
import * as styleCss from './style.css';
|
import * as styleCss from './style.css';
|
||||||
import { useDraggableItem } from './DnD';
|
import { useDraggableItem } from './DnD';
|
||||||
import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
|
import { openSpaceAddExisting } from '../../../client/action/navigation';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
|
||||||
|
import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal';
|
||||||
|
|
||||||
function SpaceProfileLoading() {
|
function SpaceProfileLoading() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -240,13 +242,14 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
|
||||||
|
|
||||||
function AddRoomButton({ item }: { item: HierarchyItem }) {
|
function AddRoomButton({ item }: { item: HierarchyItem }) {
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const openCreateRoomModal = useOpenCreateRoomModal();
|
||||||
|
|
||||||
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleAddRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRoom = () => {
|
const handleCreateRoom = () => {
|
||||||
openCreateRoom(false, item.roomId as any);
|
openCreateRoomModal(item.roomId);
|
||||||
setCords(undefined);
|
setCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -303,13 +306,14 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
|
||||||
|
|
||||||
function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const openCreateSpaceModal = useOpenCreateSpaceModal();
|
||||||
|
|
||||||
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleAddSpace: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSpace = () => {
|
const handleCreateSpace = () => {
|
||||||
openCreateRoom(true, item.roomId as any);
|
openCreateSpaceModal(item.roomId as any);
|
||||||
setCords(undefined);
|
setCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -470,7 +474,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{canEditChild && (
|
{space && canEditChild && (
|
||||||
<Box shrink="No" alignItems="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" gap="200">
|
||||||
<AddRoomButton item={item} />
|
<AddRoomButton item={item} />
|
||||||
{item.parentId === undefined && <AddSpaceButton item={item} />}
|
{item.parentId === undefined && <AddSpaceButton item={item} />}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ export function MessageSearch({
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -289,6 +292,8 @@ export function MessageSearch({
|
||||||
urlPreview={urlPreview}
|
urlPreview={urlPreview}
|
||||||
onOpen={navigateRoom}
|
onOpen={navigateRoom}
|
||||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ type SearchResultGroupProps = {
|
||||||
urlPreview?: boolean;
|
urlPreview?: boolean;
|
||||||
onOpen: (roomId: string, eventId: string) => void;
|
onOpen: (roomId: string, eventId: string) => void;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
export function SearchResultGroup({
|
export function SearchResultGroup({
|
||||||
room,
|
room,
|
||||||
|
|
@ -66,6 +68,8 @@ export function SearchResultGroup({
|
||||||
urlPreview,
|
urlPreview,
|
||||||
onOpen,
|
onOpen,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
}: SearchResultGroupProps) {
|
}: SearchResultGroupProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
@ -275,7 +279,11 @@ export function SearchResultGroup({
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={event.origin_server_ts} />
|
<Time
|
||||||
|
ts={event.origin_server_ts}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="200" alignItems="Center">
|
<Box shrink="No" gap="200" alignItems="Center">
|
||||||
<Chip
|
<Chip
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import { Room, RoomMember } from 'matrix-js-sdk';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import * as css from './MembersDrawer.css';
|
import * as css from './MembersDrawer.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
|
|
@ -56,6 +55,8 @@ import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
|
||||||
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
|
||||||
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
import { MemberSortMenu } from '../../components/MemberSortMenu';
|
||||||
|
import { useOpenUserRoomProfile, useUserRoomProfileState } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
|
|
@ -82,6 +83,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||||
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
|
const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
|
||||||
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
const fetchingMembers = members.length < room.getJoinedMemberCount();
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const openProfileUserId = useUserRoomProfileState()?.userId;
|
||||||
|
|
||||||
const membershipFilterMenu = useMembershipFilterMenu();
|
const membershipFilterMenu = useMembershipFilterMenu();
|
||||||
const sortFilterMenu = useMemberSortMenu();
|
const sortFilterMenu = useMemberSortMenu();
|
||||||
|
|
@ -142,7 +146,8 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||||
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
const btn = evt.currentTarget as HTMLButtonElement;
|
const btn = evt.currentTarget as HTMLButtonElement;
|
||||||
const userId = btn.getAttribute('data-user-id');
|
const userId = btn.getAttribute('data-user-id');
|
||||||
openProfileViewer(userId, room.roomId);
|
if (!userId) return;
|
||||||
|
openUserRoomProfile(room.roomId, space?.roomId, userId, btn.getBoundingClientRect(), 'Left');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -350,6 +355,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||||
padding: `0 ${config.space.S200}`,
|
padding: `0 ${config.space.S200}`,
|
||||||
transform: `translateY(${vItem.start}px)`,
|
transform: `translateY(${vItem.start}px)`,
|
||||||
}}
|
}}
|
||||||
|
aria-pressed={openProfileUserId === member.userId}
|
||||||
data-index={vItem.index}
|
data-index={vItem.index}
|
||||||
data-user-id={member.userId}
|
data-user-id={member.userId}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Cross} size="50" />
|
<Icon src={Icons.Cross} size="50" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box direction="Column">
|
<Box direction="Row" gap="200" alignItems="Center">
|
||||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
userColor={replyUsernameColor}
|
userColor={replyUsernameColor}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,6 @@ import {
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
|
|
@ -120,6 +119,8 @@ import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
|
import { GetPowerLevelTag } from '../../hooks/usePowerLevelTags';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
|
||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||||
({ position, className, ...props }, ref) => (
|
({ position, className, ...props }, ref) => (
|
||||||
|
|
@ -450,6 +451,9 @@ export function RoomTimeline({
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const ignoredUsersList = useIgnoredUsers();
|
const ignoredUsersList = useIgnoredUsers();
|
||||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
||||||
|
|
@ -469,6 +473,8 @@ export function RoomTimeline({
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
const spoilerClickHandler = useSpoilerClickHandler();
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
|
|
@ -906,9 +912,14 @@ export function RoomTimeline({
|
||||||
console.warn('Button should have "data-user-id" attribute!');
|
console.warn('Button should have "data-user-id" attribute!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openProfileViewer(userId, room.roomId);
|
openUserRoomProfile(
|
||||||
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
userId,
|
||||||
|
evt.currentTarget.getBoundingClientRect()
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[room]
|
[room, space, openUserRoomProfile]
|
||||||
);
|
);
|
||||||
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
|
@ -933,7 +944,7 @@ export function RoomTimeline({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(evt) => {
|
(evt, startThread = false) => {
|
||||||
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
if (!replyId) {
|
if (!replyId) {
|
||||||
console.warn('Button should have "data-event-id" attribute!');
|
console.warn('Button should have "data-event-id" attribute!');
|
||||||
|
|
@ -944,7 +955,9 @@ export function RoomTimeline({
|
||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
const { body, formatted_body: formattedBody } = content;
|
||||||
const { 'm.relates_to': relation } = replyEvt.getWireContent();
|
const { 'm.relates_to': relation } = startThread
|
||||||
|
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
||||||
|
: replyEvt.getWireContent();
|
||||||
const senderId = replyEvt.getSender();
|
const senderId = replyEvt.getSender();
|
||||||
if (senderId && typeof body === 'string') {
|
if (senderId && typeof body === 'string') {
|
||||||
setReplyDraft({
|
setReplyDraft({
|
||||||
|
|
@ -1070,6 +1083,8 @@ export function RoomTimeline({
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1152,6 +1167,8 @@ export function RoomTimeline({
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
<EncryptedContent mEvent={mEvent}>
|
<EncryptedContent mEvent={mEvent}>
|
||||||
{() => {
|
{() => {
|
||||||
|
|
@ -1254,6 +1271,8 @@ export function RoomTimeline({
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1282,7 +1301,12 @@ export function RoomTimeline({
|
||||||
const parsed = parseMemberEvent(mEvent);
|
const parsed = parseMemberEvent(mEvent);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1319,7 +1343,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1357,7 +1386,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1395,7 +1429,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1435,7 +1474,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1480,7 +1524,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useSetSetting, useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
|
||||||
|
|
@ -260,7 +260,7 @@ export function RoomViewHeader() {
|
||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
const searchParams: _SearchPathSearchParams = {
|
const searchParams: _SearchPathSearchParams = {
|
||||||
|
|
@ -434,7 +434,7 @@ export function RoomViewHeader() {
|
||||||
offset={4}
|
offset={4}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text>Members</Text>
|
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import { useRoom } from '../../../hooks/useRoom';
|
||||||
import { StateEvent } from '../../../../types/matrix/room';
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
|
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
|
||||||
import { DatePicker, TimePicker } from '../../../components/time-date';
|
import { DatePicker, TimePicker } from '../../../components/time-date';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
type JumpToTimeProps = {
|
type JumpToTimeProps = {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
|
@ -45,6 +47,8 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||||
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
|
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
|
||||||
const [ts, setTs] = useState(() => Date.now());
|
const [ts, setTs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
|
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
|
||||||
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
|
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
|
@ -125,7 +129,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||||
onClick={handleTimePicker}
|
onClick={handleTimePicker}
|
||||||
>
|
>
|
||||||
<Text size="B300">{timeHourMinute(ts)}</Text>
|
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={timePickerCords}
|
anchor={timePickerCords}
|
||||||
|
|
|
||||||
|
|
@ -669,7 +669,10 @@ export type MessageProps = {
|
||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onReplyClick: MouseEventHandler<HTMLButtonElement>;
|
onReplyClick: (
|
||||||
|
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
|
||||||
|
startThread?: boolean
|
||||||
|
) => void;
|
||||||
onEditId?: (eventId?: string) => void;
|
onEditId?: (eventId?: string) => void;
|
||||||
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||||
reply?: ReactNode;
|
reply?: ReactNode;
|
||||||
|
|
@ -679,6 +682,8 @@ export type MessageProps = {
|
||||||
powerLevelTag?: PowerLevelTag;
|
powerLevelTag?: PowerLevelTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
export const Message = as<'div', MessageProps>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -708,6 +713,8 @@ export const Message = as<'div', MessageProps>(
|
||||||
powerLevelTag,
|
powerLevelTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -772,7 +779,12 @@ export const Message = as<'div', MessageProps>(
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -859,6 +871,8 @@ export const Message = as<'div', MessageProps>(
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isThreadedMessage = mEvent.threadRootId !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
|
|
@ -921,6 +935,17 @@ export const Message = as<'div', MessageProps>(
|
||||||
>
|
>
|
||||||
<Icon src={Icons.ReplyArrow} size="100" />
|
<Icon src={Icons.ReplyArrow} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{!isThreadedMessage && (
|
||||||
|
<IconButton
|
||||||
|
onClick={(ev) => onReplyClick(ev, true)}
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ThreadPlus} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{canEditEvent(mx, mEvent) && onEditId && (
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => onEditId(mEvent.getId())}
|
onClick={() => onEditId(mEvent.getId())}
|
||||||
|
|
@ -1000,6 +1025,27 @@ export const Message = as<'div', MessageProps>(
|
||||||
Reply
|
Reply
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{!isThreadedMessage && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
||||||
|
radii="300"
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
onClick={(evt: any) => {
|
||||||
|
onReplyClick(evt, true);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={css.MessageMenuItemText}
|
||||||
|
as="span"
|
||||||
|
size="T300"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
Reply in Thread
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{canEditEvent(mx, mEvent) && onEditId && (
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ import { getMemberDisplayName } from '../../../utils/room';
|
||||||
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
|
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
import * as css from './ReactionViewer.css';
|
import * as css from './ReactionViewer.css';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { openProfileViewer } from '../../../../client/action/navigation';
|
|
||||||
import { useRelations } from '../../../hooks/useRelations';
|
import { useRelations } from '../../../hooks/useRelations';
|
||||||
import { Reaction } from '../../../components/message';
|
import { Reaction } from '../../../components/message';
|
||||||
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
|
import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
|
||||||
import { UserAvatar } from '../../../components/user-avatar';
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
|
|
||||||
export type ReactionViewerProps = {
|
export type ReactionViewerProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -41,6 +42,8 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
|
||||||
relations,
|
relations,
|
||||||
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
|
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
|
||||||
);
|
);
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
const openProfile = useOpenUserRoomProfile();
|
||||||
|
|
||||||
const [selectedKey, setSelectedKey] = useState<string>(() => {
|
const [selectedKey, setSelectedKey] = useState<string>(() => {
|
||||||
if (initialKey) return initialKey;
|
if (initialKey) return initialKey;
|
||||||
|
|
@ -111,24 +114,31 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
|
||||||
const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
|
const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId;
|
||||||
|
|
||||||
const avatarMxcUrl = member?.getMxcAvatarUrl();
|
const avatarMxcUrl = member?.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(
|
const avatarUrl = avatarMxcUrl
|
||||||
avatarMxcUrl,
|
? mx.mxcUrlToHttp(
|
||||||
100,
|
avatarMxcUrl,
|
||||||
100,
|
100,
|
||||||
'crop',
|
100,
|
||||||
undefined,
|
'crop',
|
||||||
false,
|
undefined,
|
||||||
useAuthentication
|
false,
|
||||||
) : undefined;
|
useAuthentication
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={senderId}
|
key={senderId}
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
style={{ padding: `0 ${config.space.S200}` }}
|
||||||
radii="400"
|
radii="400"
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
requestClose();
|
openProfile(
|
||||||
openProfileViewer(senderId, room.roomId);
|
room.roomId,
|
||||||
|
space?.roomId,
|
||||||
|
senderId,
|
||||||
|
event.currentTarget.getBoundingClientRect(),
|
||||||
|
'Bottom'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
before={
|
before={
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,9 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const [unpinState, unpin] = useAsyncCallback(
|
const [unpinState, unpin] = useAsyncCallback(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
|
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
|
||||||
|
|
@ -205,7 +208,11 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={pinnedEvent.getTs()} />
|
<Time
|
||||||
|
ts={pinnedEvent.getTs()}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{renderOptions()}
|
{renderOptions()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
export function DeviceTilePlaceholder() {
|
export function DeviceTilePlaceholder() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,6 +43,9 @@ export function DeviceTilePlaceholder() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceActiveTime({ ts }: { ts: number }) {
|
function DeviceActiveTime({ ts }: { ts: number }) {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text className={BreakWord} size="T200">
|
<Text className={BreakWord} size="T200">
|
||||||
<Text size="Inherit" as="span" priority="300">
|
<Text size="Inherit" as="span" priority="300">
|
||||||
|
|
@ -49,7 +54,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
|
||||||
<>
|
<>
|
||||||
{today(ts) && 'Today'}
|
{today(ts) && 'Today'}
|
||||||
{yesterday(ts) && 'Yesterday'}
|
{yesterday(ts) && 'Yesterday'}
|
||||||
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
|
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
|
||||||
|
{timeHourMinute(ts, hour24Clock)}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import React, {
|
import React, {
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
as,
|
as,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
config,
|
config,
|
||||||
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
|
@ -28,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
|
|
@ -44,6 +48,7 @@ import {
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
|
|
@ -341,6 +346,359 @@ function Appearance() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DateHintProps = {
|
||||||
|
hasChanges: boolean;
|
||||||
|
handleReset: () => void;
|
||||||
|
};
|
||||||
|
function DateHint({ hasChanges, handleReset }: DateHintProps) {
|
||||||
|
const [anchor, setAnchor] = useState<RectCords>();
|
||||||
|
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
setAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={anchor}
|
||||||
|
position="Top"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Formatting</Text>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box direction="Column">
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Year</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
YY
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}
|
||||||
|
Two-digit year
|
||||||
|
</Text>{' '}
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
YYYY
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Four-digit year
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Month</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
M
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}The month
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
MM
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Two-digit month
|
||||||
|
</Text>{' '}
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
MMM
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Short month name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
MMMM
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Full month name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Day of the Month</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
D
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Day of the month
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
DD
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Two-digit day of the month
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Day of the Week</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
d
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Day of the week (Sunday = 0)
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
dd
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Two-letter day name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
ddd
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Short day name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
dddd
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Full day name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasChanges ? (
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleReset}
|
||||||
|
type="reset"
|
||||||
|
variant="Secondary"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
type="button"
|
||||||
|
variant="Secondary"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!anchor}
|
||||||
|
>
|
||||||
|
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomDateFormatProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (format: string) => void;
|
||||||
|
};
|
||||||
|
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
|
||||||
|
const [dateFormatCustom, setDateFormatCustom] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDateFormatCustom(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const format = evt.currentTarget.value;
|
||||||
|
setDateFormatCustom(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setDateFormatCustom(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
|
||||||
|
const format = customDateFormatInput?.value;
|
||||||
|
if (!format) return;
|
||||||
|
|
||||||
|
onChange(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = dateFormatCustom !== value;
|
||||||
|
return (
|
||||||
|
<SettingTile>
|
||||||
|
<Box as="form" onSubmit={handleSubmit} gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
name="customDateFormatInput"
|
||||||
|
value={dateFormatCustom}
|
||||||
|
onChange={handleChange}
|
||||||
|
maxLength={16}
|
||||||
|
autoComplete="off"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="400"
|
||||||
|
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||||
|
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetDateFormatProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (format: string) => void;
|
||||||
|
};
|
||||||
|
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const dateFormatItems = useDateFormatItems();
|
||||||
|
|
||||||
|
const getDisplayDate = (format: string): string =>
|
||||||
|
format !== '' ? dayjs().format(format) : 'Custom';
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (format: DateFormat) => {
|
||||||
|
onChange(format);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<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="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{dateFormatItems.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.format}
|
||||||
|
size="300"
|
||||||
|
variant={value === item.format ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(item.format)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{getDisplayDate(item.format)}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectDateFormat() {
|
||||||
|
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
|
||||||
|
const customDateFormat = selectedDateFormat === '';
|
||||||
|
|
||||||
|
const handlePresetChange = (format: string) => {
|
||||||
|
setSelectedDateFormat(format);
|
||||||
|
if (format !== '') {
|
||||||
|
setDateFormatString(format);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingTile
|
||||||
|
title="Date Format"
|
||||||
|
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
|
||||||
|
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
|
||||||
|
/>
|
||||||
|
{customDateFormat && (
|
||||||
|
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateAndTime() {
|
||||||
|
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Date & Time</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="24-Hour Time Format"
|
||||||
|
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SelectDateFormat />
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
|
|
@ -637,6 +995,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Appearance />
|
<Appearance />
|
||||||
|
<DateAndTime />
|
||||||
<Editor />
|
<Editor />
|
||||||
<Messages />
|
<Messages />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
const getRoom = useGetRoom(allJoinedRooms);
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
const room = getRoom(roomId);
|
const room = getRoom(roomId);
|
||||||
const space = spaceId ? getRoom(spaceId) : undefined;
|
const space = spaceId && spaceId !== roomId ? getRoom(spaceId) : undefined;
|
||||||
|
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
|
|
||||||
12
src/app/hooks/router/useCreateSelected.ts
Normal file
12
src/app/hooks/router/useCreateSelected.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useMatch } from 'react-router-dom';
|
||||||
|
import { getCreatePath } from '../../pages/pathUtils';
|
||||||
|
|
||||||
|
export const useCreateSelected = (): boolean => {
|
||||||
|
const match = useMatch({
|
||||||
|
path: getCreatePath(),
|
||||||
|
caseSensitive: true,
|
||||||
|
end: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!match;
|
||||||
|
};
|
||||||
34
src/app/hooks/useDateFormat.ts
Normal file
34
src/app/hooks/useDateFormat.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { DateFormat } from '../state/settings';
|
||||||
|
|
||||||
|
export type DateFormatItem = {
|
||||||
|
name: string;
|
||||||
|
format: DateFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDateFormatItems = (): DateFormatItem[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
format: 'D MMM YYYY',
|
||||||
|
name: 'D MMM YYYY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'DD/MM/YYYY',
|
||||||
|
name: 'DD/MM/YYYY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'MM/DD/YYYY',
|
||||||
|
name: 'MM/DD/YYYY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'YYYY/MM/DD',
|
||||||
|
name: 'YYYY/MM/DD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: '',
|
||||||
|
name: 'Custom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
28
src/app/hooks/useMembership.ts
Normal file
28
src/app/hooks/useMembership.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Room, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk';
|
||||||
|
import { Membership } from '../../types/matrix/room';
|
||||||
|
|
||||||
|
export const useMembership = (room: Room, userId: string): Membership => {
|
||||||
|
const member = room.getMember(userId);
|
||||||
|
|
||||||
|
const [membership, setMembership] = useState<Membership>(
|
||||||
|
() => (member?.membership as Membership | undefined) ?? Membership.Leave
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMembershipChange: RoomMemberEventHandlerMap[RoomMemberEvent.Membership] = (
|
||||||
|
event,
|
||||||
|
m
|
||||||
|
) => {
|
||||||
|
if (event.getRoomId() === room.roomId && m.userId === userId) {
|
||||||
|
setMembership((m.membership as Membership | undefined) ?? Membership.Leave);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
member?.on(RoomMemberEvent.Membership, handleMembershipChange);
|
||||||
|
return () => {
|
||||||
|
member?.removeListener(RoomMemberEvent.Membership, handleMembershipChange);
|
||||||
|
};
|
||||||
|
}, [room, member, userId]);
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
};
|
||||||
|
|
@ -3,14 +3,17 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { useRoomNavigate } from './useRoomNavigate';
|
import { useRoomNavigate } from './useRoomNavigate';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { isRoomId, isUserId } from '../utils/matrix';
|
import { isRoomId, isUserId } from '../utils/matrix';
|
||||||
import { openProfileViewer } from '../../client/action/navigation';
|
|
||||||
import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils';
|
import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils';
|
||||||
import { _RoomSearchParams } from '../pages/paths';
|
import { _RoomSearchParams } from '../pages/paths';
|
||||||
|
import { useOpenUserRoomProfile } from '../state/hooks/userRoomProfile';
|
||||||
|
import { useSpaceOptionally } from './useSpace';
|
||||||
|
|
||||||
export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLElement> => {
|
export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLElement> => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const openProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const handleClick: ReactEventHandler<HTMLElement> = useCallback(
|
const handleClick: ReactEventHandler<HTMLElement> = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
|
@ -21,7 +24,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLEl
|
||||||
if (typeof mentionId !== 'string') return;
|
if (typeof mentionId !== 'string') return;
|
||||||
|
|
||||||
if (isUserId(mentionId)) {
|
if (isUserId(mentionId)) {
|
||||||
openProfileViewer(mentionId, roomId);
|
openProfile(roomId, space?.roomId, mentionId, target.getBoundingClientRect());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +40,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler<HTMLEl
|
||||||
|
|
||||||
navigate(viaServers ? withSearchParam<_RoomSearchParams>(path, { viaServers }) : path);
|
navigate(viaServers ? withSearchParam<_RoomSearchParams>(path, { viaServers }) : path);
|
||||||
},
|
},
|
||||||
[mx, navigate, navigateRoom, navigateSpace, roomId]
|
[mx, navigate, navigateRoom, navigateSpace, roomId, space, openProfile]
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleClick;
|
return handleClick;
|
||||||
|
|
|
||||||
30
src/app/hooks/useMutualRooms.ts
Normal file
30
src/app/hooks/useMutualRooms.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
|
||||||
|
import { useSpecVersions } from './useSpecVersions';
|
||||||
|
|
||||||
|
export const useMutualRoomsSupport = (): boolean => {
|
||||||
|
const { unstable_features: unstableFeatures } = useSpecVersions();
|
||||||
|
|
||||||
|
const supported =
|
||||||
|
unstableFeatures?.['uk.half-shot.msc2666'] ||
|
||||||
|
unstableFeatures?.['uk.half-shot.msc2666.mutual_rooms'] ||
|
||||||
|
unstableFeatures?.['uk.half-shot.msc2666.query_mutual_rooms'];
|
||||||
|
|
||||||
|
return !!supported;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMutualRooms = (userId: string): AsyncState<string[], unknown> => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const supported = useMutualRoomsSupport();
|
||||||
|
|
||||||
|
const [mutualRoomsState] = useAsyncCallbackValue(
|
||||||
|
useCallback(
|
||||||
|
() => (supported ? mx._unstable_getSharedRooms(userId) : Promise.resolve([])),
|
||||||
|
[mx, userId, supported]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return mutualRoomsState;
|
||||||
|
};
|
||||||
37
src/app/hooks/useTimeoutToggle.ts
Normal file
37
src/app/hooks/useTimeoutToggle.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporarily sets a boolean state.
|
||||||
|
*
|
||||||
|
* @param duration - Duration in milliseconds before resetting (default: 1500)
|
||||||
|
* @param initial - Initial value (default: false)
|
||||||
|
*/
|
||||||
|
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
|
||||||
|
const [active, setActive] = useState(initial);
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = useCallback(() => {
|
||||||
|
setActive(!initial);
|
||||||
|
clear();
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
setActive(initial);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, duration);
|
||||||
|
}, [duration, initial]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clear();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [active, trigger];
|
||||||
|
}
|
||||||
58
src/app/hooks/useUserPresence.ts
Normal file
58
src/app/hooks/useUserPresence.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { User, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
|
export enum Presence {
|
||||||
|
Online = 'online',
|
||||||
|
Unavailable = 'unavailable',
|
||||||
|
Offline = 'offline',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserPresence = {
|
||||||
|
presence: Presence;
|
||||||
|
status?: string;
|
||||||
|
active: boolean;
|
||||||
|
lastActiveTs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserPresence = (user: User): UserPresence => ({
|
||||||
|
presence: user.presence as Presence,
|
||||||
|
status: user.presenceStatusMsg,
|
||||||
|
active: user.currentlyActive,
|
||||||
|
lastActiveTs: user.getLastActiveTs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUserPresence = (userId: string): UserPresence | undefined => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const user = mx.getUser(userId);
|
||||||
|
|
||||||
|
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
|
||||||
|
if (u.userId === user?.userId) {
|
||||||
|
setPresence(getUserPresence(user));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
user?.on(UserEvent.Presence, updatePresence);
|
||||||
|
user?.on(UserEvent.CurrentlyActive, updatePresence);
|
||||||
|
user?.on(UserEvent.LastPresenceTs, updatePresence);
|
||||||
|
return () => {
|
||||||
|
user?.removeListener(UserEvent.Presence, updatePresence);
|
||||||
|
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||||
|
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return presence;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePresenceLabel = (): Record<Presence, string> =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[Presence.Online]: 'Active',
|
||||||
|
[Presence.Unavailable]: 'Busy',
|
||||||
|
[Presence.Offline]: 'Away',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
_ROOM_PATH,
|
_ROOM_PATH,
|
||||||
_SEARCH_PATH,
|
_SEARCH_PATH,
|
||||||
_SERVER_PATH,
|
_SERVER_PATH,
|
||||||
|
CREATE_PATH,
|
||||||
} from './paths';
|
} from './paths';
|
||||||
import { isAuthenticated } from '../../client/state/auth';
|
import { isAuthenticated } from '../../client/state/auth';
|
||||||
import {
|
import {
|
||||||
|
|
@ -61,6 +62,11 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
|
||||||
import { RoomSettingsRenderer } from '../features/room-settings';
|
import { RoomSettingsRenderer } from '../features/room-settings';
|
||||||
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
|
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
|
||||||
import { SpaceSettingsRenderer } from '../features/space-settings';
|
import { SpaceSettingsRenderer } from '../features/space-settings';
|
||||||
|
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
|
||||||
|
import { CreateRoomModalRenderer } from '../features/create-room';
|
||||||
|
import { HomeCreateRoom } from './client/home/CreateRoom';
|
||||||
|
import { Create } from './client/create';
|
||||||
|
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||||
|
|
||||||
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
|
||||||
const { hashRouter } = clientConfig;
|
const { hashRouter } = clientConfig;
|
||||||
|
|
@ -125,6 +131,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
|
<UserRoomProfileRenderer />
|
||||||
|
<CreateRoomModalRenderer />
|
||||||
|
<CreateSpaceModalRenderer />
|
||||||
<RoomSettingsRenderer />
|
<RoomSettingsRenderer />
|
||||||
<SpaceSettingsRenderer />
|
<SpaceSettingsRenderer />
|
||||||
<ReceiveSelfDeviceVerification />
|
<ReceiveSelfDeviceVerification />
|
||||||
|
|
@ -152,7 +161,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
<Route path={_CREATE_PATH} element={<p>create</p>} />
|
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
|
||||||
<Route path={_JOIN_PATH} element={<p>join</p>} />
|
<Route path={_JOIN_PATH} element={<p>join</p>} />
|
||||||
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
|
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
|
||||||
<Route
|
<Route
|
||||||
|
|
@ -253,6 +262,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
|
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
|
||||||
<Route path={_SERVER_PATH} element={<PublicRooms />} />
|
<Route path={_SERVER_PATH} element={<PublicRooms />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path={CREATE_PATH} element={<Create />} />
|
||||||
<Route
|
<Route
|
||||||
path={INBOX_PATH}
|
path={INBOX_PATH}
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ import {
|
||||||
SettingsTab,
|
SettingsTab,
|
||||||
UnverifiedTab,
|
UnverifiedTab,
|
||||||
} from './sidebar';
|
} from './sidebar';
|
||||||
import { openCreateRoom, openSearch } from '../../../client/action/navigation';
|
import { openSearch } from '../../../client/action/navigation';
|
||||||
|
import { CreateTab } from './sidebar/CreateTab';
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -37,20 +38,7 @@ export function SidebarNav() {
|
||||||
<SidebarStackSeparator />
|
<SidebarStackSeparator />
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<ExploreTab />
|
<ExploreTab />
|
||||||
<SidebarItem>
|
<CreateTab />
|
||||||
<SidebarItemTooltip tooltip="Create Space">
|
|
||||||
{(triggerRef) => (
|
|
||||||
<SidebarAvatar
|
|
||||||
as="button"
|
|
||||||
ref={triggerRef}
|
|
||||||
outlined
|
|
||||||
onClick={() => openCreateRoom(true)}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Plus} />
|
|
||||||
</SidebarAvatar>
|
|
||||||
)}
|
|
||||||
</SidebarItemTooltip>
|
|
||||||
</SidebarItem>
|
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/app/pages/client/create/Create.tsx
Normal file
38
src/app/pages/client/create/Create.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, Icons, Scroll } from 'folds';
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
PageContent,
|
||||||
|
PageContentCenter,
|
||||||
|
PageHero,
|
||||||
|
PageHeroSection,
|
||||||
|
} from '../../../components/page';
|
||||||
|
import { CreateSpaceForm } from '../../../features/create-space';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
|
export function Create() {
|
||||||
|
const { navigateSpace } = useRoomNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<PageContentCenter>
|
||||||
|
<PageHeroSection>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<PageHero
|
||||||
|
icon={<Icon size="600" src={Icons.Space} />}
|
||||||
|
title="Create Space"
|
||||||
|
subTitle="Build a space for your community."
|
||||||
|
/>
|
||||||
|
<CreateSpaceForm onCreate={navigateSpace} />
|
||||||
|
</Box>
|
||||||
|
</PageHeroSection>
|
||||||
|
</PageContentCenter>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/pages/client/create/index.ts
Normal file
1
src/app/pages/client/create/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './Create';
|
||||||
56
src/app/pages/client/home/CreateRoom.tsx
Normal file
56
src/app/pages/client/home/CreateRoom.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
PageContent,
|
||||||
|
PageContentCenter,
|
||||||
|
PageHeader,
|
||||||
|
PageHero,
|
||||||
|
PageHeroSection,
|
||||||
|
} from '../../../components/page';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||||
|
import { CreateRoomForm } from '../../../features/create-room';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
|
export function HomeCreateRoom() {
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
|
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.Hash} />}
|
||||||
|
title="Create Room"
|
||||||
|
subTitle="Build a Room for Real-Time Conversations"
|
||||||
|
/>
|
||||||
|
<CreateRoomForm onCreate={navigateRoom} />
|
||||||
|
</Box>
|
||||||
|
</PageHeroSection>
|
||||||
|
</PageContentCenter>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,10 +29,18 @@ import {
|
||||||
NavItemContent,
|
NavItemContent,
|
||||||
NavLink,
|
NavLink,
|
||||||
} from '../../../components/nav';
|
} from '../../../components/nav';
|
||||||
import { getExplorePath, getHomeRoomPath, getHomeSearchPath } from '../../pathUtils';
|
import {
|
||||||
|
getExplorePath,
|
||||||
|
getHomeCreatePath,
|
||||||
|
getHomeRoomPath,
|
||||||
|
getHomeSearchPath,
|
||||||
|
} 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 { useHomeSearchSelected } from '../../../hooks/router/useHomeSelected';
|
import {
|
||||||
|
useHomeCreateSelected,
|
||||||
|
useHomeSearchSelected,
|
||||||
|
} from '../../../hooks/router/useHomeSelected';
|
||||||
import { useHomeRooms } from './useHomeRooms';
|
import { useHomeRooms } from './useHomeRooms';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { VirtualTile } from '../../../components/virtualizer';
|
import { VirtualTile } from '../../../components/virtualizer';
|
||||||
|
|
@ -41,7 +49,7 @@ import { makeNavCategoryId } from '../../../state/closedNavCategories';
|
||||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
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 { openCreateRoom, openJoinAlias } from '../../../../client/action/navigation';
|
import { openJoinAlias } from '../../../../client/action/navigation';
|
||||||
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
|
||||||
import { useRoomsUnread } from '../../../state/hooks/unread';
|
import { useRoomsUnread } from '../../../state/hooks/unread';
|
||||||
import { markAsRead } from '../../../../client/action/notifications';
|
import { markAsRead } from '../../../../client/action/notifications';
|
||||||
|
|
@ -174,7 +182,7 @@ function HomeEmpty() {
|
||||||
}
|
}
|
||||||
options={
|
options={
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => openCreateRoom()} variant="Secondary" size="300">
|
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
Create Room
|
Create Room
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -204,8 +212,10 @@ export function Home() {
|
||||||
const rooms = useHomeRooms();
|
const rooms = useHomeRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
const createRoomSelected = useHomeCreateSelected();
|
||||||
const searchSelected = useHomeSearchSelected();
|
const searchSelected = useHomeSearchSelected();
|
||||||
const noRoomToDisplay = rooms.length === 0;
|
const noRoomToDisplay = rooms.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
@ -242,8 +252,8 @@ export function Home() {
|
||||||
<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={createRoomSelected}>
|
||||||
<NavButton onClick={() => openCreateRoom()}>
|
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ import { testBadWords } from '../../../plugins/bad-words';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
|
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
const COMPACT_CARD_WIDTH = 548;
|
const COMPACT_CARD_WIDTH = 548;
|
||||||
|
|
||||||
|
|
@ -135,10 +137,19 @@ type NavigateHandler = (roomId: string, space: boolean) => void;
|
||||||
type InviteCardProps = {
|
type InviteCardProps = {
|
||||||
invite: InviteData;
|
invite: InviteData;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
onNavigate: NavigateHandler;
|
onNavigate: NavigateHandler;
|
||||||
hideAvatar: boolean;
|
hideAvatar: boolean;
|
||||||
};
|
};
|
||||||
function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps) {
|
function InviteCard({
|
||||||
|
invite,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
onNavigate,
|
||||||
|
hideAvatar,
|
||||||
|
}: InviteCardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getSafeUserId();
|
const userId = mx.getSafeUserId();
|
||||||
|
|
||||||
|
|
@ -295,7 +306,13 @@ function InviteCard({ invite, compact, onNavigate, hideAvatar }: InviteCardProps
|
||||||
</Box>
|
</Box>
|
||||||
{invite.inviteTs && (
|
{invite.inviteTs && (
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<Time size="T200" ts={invite.inviteTs} priority="300" />
|
<Time
|
||||||
|
size="T200"
|
||||||
|
ts={invite.inviteTs}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
priority="300"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -384,8 +401,16 @@ type KnownInvitesProps = {
|
||||||
invites: InviteData[];
|
invites: InviteData[];
|
||||||
handleNavigate: NavigateHandler;
|
handleNavigate: NavigateHandler;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) {
|
function KnownInvites({
|
||||||
|
invites,
|
||||||
|
handleNavigate,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
}: KnownInvitesProps) {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="H4">Primary</Text>
|
<Text size="H4">Primary</Text>
|
||||||
|
|
@ -396,6 +421,8 @@ function KnownInvites({ invites, handleNavigate, compact }: KnownInvitesProps) {
|
||||||
key={invite.roomId}
|
key={invite.roomId}
|
||||||
invite={invite}
|
invite={invite}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
hideAvatar={false}
|
hideAvatar={false}
|
||||||
/>
|
/>
|
||||||
|
|
@ -420,8 +447,16 @@ type UnknownInvitesProps = {
|
||||||
invites: InviteData[];
|
invites: InviteData[];
|
||||||
handleNavigate: NavigateHandler;
|
handleNavigate: NavigateHandler;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProps) {
|
function UnknownInvites({
|
||||||
|
invites,
|
||||||
|
handleNavigate,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
}: UnknownInvitesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [declineAllStatus, declineAll] = useAsyncCallback(
|
const [declineAllStatus, declineAll] = useAsyncCallback(
|
||||||
|
|
@ -459,6 +494,8 @@ function UnknownInvites({ invites, handleNavigate, compact }: UnknownInvitesProp
|
||||||
key={invite.roomId}
|
key={invite.roomId}
|
||||||
invite={invite}
|
invite={invite}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
hideAvatar
|
hideAvatar
|
||||||
/>
|
/>
|
||||||
|
|
@ -483,8 +520,16 @@ type SpamInvitesProps = {
|
||||||
invites: InviteData[];
|
invites: InviteData[];
|
||||||
handleNavigate: NavigateHandler;
|
handleNavigate: NavigateHandler;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
function SpamInvites({ invites, handleNavigate, compact }: SpamInvitesProps) {
|
function SpamInvites({
|
||||||
|
invites,
|
||||||
|
handleNavigate,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
}: SpamInvitesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [showInvites, setShowInvites] = useState(false);
|
const [showInvites, setShowInvites] = useState(false);
|
||||||
|
|
||||||
|
|
@ -608,6 +653,8 @@ function SpamInvites({ invites, handleNavigate, compact }: SpamInvitesProps) {
|
||||||
key={invite.roomId}
|
key={invite.roomId}
|
||||||
invite={invite}
|
invite={invite}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
hideAvatar
|
hideAvatar
|
||||||
/>
|
/>
|
||||||
|
|
@ -671,6 +718,9 @@ export function Invites() {
|
||||||
);
|
);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const handleNavigate = (roomId: string, space: boolean) => {
|
const handleNavigate = (roomId: string, space: boolean) => {
|
||||||
if (space) {
|
if (space) {
|
||||||
navigateSpace(roomId);
|
navigateSpace(roomId);
|
||||||
|
|
@ -723,6 +773,8 @@ export function Invites() {
|
||||||
<KnownInvites
|
<KnownInvites
|
||||||
invites={knownInvites}
|
invites={knownInvites}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
handleNavigate={handleNavigate}
|
handleNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -731,6 +783,8 @@ export function Invites() {
|
||||||
<UnknownInvites
|
<UnknownInvites
|
||||||
invites={unknownInvites}
|
invites={unknownInvites}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
handleNavigate={handleNavigate}
|
handleNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -739,6 +793,8 @@ export function Invites() {
|
||||||
<SpamInvites
|
<SpamInvites
|
||||||
invites={spamInvites}
|
invites={spamInvites}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
handleNavigate={handleNavigate}
|
handleNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,8 @@ type RoomNotificationsGroupProps = {
|
||||||
hideActivity: boolean;
|
hideActivity: boolean;
|
||||||
onOpen: (roomId: string, eventId: string) => void;
|
onOpen: (roomId: string, eventId: string) => void;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
function RoomNotificationsGroupComp({
|
function RoomNotificationsGroupComp({
|
||||||
room,
|
room,
|
||||||
|
|
@ -214,6 +216,8 @@ function RoomNotificationsGroupComp({
|
||||||
hideActivity,
|
hideActivity,
|
||||||
onOpen,
|
onOpen,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
}: RoomNotificationsGroupProps) {
|
}: RoomNotificationsGroupProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
@ -496,7 +500,11 @@ function RoomNotificationsGroupComp({
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={event.origin_server_ts} />
|
<Time
|
||||||
|
ts={event.origin_server_ts}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="200" alignItems="Center">
|
<Box shrink="No" gap="200" alignItems="Center">
|
||||||
<Chip
|
<Chip
|
||||||
|
|
@ -549,6 +557,8 @@ export function Notifications() {
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
||||||
|
|
@ -713,6 +723,8 @@ export function Notifications() {
|
||||||
legacyUsernameColor={
|
legacyUsernameColor={
|
||||||
legacyUsernameColor || mDirects.has(groupRoom.roomId)
|
legacyUsernameColor || mDirects.has(groupRoom.roomId)
|
||||||
}
|
}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
111
src/app/pages/client/sidebar/CreateTab.tsx
Normal file
111
src/app/pages/client/sidebar/CreateTab.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
|
import { openJoinAlias } from '../../../../client/action/navigation';
|
||||||
|
import { getCreatePath } from '../../pathUtils';
|
||||||
|
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
|
||||||
|
|
||||||
|
export function CreateTab() {
|
||||||
|
const createSelected = useCreateSelected();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSpace = () => {
|
||||||
|
navigate(getCreatePath());
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinWithAddress = () => {
|
||||||
|
openJoinAlias();
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem active={createSelected}>
|
||||||
|
<SidebarItemTooltip tooltip="Add Space">
|
||||||
|
{(triggerRef) => (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
position="Right"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
returnFocusOnDeactivate: false,
|
||||||
|
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">
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="Surface"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
radii="0"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateSpace}
|
||||||
|
>
|
||||||
|
<SettingTile before={<Icon size="400" src={Icons.Space} />}>
|
||||||
|
<Text size="H6">Create Space</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Build a space for your community.
|
||||||
|
</Text>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
style={{ padding: config.space.S300 }}
|
||||||
|
variant="Surface"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
radii="0"
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={handleJoinWithAddress}
|
||||||
|
>
|
||||||
|
<SettingTile before={<Icon size="400" src={Icons.Link} />}>
|
||||||
|
<Text size="H6">Join with Address</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Become a part of existing community.
|
||||||
|
</Text>
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SidebarAvatar
|
||||||
|
className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
|
||||||
|
as="button"
|
||||||
|
ref={triggerRef}
|
||||||
|
outlined
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Plus} />
|
||||||
|
</SidebarAvatar>
|
||||||
|
</PopOut>
|
||||||
|
)}
|
||||||
|
</SidebarItemTooltip>
|
||||||
|
</SidebarItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ export function SettingsTab() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem active={settings}>
|
<SidebarItem active={settings}>
|
||||||
<SidebarItemTooltip tooltip={displayName}>
|
<SidebarItemTooltip tooltip="User Settings">
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<SidebarAvatar as="button" ref={triggerRef} onClick={openSettings}>
|
<SidebarAvatar as="button" ref={triggerRef} onClick={openSettings}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
SPACE_PATH,
|
SPACE_PATH,
|
||||||
SPACE_ROOM_PATH,
|
SPACE_ROOM_PATH,
|
||||||
SPACE_SEARCH_PATH,
|
SPACE_SEARCH_PATH,
|
||||||
|
CREATE_PATH,
|
||||||
} from './paths';
|
} from './paths';
|
||||||
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
|
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
|
||||||
import { HashRouterConfig } from '../hooks/useClientConfig';
|
import { HashRouterConfig } from '../hooks/useClientConfig';
|
||||||
|
|
@ -152,6 +153,8 @@ export const getExploreServerPath = (server: string): string => {
|
||||||
return generatePath(EXPLORE_SERVER_PATH, params);
|
return generatePath(EXPLORE_SERVER_PATH, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCreatePath = (): string => CREATE_PATH;
|
||||||
|
|
||||||
export const getInboxPath = (): string => INBOX_PATH;
|
export const getInboxPath = (): string => INBOX_PATH;
|
||||||
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
|
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
|
||||||
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
|
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ export type ExploreServerPathSearchParams = {
|
||||||
};
|
};
|
||||||
export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
|
export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
|
||||||
|
|
||||||
|
export const CREATE_PATH = '/create';
|
||||||
|
|
||||||
export const _NOTIFICATIONS_PATH = 'notifications/';
|
export const _NOTIFICATIONS_PATH = 'notifications/';
|
||||||
export const _INVITES_PATH = 'invites/';
|
export const _INVITES_PATH = 'invites/';
|
||||||
export const INBOX_PATH = '/inbox/';
|
export const INBOX_PATH = '/inbox/';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
/* eslint-disable jsx-a11y/alt-text */
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react';
|
import React, {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
ReactEventHandler,
|
||||||
|
Suspense,
|
||||||
|
lazy,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
Element,
|
Element,
|
||||||
Text as DOMText,
|
Text as DOMText,
|
||||||
|
|
@ -9,10 +16,11 @@ import {
|
||||||
} from 'html-react-parser';
|
} from 'html-react-parser';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Scroll, Text } from 'folds';
|
import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
|
||||||
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
|
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { ChildNode } from 'domhandler';
|
||||||
import * as css from '../styles/CustomHtml.css';
|
import * as css from '../styles/CustomHtml.css';
|
||||||
import {
|
import {
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
|
|
@ -31,7 +39,8 @@ import {
|
||||||
testMatrixTo,
|
testMatrixTo,
|
||||||
} from './matrix-to';
|
} from './matrix-to';
|
||||||
import { onEnterOrSpace } from '../utils/keyboard';
|
import { onEnterOrSpace } from '../utils/keyboard';
|
||||||
import { tryDecodeURIComponent } from '../utils/dom';
|
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||||
|
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||||
|
|
||||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||||
|
|
||||||
|
|
@ -195,6 +204,111 @@ export const highlightText = (
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively extracts and concatenates all text content from an array of ChildNode objects.
|
||||||
|
*
|
||||||
|
* @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
|
||||||
|
* @returns {string} The concatenated plain text content of all descendant text nodes.
|
||||||
|
*/
|
||||||
|
const extractTextFromChildren = (nodes: ChildNode[]): string => {
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
text += node.data;
|
||||||
|
} else if (node instanceof Element && node.children) {
|
||||||
|
text += extractTextFromChildren(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodeBlock({
|
||||||
|
children,
|
||||||
|
opts,
|
||||||
|
}: {
|
||||||
|
children: ChildNode[];
|
||||||
|
opts: HTMLReactParserOptions;
|
||||||
|
}) {
|
||||||
|
const code = children[0];
|
||||||
|
const languageClass =
|
||||||
|
code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
|
||||||
|
const language =
|
||||||
|
languageClass && languageClass.startsWith('language-')
|
||||||
|
? languageClass.replace('language-', '')
|
||||||
|
: languageClass;
|
||||||
|
|
||||||
|
const LINE_LIMIT = 14;
|
||||||
|
const largeCodeBlock = useMemo(
|
||||||
|
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
|
||||||
|
[children]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [expanded, setExpand] = useState(false);
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
copyToClipboard(extractTextFromChildren(children));
|
||||||
|
setCopied();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = () => {
|
||||||
|
setExpand(!expanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="T300" as="pre" className={css.CodeBlock}>
|
||||||
|
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
{language ?? 'Code'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Chip
|
||||||
|
variant={copied ? 'Success' : 'Surface'}
|
||||||
|
fill="None"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleCopy}
|
||||||
|
before={copied && <Icon size="50" src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
||||||
|
</Chip>
|
||||||
|
{largeCodeBlock && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
onClick={toggleExpand}
|
||||||
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Scroll
|
||||||
|
style={{
|
||||||
|
maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
|
||||||
|
paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
|
||||||
|
}}
|
||||||
|
direction="Both"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
>
|
||||||
|
<div id="code-block-content" className={css.CodeBlockInternal}>
|
||||||
|
{domToReact(children, opts)}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
{largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const getReactCustomHtmlParser = (
|
export const getReactCustomHtmlParser = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomId: string | undefined,
|
roomId: string | undefined,
|
||||||
|
|
@ -269,19 +383,7 @@ export const getReactCustomHtmlParser = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'pre') {
|
if (name === 'pre') {
|
||||||
return (
|
return <CodeBlock opts={opts}>{children}</CodeBlock>;
|
||||||
<Text {...props} as="pre" className={css.CodeBlock}>
|
|
||||||
<Scroll
|
|
||||||
direction="Horizontal"
|
|
||||||
variant="Secondary"
|
|
||||||
size="300"
|
|
||||||
visibility="Hover"
|
|
||||||
hideTrack
|
|
||||||
>
|
|
||||||
<div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
|
|
||||||
</Scroll>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'blockquote') {
|
if (name === 'blockquote') {
|
||||||
|
|
@ -331,9 +433,9 @@ export const getReactCustomHtmlParser = (
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<code className={css.Code} {...props}>
|
<Text as="code" size="T300" className={css.Code} {...props}>
|
||||||
{domToReact(children, opts)}
|
{domToReact(children, opts)}
|
||||||
</code>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
src/app/state/createRoomModal.ts
Normal file
7
src/app/state/createRoomModal.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export type CreateRoomModalState = {
|
||||||
|
spaceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRoomModalAtom = atom<CreateRoomModalState | undefined>(undefined);
|
||||||
7
src/app/state/createSpaceModal.ts
Normal file
7
src/app/state/createSpaceModal.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export type CreateSpaceModalState = {
|
||||||
|
spaceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSpaceModalAtom = atom<CreateSpaceModalState | undefined>(undefined);
|
||||||
34
src/app/state/hooks/createRoomModal.ts
Normal file
34
src/app/state/hooks/createRoomModal.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { createRoomModalAtom, CreateRoomModalState } from '../createRoomModal';
|
||||||
|
|
||||||
|
export const useCreateRoomModalState = (): CreateRoomModalState | undefined => {
|
||||||
|
const data = useAtomValue(createRoomModalAtom);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloseCallback = () => void;
|
||||||
|
export const useCloseCreateRoomModal = (): CloseCallback => {
|
||||||
|
const setSettings = useSetAtom(createRoomModalAtom);
|
||||||
|
|
||||||
|
const close: CloseCallback = useCallback(() => {
|
||||||
|
setSettings(undefined);
|
||||||
|
}, [setSettings]);
|
||||||
|
|
||||||
|
return close;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenCallback = (space?: string) => void;
|
||||||
|
export const useOpenCreateRoomModal = (): OpenCallback => {
|
||||||
|
const setSettings = useSetAtom(createRoomModalAtom);
|
||||||
|
|
||||||
|
const open: OpenCallback = useCallback(
|
||||||
|
(spaceId) => {
|
||||||
|
setSettings({ spaceId });
|
||||||
|
},
|
||||||
|
[setSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return open;
|
||||||
|
};
|
||||||
34
src/app/state/hooks/createSpaceModal.ts
Normal file
34
src/app/state/hooks/createSpaceModal.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { createSpaceModalAtom, CreateSpaceModalState } from '../createSpaceModal';
|
||||||
|
|
||||||
|
export const useCreateSpaceModalState = (): CreateSpaceModalState | undefined => {
|
||||||
|
const data = useAtomValue(createSpaceModalAtom);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloseCallback = () => void;
|
||||||
|
export const useCloseCreateSpaceModal = (): CloseCallback => {
|
||||||
|
const setSettings = useSetAtom(createSpaceModalAtom);
|
||||||
|
|
||||||
|
const close: CloseCallback = useCallback(() => {
|
||||||
|
setSettings(undefined);
|
||||||
|
}, [setSettings]);
|
||||||
|
|
||||||
|
return close;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenCallback = (space?: string) => void;
|
||||||
|
export const useOpenCreateSpaceModal = (): OpenCallback => {
|
||||||
|
const setSettings = useSetAtom(createSpaceModalAtom);
|
||||||
|
|
||||||
|
const open: OpenCallback = useCallback(
|
||||||
|
(spaceId) => {
|
||||||
|
setSettings({ spaceId });
|
||||||
|
},
|
||||||
|
[setSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return open;
|
||||||
|
};
|
||||||
41
src/app/state/hooks/userRoomProfile.ts
Normal file
41
src/app/state/hooks/userRoomProfile.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { Position, RectCords } from 'folds';
|
||||||
|
import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile';
|
||||||
|
|
||||||
|
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
|
||||||
|
const data = useAtomValue(userRoomProfileAtom);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CloseCallback = () => void;
|
||||||
|
export const useCloseUserRoomProfile = (): CloseCallback => {
|
||||||
|
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
||||||
|
|
||||||
|
const close: CloseCallback = useCallback(() => {
|
||||||
|
setUserRoomProfile(undefined);
|
||||||
|
}, [setUserRoomProfile]);
|
||||||
|
|
||||||
|
return close;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenCallback = (
|
||||||
|
roomId: string,
|
||||||
|
spaceId: string | undefined,
|
||||||
|
userId: string,
|
||||||
|
cords: RectCords,
|
||||||
|
position?: Position
|
||||||
|
) => void;
|
||||||
|
export const useOpenUserRoomProfile = (): OpenCallback => {
|
||||||
|
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
||||||
|
|
||||||
|
const open: OpenCallback = useCallback(
|
||||||
|
(roomId, spaceId, userId, cords, position) => {
|
||||||
|
setUserRoomProfile({ roomId, spaceId, userId, cords, position });
|
||||||
|
},
|
||||||
|
[setUserRoomProfile]
|
||||||
|
);
|
||||||
|
|
||||||
|
return open;
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
const STORAGE_KEY = 'settings';
|
const STORAGE_KEY = 'settings';
|
||||||
|
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
|
||||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||||
export enum MessageLayout {
|
export enum MessageLayout {
|
||||||
Modern = 0,
|
Modern = 0,
|
||||||
|
|
@ -35,6 +36,9 @@ export interface Settings {
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
|
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
|
||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,6 +69,9 @@ const defaultSettings: Settings = {
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
|
||||||
|
hour24Clock: false,
|
||||||
|
dateFormatString: 'D MMM YYYY',
|
||||||
|
|
||||||
developerTools: false,
|
developerTools: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
12
src/app/state/userRoomProfile.ts
Normal file
12
src/app/state/userRoomProfile.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Position, RectCords } from 'folds';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export type UserRoomProfileState = {
|
||||||
|
userId: string;
|
||||||
|
roomId: string;
|
||||||
|
spaceId?: string;
|
||||||
|
cords: RectCords;
|
||||||
|
position?: Position;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userRoomProfileAtom = atom<UserRoomProfileState | undefined>(undefined);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ComplexStyleRule } from '@vanilla-extract/css';
|
import { ComplexStyleRule } from '@vanilla-extract/css';
|
||||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||||
import { ContainerColor as TContainerColor, DefaultReset, color } from 'folds';
|
import { ContainerColor as TContainerColor, DefaultReset, color, config } from 'folds';
|
||||||
|
|
||||||
const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
|
const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
|
||||||
vars: {
|
vars: {
|
||||||
|
|
@ -9,6 +9,20 @@ const getVariant = (variant: TContainerColor): ComplexStyleRule => ({
|
||||||
outlineColor: color[variant].ContainerLine,
|
outlineColor: color[variant].ContainerLine,
|
||||||
color: color[variant].OnContainer,
|
color: color[variant].OnContainer,
|
||||||
},
|
},
|
||||||
|
selectors: {
|
||||||
|
'button&[aria-pressed=true]': {
|
||||||
|
backgroundColor: color[variant].ContainerActive,
|
||||||
|
},
|
||||||
|
'button&:hover, &:focus-visible': {
|
||||||
|
backgroundColor: color[variant].ContainerHover,
|
||||||
|
},
|
||||||
|
'button&:active': {
|
||||||
|
backgroundColor: color[variant].ContainerActive,
|
||||||
|
},
|
||||||
|
'button&[disabled]': {
|
||||||
|
opacity: config.opacity.Disabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ContainerColor = recipe({
|
export const ContainerColor = recipe({
|
||||||
|
|
|
||||||
|
|
@ -41,16 +41,19 @@ export const BlockQuote = style([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const BaseCode = style({
|
const BaseCode = style({
|
||||||
fontFamily: 'monospace',
|
color: color.SurfaceVariant.OnContainer,
|
||||||
color: color.Secondary.OnContainer,
|
background: color.SurfaceVariant.Container,
|
||||||
background: color.Secondary.Container,
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
|
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
});
|
});
|
||||||
|
const CodeFont = style({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
|
||||||
export const Code = style([
|
export const Code = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
BaseCode,
|
BaseCode,
|
||||||
|
CodeFont,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
},
|
},
|
||||||
|
|
@ -85,10 +88,32 @@ export const CodeBlock = style([
|
||||||
MarginSpaced,
|
MarginSpaced,
|
||||||
{
|
{
|
||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
export const CodeBlockInternal = style({
|
export const CodeBlockHeader = style({
|
||||||
padding: `${config.space.S200} ${config.space.S200} 0`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
|
export const CodeBlockInternal = style([
|
||||||
|
CodeFont,
|
||||||
|
{
|
||||||
|
padding: `${config.space.S200} ${config.space.S200} 0`,
|
||||||
|
minWidth: toRem(200),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CodeBlockBottomShadow = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
|
||||||
|
height: config.space.S400,
|
||||||
|
background: `linear-gradient(to top, #00000022, #00000000)`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const List = style([
|
export const List = style([
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ export const millisecondsToMinutesAndSeconds = (milliseconds: number): string =>
|
||||||
return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
|
return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const millisecondsToMinutes = (milliseconds: number): string => {
|
||||||
|
const seconds = Math.floor(milliseconds / 1000);
|
||||||
|
const mm = Math.floor(seconds / 60);
|
||||||
|
|
||||||
|
return mm.toString();
|
||||||
|
};
|
||||||
|
|
||||||
export const secondsToMinutesAndSeconds = (seconds: number): string => {
|
export const secondsToMinutesAndSeconds = (seconds: number): string => {
|
||||||
const mm = Math.floor(seconds / 60);
|
const mm = Math.floor(seconds / 60);
|
||||||
const ss = Math.round(seconds % 60);
|
const ss = Math.round(seconds % 60);
|
||||||
|
|
|
||||||
|
|
@ -344,3 +344,16 @@ export const rateLimitedActions = async <T, R = void>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const knockSupported = (version: string): boolean => {
|
||||||
|
const unsupportedVersion = ['1', '2', '3', '4', '5', '6'];
|
||||||
|
return !unsupportedVersion.includes(version);
|
||||||
|
};
|
||||||
|
export const restrictedSupported = (version: string): boolean => {
|
||||||
|
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7'];
|
||||||
|
return !unsupportedVersion.includes(version);
|
||||||
|
};
|
||||||
|
export const knockRestrictedSupported = (version: string): boolean => {
|
||||||
|
const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||||
|
return !unsupportedVersion.includes(version);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -295,9 +295,14 @@ export const getDirectRoomAvatarUrl = (
|
||||||
useAuthentication = false
|
useAuthentication = false
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
|
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
|
||||||
return mxcUrl
|
|
||||||
? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
|
if (!mxcUrl) {
|
||||||
: undefined;
|
return getRoomAvatarUrl(mx, room, size, useAuthentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trimReplyFromBody = (body: string): string => {
|
export const trimReplyFromBody = (body: string): string => {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ export const today = (ts: number): boolean => dayjs(ts).isToday();
|
||||||
|
|
||||||
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
|
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
|
||||||
|
|
||||||
export const timeHour = (ts: number): string => dayjs(ts).format('hh');
|
export const timeHour = (ts: number, hour24Clock: boolean): string =>
|
||||||
|
dayjs(ts).format(hour24Clock ? 'HH' : 'hh');
|
||||||
export const timeMinute = (ts: number): string => dayjs(ts).format('mm');
|
export const timeMinute = (ts: number): string => dayjs(ts).format('mm');
|
||||||
export const timeAmPm = (ts: number): string => dayjs(ts).format('A');
|
export const timeAmPm = (ts: number): string => dayjs(ts).format('A');
|
||||||
export const timeDay = (ts: number): string => dayjs(ts).format('D');
|
export const timeDay = (ts: number): string => dayjs(ts).format('D');
|
||||||
|
|
@ -17,9 +18,11 @@ export const timeMon = (ts: number): string => dayjs(ts).format('MMM');
|
||||||
export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM');
|
export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM');
|
||||||
export const timeYear = (ts: number): string => dayjs(ts).format('YYYY');
|
export const timeYear = (ts: number): string => dayjs(ts).format('YYYY');
|
||||||
|
|
||||||
export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A');
|
export const timeHourMinute = (ts: number, hour24Clock: boolean): string =>
|
||||||
|
dayjs(ts).format(hour24Clock ? 'HH:mm' : 'hh:mm A');
|
||||||
|
|
||||||
export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY');
|
export const timeDayMonYear = (ts: number, dateFormatString: string): string =>
|
||||||
|
dayjs(ts).format(dateFormatString);
|
||||||
|
|
||||||
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
|
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue