mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-16 04:00:29 +03:00
New room settings, add customizable power levels and dev tools (#2222)
* WIP - add room settings dialog * join rule setting - WIP * show emojis & stickers in room settings - WIP * restyle join rule switcher * Merge branch 'dev' into new-room-settings * add join rule hook * open room settings from global state * open new room settings from all places * rearrange settings menu item * add option for creating new image pack * room devtools - WIP * render room state events as list * add option to open state event * add option to edit state event * refactor text area code editor into hook * add option to send message and state event * add cutout card component * add hook for room account data * display room account data - WIP * refactor global account data editor component * add account data editor in room * fix font style in devtool * show state events in compact form * add option to delete room image pack * add server badge component * add member tile component * render members in room settings * add search in room settings member * add option to reset member search * add filter in room members * fix member virtual item key * remove color from serve badge in room members * show room in settings * fix loading indicator position * power level tags in room setting - WIP * generate fallback tag in backward compatible way * add color picker * add powers editor - WIP * add props to stop adding emoji to recent usage * add beta feature notice badge * add types for power level tag icon * refactor image pack rooms code to hook * option for adding new power levels tags * remove console log * refactor power icon * add option to edit power level tags * remove power level from powers pill * fix power level labels * add option to delete power levels * fix long power level name shrinks power integer * room permissions - WIP * add power level selector component * add room permissions * move user default permission setting to other group * add power permission peek menu * fix weigh of power switch text * hide above for max power in permission switcher * improve beta badge description * render room profile in room settings * add option to edit room profile * make room topic input text area * add option to enable room encryption in room settings * add option to change message history visibility * add option to change join rule * add option for addresses in room settings * close encryption dialog after enabling
This commit is contained in:
parent
00f3df8719
commit
286983c833
73 changed files with 6196 additions and 420 deletions
57
src/app/features/room-settings/general/General.tsx
Normal file
57
src/app/features/room-settings/general/General.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { RoomProfile } from './RoomProfile';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { RoomEncryption } from './RoomEncryption';
|
||||
import { RoomHistoryVisibility } from './RoomHistoryVisibility';
|
||||
import { RoomJoinRules } from './RoomJoinRules';
|
||||
import { RoomLocalAddresses, RoomPublishedAddresses } from './RoomAddress';
|
||||
|
||||
type GeneralProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function General({ requestClose }: GeneralProps) {
|
||||
const room = useRoom();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
General
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<RoomProfile powerLevels={powerLevels} />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Options</Text>
|
||||
<RoomJoinRules powerLevels={powerLevels} />
|
||||
<RoomHistoryVisibility powerLevels={powerLevels} />
|
||||
<RoomEncryption powerLevels={powerLevels} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Addresses</Text>
|
||||
<RoomPublishedAddresses powerLevels={powerLevels} />
|
||||
<RoomLocalAddresses powerLevels={powerLevels} />
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
438
src/app/features/room-settings/general/RoomAddress.tsx
Normal file
438
src/app/features/room-settings/general/RoomAddress.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import {
|
||||
useLocalAliases,
|
||||
usePublishedAliases,
|
||||
usePublishUnpublishAliases,
|
||||
useSetMainAlias,
|
||||
} from '../../../hooks/useRoomAliases';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { CutoutCard } from '../../../components/cutout-card';
|
||||
import { getIdServer } from '../../../../util/matrixUtil';
|
||||
import { replaceSpaceWithDash } from '../../../utils/common';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
|
||||
type RoomPublishedAddressesProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
};
|
||||
|
||||
export function RoomPublishedAddresses({ powerLevels }: RoomPublishedAddressesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||
const canEditCanonical = powerLevelAPI.canSendStateEvent(
|
||||
powerLevels,
|
||||
StateEvent.RoomCanonicalAlias,
|
||||
userPowerLevel
|
||||
);
|
||||
|
||||
const [canonicalAlias, publishedAliases] = usePublishedAliases(room);
|
||||
const setMainAlias = useSetMainAlias(room);
|
||||
|
||||
const [mainState, setMain] = useAsyncCallback(setMainAlias);
|
||||
const loading = mainState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Published Addresses"
|
||||
description={
|
||||
<span>
|
||||
If room access is <b>Public</b>, Published addresses will be used to join by anyone.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
|
||||
{publishedAliases.length === 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">No Addresses</Text>
|
||||
<Text size="T200">
|
||||
To publish an address, it needs to be set as a local address first
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box direction="Column" gap="300">
|
||||
{publishedAliases.map((alias) => (
|
||||
<Box key={alias} as="span" gap="200" alignItems="Center">
|
||||
<Box grow="Yes" gap="Inherit" alignItems="Center">
|
||||
<Text size="T300" truncate>
|
||||
{alias === canonicalAlias ? <b>{alias}</b> : alias}
|
||||
</Text>
|
||||
{alias === canonicalAlias && (
|
||||
<Badge variant="Success" fill="Solid" size="500">
|
||||
<Text size="L400">Main</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{canEditCanonical && (
|
||||
<Box shrink="No" gap="100">
|
||||
{alias === canonicalAlias ? (
|
||||
<Chip
|
||||
variant="Warning"
|
||||
radii="Pill"
|
||||
fill="None"
|
||||
disabled={loading}
|
||||
onClick={() => setMain(undefined)}
|
||||
>
|
||||
<Text size="B300">Unset Main</Text>
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip
|
||||
variant="Success"
|
||||
radii="Pill"
|
||||
fill={canonicalAlias ? 'None' : 'Soft'}
|
||||
disabled={loading}
|
||||
onClick={() => setMain(alias)}
|
||||
>
|
||||
<Text size="B300">Set Main</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{mainState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{(mainState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CutoutCard>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise<void> }) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getSafeUserId();
|
||||
const server = getIdServer(userId);
|
||||
const alive = useAlive();
|
||||
|
||||
const [addState, addAlias] = useAsyncCallback(addLocalAlias);
|
||||
const adding = addState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
if (adding) return;
|
||||
evt.preventDefault();
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const aliasInput = target?.aliasInput as HTMLInputElement | undefined;
|
||||
if (!aliasInput) return;
|
||||
const alias = replaceSpaceWithDash(aliasInput.value.trim());
|
||||
if (!alias) return;
|
||||
|
||||
addAlias(`#${alias}:${server}`).then(() => {
|
||||
if (alive()) {
|
||||
aliasInput.value = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
|
||||
<Box gap="200">
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Input
|
||||
name="aliasInput"
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
radii="300"
|
||||
before={<Text size="T200">#</Text>}
|
||||
readOnly={adding}
|
||||
after={
|
||||
<Text style={{ maxWidth: toRem(300) }} size="T200" truncate>
|
||||
:{server}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Button
|
||||
variant="Success"
|
||||
size="400"
|
||||
radii="300"
|
||||
type="submit"
|
||||
disabled={adding}
|
||||
before={adding && <Spinner size="100" variant="Success" fill="Solid" />}
|
||||
>
|
||||
<Text size="B400">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{addState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(addState.error as MatrixError).httpStatus === 409
|
||||
? 'Address is already in use!'
|
||||
: (addState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalAddressesList({
|
||||
localAliases,
|
||||
removeLocalAlias,
|
||||
canEditCanonical,
|
||||
}: {
|
||||
localAliases: string[];
|
||||
removeLocalAlias: (alias: string) => Promise<void>;
|
||||
canEditCanonical?: boolean;
|
||||
}) {
|
||||
const room = useRoom();
|
||||
const alive = useAlive();
|
||||
|
||||
const [, publishedAliases] = usePublishedAliases(room);
|
||||
const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room);
|
||||
|
||||
const [selectedAliases, setSelectedAliases] = useState<string[]>([]);
|
||||
const selectHasPublished = selectedAliases.find((alias) => publishedAliases.includes(alias));
|
||||
|
||||
const toggleSelect = (alias: string) => {
|
||||
setSelectedAliases((aliases) => {
|
||||
if (aliases.includes(alias)) {
|
||||
return aliases.filter((a) => a !== alias);
|
||||
}
|
||||
const newAliases = [...aliases];
|
||||
newAliases.push(alias);
|
||||
return newAliases;
|
||||
});
|
||||
};
|
||||
const clearSelected = () => {
|
||||
if (alive()) {
|
||||
setSelectedAliases([]);
|
||||
}
|
||||
};
|
||||
|
||||
const [deleteState, deleteAliases] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (aliases: string[]) => {
|
||||
for (let i = 0; i < aliases.length; i += 1) {
|
||||
const alias = aliases[i];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await removeLocalAlias(alias);
|
||||
}
|
||||
},
|
||||
[removeLocalAlias]
|
||||
)
|
||||
);
|
||||
const [publishState, publish] = useAsyncCallback(publishAliases);
|
||||
const [unpublishState, unpublish] = useAsyncCallback(unpublishAliases);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteAliases(selectedAliases).then(clearSelected);
|
||||
};
|
||||
const handlePublish = () => {
|
||||
publish(selectedAliases).then(clearSelected);
|
||||
};
|
||||
const handleUnpublish = () => {
|
||||
unpublish(selectedAliases).then(clearSelected);
|
||||
};
|
||||
|
||||
const loading =
|
||||
deleteState.status === AsyncStatus.Loading ||
|
||||
publishState.status === AsyncStatus.Loading ||
|
||||
unpublishState.status === AsyncStatus.Loading;
|
||||
let error: MatrixError | undefined;
|
||||
if (deleteState.status === AsyncStatus.Error) error = deleteState.error as MatrixError;
|
||||
if (publishState.status === AsyncStatus.Error) error = publishState.error as MatrixError;
|
||||
if (unpublishState.status === AsyncStatus.Error) error = unpublishState.error as MatrixError;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
{selectedAliases.length > 0 && (
|
||||
<Box gap="200">
|
||||
<Box grow="Yes">
|
||||
<Text size="L400">{selectedAliases.length} Selected</Text>
|
||||
</Box>
|
||||
<Box shrink="No" gap="Inherit">
|
||||
{canEditCanonical &&
|
||||
(selectHasPublished ? (
|
||||
<Chip
|
||||
variant="Warning"
|
||||
radii="Pill"
|
||||
disabled={loading}
|
||||
onClick={handleUnpublish}
|
||||
before={
|
||||
unpublishState.status === AsyncStatus.Loading && (
|
||||
<Spinner size="100" variant="Warning" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B300">Unpublish</Text>
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip
|
||||
variant="Success"
|
||||
radii="Pill"
|
||||
disabled={loading}
|
||||
onClick={handlePublish}
|
||||
before={
|
||||
publishState.status === AsyncStatus.Loading && (
|
||||
<Spinner size="100" variant="Success" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B300">Publish</Text>
|
||||
</Chip>
|
||||
))}
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
disabled={loading}
|
||||
onClick={handleDelete}
|
||||
before={
|
||||
deleteState.status === AsyncStatus.Loading && (
|
||||
<Spinner size="100" variant="Critical" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B300">Delete</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{localAliases.map((alias) => {
|
||||
const published = publishedAliases.includes(alias);
|
||||
const selected = selectedAliases.includes(alias);
|
||||
|
||||
return (
|
||||
<Box key={alias} as="span" alignItems="Center" gap="200">
|
||||
<Box shrink="No">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={() => toggleSelect(alias)}
|
||||
size="50"
|
||||
variant="Primary"
|
||||
disabled={loading}
|
||||
/>
|
||||
</Box>
|
||||
<Box grow="Yes">
|
||||
<Text size="T300" truncate>
|
||||
{alias}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" gap="100">
|
||||
{published && (
|
||||
<Badge variant="Success" fill="Soft" size="500">
|
||||
<Text size="L400">Published</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomLocalAddresses({ powerLevels }: { powerLevels: IPowerLevels }) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||
const canEditCanonical = powerLevelAPI.canSendStateEvent(
|
||||
powerLevels,
|
||||
StateEvent.RoomCanonicalAlias,
|
||||
userPowerLevel
|
||||
);
|
||||
|
||||
const [expand, setExpand] = useState(false);
|
||||
|
||||
const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Local Addresses"
|
||||
description="Set local address so users can join through your homeserver."
|
||||
after={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setExpand(!expand)}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
before={
|
||||
<Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
|
||||
}
|
||||
>
|
||||
<Text as="span" size="B300" truncate>
|
||||
{expand ? 'Collapse' : 'Expand'}
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{expand && (
|
||||
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
|
||||
{localAliasesState.status === AsyncStatus.Loading && (
|
||||
<Box gap="100">
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
<Text size="T200">Loading...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{localAliasesState.status === AsyncStatus.Success &&
|
||||
(localAliasesState.data.length === 0 ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">No Addresses</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<LocalAddressesList
|
||||
localAliases={localAliasesState.data}
|
||||
removeLocalAlias={removeLocalAlias}
|
||||
canEditCanonical={canEditCanonical}
|
||||
/>
|
||||
))}
|
||||
{localAliasesState.status === AsyncStatus.Error && (
|
||||
<Box gap="100">
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{localAliasesState.error.message}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</CutoutCard>
|
||||
)}
|
||||
{expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
150
src/app/features/room-settings/general/RoomEncryption.tsx
Normal file
150
src/app/features/room-settings/general/RoomEncryption.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
||||
|
||||
type RoomEncryptionProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
};
|
||||
export function RoomEncryption({ powerLevels }: RoomEncryptionProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||
const canEnable = powerLevelAPI.canSendStateEvent(
|
||||
powerLevels,
|
||||
StateEvent.RoomEncryption,
|
||||
userPowerLevel
|
||||
);
|
||||
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
|
||||
algorithm: string;
|
||||
}>();
|
||||
const enabled = content?.algorithm === ROOM_ENC_ALGO;
|
||||
|
||||
const [enableState, enable] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
|
||||
algorithm: ROOM_ENC_ALGO,
|
||||
});
|
||||
}, [mx, room.roomId])
|
||||
);
|
||||
|
||||
const enabling = enableState.status === AsyncStatus.Loading;
|
||||
|
||||
const [prompt, setPrompt] = useState(false);
|
||||
|
||||
const handleEnable = () => {
|
||||
enable();
|
||||
setPrompt(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Room Encryption"
|
||||
description={
|
||||
enabled
|
||||
? 'Messages in this room are protected by end-to-end encryption.'
|
||||
: 'Once enabled, encryption cannot be disabled!'
|
||||
}
|
||||
after={
|
||||
enabled ? (
|
||||
<Badge size="500" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="L400">Enabled</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={!canEnable}
|
||||
onClick={() => setPrompt(true)}
|
||||
before={enabling && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B300">Enable</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{enableState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(enableState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
{prompt && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setPrompt(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">Enable Encryption</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text priority="400">
|
||||
Are you sure? Once enabled, encryption cannot be disabled!
|
||||
</Text>
|
||||
<Button type="submit" variant="Primary" onClick={handleEnable}>
|
||||
<Text size="B400">Enable E2E Encryption</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
169
src/app/features/room-settings/general/RoomHistoryVisibility.tsx
Normal file
169
src/app/features/room-settings/general/RoomHistoryVisibility.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
|
||||
import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
const useVisibilityStr = () =>
|
||||
useMemo(
|
||||
() => ({
|
||||
[HistoryVisibility.Invited]: 'After Invite',
|
||||
[HistoryVisibility.Joined]: 'After Join',
|
||||
[HistoryVisibility.Shared]: 'All Messages',
|
||||
[HistoryVisibility.WorldReadable]: 'All Messages (Guests)',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const useVisibilityMenu = () =>
|
||||
useMemo(
|
||||
() => [
|
||||
HistoryVisibility.Shared,
|
||||
HistoryVisibility.Invited,
|
||||
HistoryVisibility.Joined,
|
||||
HistoryVisibility.WorldReadable,
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
type RoomHistoryVisibilityProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
};
|
||||
export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||
const canEdit = powerLevelAPI.canSendStateEvent(
|
||||
powerLevels,
|
||||
StateEvent.RoomHistoryVisibility,
|
||||
userPowerLevel
|
||||
);
|
||||
|
||||
const visibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
|
||||
const historyVisibility: HistoryVisibility =
|
||||
visibilityEvent?.getContent<RoomHistoryVisibilityEventContent>().history_visibility ??
|
||||
HistoryVisibility.Shared;
|
||||
const visibilityMenu = useVisibilityMenu();
|
||||
const visibilityStr = useVisibilityStr();
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (visibility: HistoryVisibility) => {
|
||||
const content: RoomHistoryVisibilityEventContent = {
|
||||
history_visibility: visibility,
|
||||
};
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
|
||||
},
|
||||
[mx, room.roomId]
|
||||
)
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange = (visibility: HistoryVisibility) => {
|
||||
submit(visibility);
|
||||
setMenuAnchor(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Message History Visibility"
|
||||
description="Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect."
|
||||
after={
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
{visibilityMenu.map((visibility) => (
|
||||
<MenuItem
|
||||
key={visibility}
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => handleChange(visibility)}
|
||||
aria-pressed={visibility === historyVisibility}
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
{visibilityStr[visibility]}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={!canEdit || submitting}
|
||||
onClick={handleOpenMenu}
|
||||
after={
|
||||
submitting ? (
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Icon size="100" src={Icons.ChevronBottom} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="B300">{visibilityStr[historyVisibility]}</Text>
|
||||
</Button>
|
||||
</PopOut>
|
||||
}
|
||||
>
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(submitState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
124
src/app/features/room-settings/general/RoomJoinRules.tsx
Normal file
124
src/app/features/room-settings/general/RoomJoinRules.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { color, Text } from 'folds';
|
||||
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||
import {
|
||||
JoinRulesSwitcher,
|
||||
useRoomJoinRuleIcon,
|
||||
useRoomJoinRuleLabel,
|
||||
} from '../../../components/JoinRulesSwitcher';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { getStateEvents } from '../../../utils/room';
|
||||
|
||||
type RestrictedRoomAllowContent = {
|
||||
room_id: string;
|
||||
type: RestrictedAllowType;
|
||||
};
|
||||
|
||||
type RoomJoinRulesProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
};
|
||||
export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const roomVersion = parseInt(room.getVersion(), 10);
|
||||
const allowRestricted = roomVersion >= 8;
|
||||
const allowKnock = roomVersion >= 7;
|
||||
const space = useSpaceOptionally();
|
||||
|
||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||
const canEdit = powerLevelAPI.canSendStateEvent(
|
||||
powerLevels,
|
||||
StateEvent.RoomHistoryVisibility,
|
||||
userPowerLevel
|
||||
);
|
||||
|
||||
const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules);
|
||||
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
|
||||
const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
|
||||
|
||||
const joinRules: Array<JoinRule> = useMemo(() => {
|
||||
const r: JoinRule[] = [JoinRule.Invite];
|
||||
if (allowKnock) {
|
||||
r.push(JoinRule.Knock);
|
||||
}
|
||||
if (allowRestricted && space) {
|
||||
r.push(JoinRule.Restricted);
|
||||
}
|
||||
r.push(JoinRule.Public);
|
||||
|
||||
return r;
|
||||
}, [allowRestricted, allowKnock, space]);
|
||||
|
||||
const icons = useRoomJoinRuleIcon();
|
||||
const labels = useRoomJoinRuleLabel();
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (joinRule: JoinRule) => {
|
||||
const allow: RestrictedRoomAllowContent[] = [];
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
|
||||
event.getStateKey()
|
||||
);
|
||||
parents.forEach((parentRoomId) => {
|
||||
if (!parentRoomId) return;
|
||||
allow.push({
|
||||
type: RestrictedAllowType.RoomMembership,
|
||||
room_id: parentRoomId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const c: RoomJoinRulesEventContent = {
|
||||
join_rule: joinRule,
|
||||
};
|
||||
if (allow.length > 0) c.allow = allow;
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
|
||||
},
|
||||
[mx, room]
|
||||
)
|
||||
);
|
||||
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Room Access"
|
||||
description="Change how people can join the room."
|
||||
after={
|
||||
<JoinRulesSwitcher
|
||||
icons={icons}
|
||||
labels={labels}
|
||||
rules={joinRules}
|
||||
value={rule}
|
||||
onChange={submit}
|
||||
disabled={!canEdit || submitting}
|
||||
changing={submitting}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(submitState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
351
src/app/features/room-settings/general/RoomProfile.tsx
Normal file
351
src/app/features/room-settings/general/RoomProfile.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
TextArea,
|
||||
} from 'folds';
|
||||
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import Linkify from 'linkify-react';
|
||||
import classNames from 'classnames';
|
||||
import { JoinRule, MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import {
|
||||
useRoomAvatar,
|
||||
useRoomJoinRule,
|
||||
useRoomName,
|
||||
useRoomTopic,
|
||||
} from '../../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
|
||||
type RoomProfileEditProps = {
|
||||
canEditAvatar: boolean;
|
||||
canEditName: boolean;
|
||||
canEditTopic: boolean;
|
||||
avatar?: string;
|
||||
name?: string;
|
||||
topic?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function RoomProfileEdit({
|
||||
canEditAvatar,
|
||||
canEditName,
|
||||
canEditTopic,
|
||||
avatar,
|
||||
name,
|
||||
topic,
|
||||
onClose,
|
||||
}: RoomProfileEditProps) {
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const joinRule = useRoomJoinRule(room);
|
||||
const [roomAvatar, setRoomAvatar] = useState(avatar);
|
||||
|
||||
const avatarUrl = roomAvatar
|
||||
? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined
|
||||
: undefined;
|
||||
|
||||
const [imageFile, setImageFile] = useState<File>();
|
||||
const avatarFileUrl = useObjectURL(imageFile);
|
||||
const uploadingAvatar = avatarFileUrl ? roomAvatar === avatar : false;
|
||||
const uploadAtom = useMemo(() => {
|
||||
if (imageFile) return createUploadAtom(imageFile);
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
setRoomAvatar(avatar);
|
||||
}, [avatar]);
|
||||
|
||||
const handleUploaded = useCallback((upload: UploadSuccess) => {
|
||||
setRoomAvatar(upload.mxc);
|
||||
}, []);
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (
|
||||
roomAvatarMxc?: string | null,
|
||||
roomName?: string | null,
|
||||
roomTopic?: string | null
|
||||
) => {
|
||||
if (roomAvatarMxc !== undefined) {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
|
||||
url: roomAvatarMxc,
|
||||
});
|
||||
}
|
||||
if (roomName !== undefined) {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
|
||||
}
|
||||
if (roomTopic !== undefined) {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic });
|
||||
}
|
||||
},
|
||||
[mx, room.roomId]
|
||||
)
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (uploadingAvatar) return;
|
||||
|
||||
const target = evt.target as HTMLFormElement | undefined;
|
||||
const nameInput = target?.nameInput as HTMLInputElement | undefined;
|
||||
const topicTextArea = target?.topicTextArea as HTMLTextAreaElement | undefined;
|
||||
if (!nameInput || !topicTextArea) return;
|
||||
|
||||
const roomName = nameInput.value.trim();
|
||||
const roomTopic = topicTextArea.value.trim();
|
||||
|
||||
submit(
|
||||
roomAvatar === avatar ? undefined : roomAvatar || null,
|
||||
roomName === name ? undefined : roomName || null,
|
||||
roomTopic === topic ? undefined : roomTopic || null
|
||||
).then(() => {
|
||||
if (alive()) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
|
||||
<Box gap="400">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="L400">Avatar</Text>
|
||||
{uploadAtom ? (
|
||||
<Box gap="200" direction="Column">
|
||||
<CompactUploadCardRenderer
|
||||
uploadAtom={uploadAtom}
|
||||
onRemove={handleRemoveUpload}
|
||||
onComplete={handleUploaded}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={!canEditAvatar || submitting}
|
||||
onClick={() => pickFile('image/*')}
|
||||
>
|
||||
<Text size="B300">Upload</Text>
|
||||
</Button>
|
||||
{!roomAvatar && avatar && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={!canEditAvatar || submitting}
|
||||
onClick={() => setRoomAvatar(avatar)}
|
||||
>
|
||||
<Text size="B300">Reset</Text>
|
||||
</Button>
|
||||
)}
|
||||
{roomAvatar && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={!canEditAvatar || submitting}
|
||||
onClick={() => setRoomAvatar(undefined)}
|
||||
>
|
||||
<Text size="B300">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="400" joinRule={joinRule?.join_rule ?? JoinRule.Invite} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Inherit" gap="100">
|
||||
<Text size="L400">Name</Text>
|
||||
<Input
|
||||
name="nameInput"
|
||||
defaultValue={name}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={!canEditName || submitting}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Inherit" gap="100">
|
||||
<Text size="L400">Topic</Text>
|
||||
<TextArea
|
||||
name="topicTextArea"
|
||||
defaultValue={topic}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={!canEditTopic || submitting}
|
||||
/>
|
||||
</Box>
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{(submitState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
<Box gap="300">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="Success"
|
||||
size="300"
|
||||
radii="300"
|
||||
disabled={uploadingAvatar || submitting}
|
||||
before={submitting && <Spinner size="100" variant="Success" fill="Solid" />}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
<Button
|
||||
type="reset"
|
||||
onClick={onClose}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type RoomProfileProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
};
|
||||
export function RoomProfile({ powerLevels }: RoomProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = useRoom();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
|
||||
const userPowerLevel = getPowerLevel(mx.getSafeUserId());
|
||||
|
||||
const avatar = useRoomAvatar(room, directs.has(room.roomId));
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const joinRule = useRoomJoinRule(room);
|
||||
|
||||
const canEditAvatar = canSendStateEvent(StateEvent.RoomAvatar, userPowerLevel);
|
||||
const canEditName = canSendStateEvent(StateEvent.RoomName, userPowerLevel);
|
||||
const canEditTopic = canSendStateEvent(StateEvent.RoomTopic, userPowerLevel);
|
||||
const canEdit = canEditAvatar || canEditName || canEditTopic;
|
||||
|
||||
const avatarUrl = avatar
|
||||
? mxcUrlToHttp(mx, avatar, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
const [edit, setEdit] = useState(false);
|
||||
|
||||
const handleCloseEdit = useCallback(() => setEdit(false), []);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Profile</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{edit ? (
|
||||
<RoomProfileEdit
|
||||
canEditAvatar={canEditAvatar}
|
||||
canEditName={canEditName}
|
||||
canEditTopic={canEditTopic}
|
||||
avatar={avatar}
|
||||
name={name}
|
||||
topic={topic}
|
||||
onClose={handleCloseEdit}
|
||||
/>
|
||||
) : (
|
||||
<Box gap="400">
|
||||
<Box grow="Yes" direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text className={BreakWord} size="H5">
|
||||
{name ?? 'Unknown'}
|
||||
</Text>
|
||||
{topic && (
|
||||
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
|
||||
<Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{canEdit && (
|
||||
<Box gap="200">
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.Pencil} />}
|
||||
onClick={() => setEdit(true)}
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">Edit</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Avatar size="500" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="400" joinRule={joinRule?.join_rule ?? JoinRule.Invite} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
src/app/features/room-settings/general/index.ts
Normal file
1
src/app/features/room-settings/general/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './General';
|
||||
Loading…
Add table
Add a link
Reference in a new issue