"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
+ "react-colorful": "5.6.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0",
"react": ">=15"
}
},
+ "node_modules/react-colorful": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
+ "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
+ "react-colorful": "5.6.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0",
--- /dev/null
+import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Box,
+ Text,
+ Icon,
+ Icons,
+ IconButton,
+ Input,
+ Button,
+ TextArea as TextAreaComponent,
+ color,
+ Spinner,
+ Chip,
+ Scroll,
+ config,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { Cursor } from '../plugins/text-area';
+import { syntaxErrorPosition } from '../utils/dom';
+import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
+import { Page, PageHeader } from './page';
+import { useAlive } from '../hooks/useAlive';
+import { SequenceCard } from './sequence-card';
+import { TextViewerContent } from './text-viewer';
+import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
+
+const EDITOR_INTENT_SPACE_COUNT = 2;
+
+export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
+
+type AccountDataInfo = {
+ type: string;
+ content: object;
+};
+
+type AccountDataEditProps = {
+ type: string;
+ defaultContent: string;
+ submitChange: AccountDataSubmitCallback;
+ onCancel: () => void;
+ onSave: (info: AccountDataInfo) => void;
+};
+function AccountDataEdit({
+ type,
+ defaultContent,
+ submitChange,
+ onCancel,
+ onSave,
+}: AccountDataEditProps) {
+ const alive = useAlive();
+
+ const textAreaRef = useRef<HTMLTextAreaElement>(null);
+ const [jsonError, setJSONError] = useState<SyntaxError>();
+
+ const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
+ textAreaRef,
+ EDITOR_INTENT_SPACE_COUNT
+ );
+
+ const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (submitting) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const typeInput = target?.typeInput as HTMLInputElement | undefined;
+ const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+ if (!typeInput || !contentTextArea) return;
+
+ const typeStr = typeInput.value.trim();
+ const contentStr = contentTextArea.value.trim();
+
+ let parsedContent: object;
+ try {
+ parsedContent = JSON.parse(contentStr);
+ } catch (e) {
+ setJSONError(e as SyntaxError);
+ return;
+ }
+ setJSONError(undefined);
+
+ if (
+ !typeStr ||
+ parsedContent === null ||
+ defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
+ ) {
+ return;
+ }
+
+ submit(typeStr, parsedContent).then(() => {
+ if (alive()) {
+ onSave({
+ type: typeStr,
+ content: parsedContent,
+ });
+ }
+ });
+ };
+
+ useEffect(() => {
+ if (jsonError) {
+ const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
+ const cursor = new Cursor(errorPosition, errorPosition, 'none');
+ operations.select(cursor);
+ getTarget()?.focus();
+ }
+ }, [jsonError, operations, getTarget]);
+
+ return (
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ grow="Yes"
+ style={{
+ padding: config.space.S400,
+ }}
+ direction="Column"
+ gap="400"
+ aria-disabled={submitting}
+ >
+ <Box shrink="No" direction="Column" gap="100">
+ <Text size="L400">Account Data</Text>
+ <Box gap="300">
+ <Box grow="Yes" direction="Column">
+ <Input
+ variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
+ name="typeInput"
+ size="400"
+ radii="300"
+ readOnly={type.length > 0 || submitting}
+ defaultValue={type}
+ required
+ />
+ </Box>
+ <Button
+ variant="Success"
+ size="400"
+ radii="300"
+ type="submit"
+ disabled={submitting}
+ before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+ >
+ <Text size="B400">Save</Text>
+ </Button>
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="400"
+ radii="300"
+ type="button"
+ onClick={onCancel}
+ disabled={submitting}
+ >
+ <Text size="B400">Cancel</Text>
+ </Button>
+ </Box>
+
+ {submitState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>{submitState.error.message}</b>
+ </Text>
+ )}
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <Box shrink="No">
+ <Text size="L400">JSON Content</Text>
+ </Box>
+ <TextAreaComponent
+ ref={textAreaRef}
+ name="contentTextArea"
+ style={{
+ fontFamily: 'monospace',
+ }}
+ onKeyDown={handleKeyDown}
+ defaultValue={defaultContent}
+ resize="None"
+ spellCheck="false"
+ required
+ readOnly={submitting}
+ />
+ {jsonError && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>
+ {jsonError.name}: {jsonError.message}
+ </b>
+ </Text>
+ )}
+ </Box>
+ </Box>
+ );
+}
+
+type AccountDataViewProps = {
+ type: string;
+ defaultContent: string;
+ onEdit: () => void;
+};
+function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
+ return (
+ <Box
+ direction="Column"
+ style={{
+ padding: config.space.S400,
+ }}
+ gap="400"
+ >
+ <Box shrink="No" gap="300" alignItems="End">
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">Account Data</Text>
+ <Input
+ variant="SurfaceVariant"
+ size="400"
+ radii="300"
+ readOnly
+ defaultValue={type}
+ required
+ />
+ </Box>
+ <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
+ <Text size="B400">Edit</Text>
+ </Button>
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">JSON Content</Text>
+ <SequenceCard variant="SurfaceVariant">
+ <Scroll visibility="Always" size="300" hideTrack>
+ <TextViewerContent
+ size="T300"
+ style={{
+ padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
+ }}
+ text={defaultContent}
+ langName="JSON"
+ />
+ </Scroll>
+ </SequenceCard>
+ </Box>
+ </Box>
+ );
+}
+
+export type AccountDataEditorProps = {
+ type?: string;
+ content?: object;
+ submitChange: AccountDataSubmitCallback;
+ requestClose: () => void;
+};
+
+export function AccountDataEditor({
+ type,
+ content,
+ submitChange,
+ requestClose,
+}: AccountDataEditorProps) {
+ const [data, setData] = useState<AccountDataInfo>({
+ type: type ?? '',
+ content: content ?? {},
+ });
+
+ const [edit, setEdit] = useState(!type);
+
+ const closeEdit = useCallback(() => {
+ if (!type) {
+ requestClose();
+ return;
+ }
+ setEdit(false);
+ }, [type, requestClose]);
+
+ const handleSave = useCallback((info: AccountDataInfo) => {
+ setData(info);
+ setEdit(false);
+ }, []);
+
+ const contentJSONStr = useMemo(
+ () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
+ [data.content]
+ );
+
+ return (
+ <Page>
+ <PageHeader outlined={false} balance>
+ <Box alignItems="Center" grow="Yes" gap="200">
+ <Box alignItems="Inherit" grow="Yes" gap="200">
+ <Chip
+ size="500"
+ radii="Pill"
+ onClick={requestClose}
+ before={<Icon size="100" src={Icons.ArrowLeft} />}
+ >
+ <Text size="T300">Developer Tools</Text>
+ </Chip>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes" direction="Column">
+ {edit ? (
+ <AccountDataEdit
+ type={data.type}
+ defaultContent={contentJSONStr}
+ submitChange={submitChange}
+ onCancel={closeEdit}
+ onSave={handleSave}
+ />
+ ) : (
+ <AccountDataView
+ type={data.type}
+ defaultContent={contentJSONStr}
+ onEdit={() => setEdit(true)}
+ />
+ )}
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React from 'react';
+import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds';
+
+export function BetaNoticeBadge() {
+ return (
+ <TooltipProvider
+ position="Right"
+ align="Center"
+ tooltip={
+ <Tooltip style={{ maxWidth: toRem(200) }}>
+ <Box direction="Column">
+ <Text size="L400">Notice</Text>
+ <Text size="T200">This feature is under testing and may change over time.</Text>
+ </Box>
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <Badge size="500" tabIndex={0} ref={triggerRef} variant="Primary" fill="Solid">
+ <Text size="L400">Beta</Text>
+ </Badge>
+ )}
+ </TooltipProvider>
+ );
+}
--- /dev/null
+import FocusTrap from 'focus-trap-react';
+import { Box, Button, config, Menu, PopOut, RectCords, Text } from 'folds';
+import React, { MouseEventHandler, ReactNode, useState } from 'react';
+import { stopPropagation } from '../utils/keyboard';
+
+type HexColorPickerPopOutProps = {
+ children: (onOpen: MouseEventHandler<HTMLElement>, opened: boolean) => ReactNode;
+ picker: ReactNode;
+ onRemove?: () => void;
+};
+export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) {
+ const [cords, setCords] = useState<RectCords>();
+
+ const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="Center"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ onDeactivate: () => setCords(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu
+ style={{
+ padding: config.space.S100,
+ borderRadius: config.radii.R500,
+ overflow: 'initial',
+ }}
+ >
+ <Box direction="Column" gap="200">
+ {picker}
+ {onRemove && (
+ <Button
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ radii="400"
+ onClick={() => onRemove()}
+ >
+ <Text size="B300">Remove</Text>
+ </Button>
+ )}
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {children(handleOpen, !!cords)}
+ </PopOut>
+ );
+}
--- /dev/null
+import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+ config,
+ Box,
+ MenuItem,
+ Text,
+ Icon,
+ Icons,
+ IconSrc,
+ RectCords,
+ PopOut,
+ Menu,
+ Button,
+ Spinner,
+} from 'folds';
+import { JoinRule } from 'matrix-js-sdk';
+import FocusTrap from 'focus-trap-react';
+import { stopPropagation } from '../utils/keyboard';
+
+type JoinRuleIcons = Record<JoinRule, IconSrc>;
+export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
+ useMemo(
+ () => ({
+ [JoinRule.Invite]: Icons.HashLock,
+ [JoinRule.Knock]: Icons.HashLock,
+ [JoinRule.Restricted]: Icons.Hash,
+ [JoinRule.Public]: Icons.HashGlobe,
+ [JoinRule.Private]: Icons.HashLock,
+ }),
+ []
+ );
+
+type JoinRuleLabels = Record<JoinRule, string>;
+export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
+ useMemo(
+ () => ({
+ [JoinRule.Invite]: 'Invite Only',
+ [JoinRule.Knock]: 'Knock & Invite',
+ [JoinRule.Restricted]: 'Space Members',
+ [JoinRule.Public]: 'Public',
+ [JoinRule.Private]: 'Invite Only',
+ }),
+ []
+ );
+
+type JoinRulesSwitcherProps<T extends JoinRule[]> = {
+ icons: JoinRuleIcons;
+ labels: JoinRuleLabels;
+ rules: T;
+ value: T[number];
+ onChange: (value: T[number]) => void;
+ disabled?: boolean;
+ changing?: boolean;
+};
+export function JoinRulesSwitcher<T extends JoinRule[]>({
+ icons,
+ labels,
+ rules,
+ value,
+ onChange,
+ disabled,
+ changing,
+}: JoinRulesSwitcherProps<T>) {
+ const [cords, setCords] = useState<RectCords>();
+
+ const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleChange = useCallback(
+ (selectedRule: JoinRule) => {
+ setCords(undefined);
+ onChange(selectedRule);
+ },
+ [onChange]
+ );
+
+ return (
+ <PopOut
+ anchor={cords}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu>
+ <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
+ {rules.map((rule) => (
+ <MenuItem
+ key={rule}
+ size="300"
+ variant="Surface"
+ radii="300"
+ aria-pressed={value === rule}
+ onClick={() => handleChange(rule)}
+ before={<Icon size="100" src={icons[rule]} />}
+ disabled={disabled}
+ >
+ <Box grow="Yes">
+ <Text size="T300">{labels[rule]}</Text>
+ </Box>
+ </MenuItem>
+ ))}
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ <Button
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ outlined
+ before={<Icon size="100" src={icons[value]} />}
+ after={
+ changing ? (
+ <Spinner size="100" variant="Secondary" fill="Soft" />
+ ) : (
+ <Icon size="100" src={Icons.ChevronBottom} />
+ )
+ }
+ onClick={handleOpenMenu}
+ disabled={disabled}
+ >
+ <Text size="B300">{labels[value]}</Text>
+ </Button>
+ </PopOut>
+ );
+}
--- /dev/null
+import FocusTrap from 'focus-trap-react';
+import React from 'react';
+import { config, Menu, MenuItem, Text } from 'folds';
+import { stopPropagation } from '../utils/keyboard';
+import { useMemberSortMenu } from '../hooks/useMemberSort';
+
+type MemberSortMenuProps = {
+ requestClose: () => void;
+ selected: number;
+ onSelect: (index: number) => void;
+};
+export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortMenuProps) {
+ const memberSortMenu = useMemberSortMenu();
+
+ return (
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: requestClose,
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ {memberSortMenu.map((menuItem, index) => (
+ <MenuItem
+ key={menuItem.name}
+ variant="Surface"
+ aria-pressed={selected === index}
+ size="300"
+ radii="300"
+ onClick={() => {
+ onSelect(index);
+ requestClose();
+ }}
+ >
+ <Text size="T300">{menuItem.name}</Text>
+ </MenuItem>
+ ))}
+ </Menu>
+ </FocusTrap>
+ );
+}
--- /dev/null
+import FocusTrap from 'focus-trap-react';
+import React from 'react';
+import { config, Menu, MenuItem, Text } from 'folds';
+import { stopPropagation } from '../utils/keyboard';
+import { useMembershipFilterMenu } from '../hooks/useMemberFilter';
+
+type MembershipFilterMenuProps = {
+ requestClose: () => void;
+ selected: number;
+ onSelect: (index: number) => void;
+};
+export function MembershipFilterMenu({
+ selected,
+ onSelect,
+ requestClose,
+}: MembershipFilterMenuProps) {
+ const membershipFilterMenu = useMembershipFilterMenu();
+
+ return (
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: requestClose,
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ {membershipFilterMenu.map((menuItem, index) => (
+ <MenuItem
+ key={menuItem.name}
+ variant="Surface"
+ aria-pressed={selected === index}
+ size="300"
+ radii="300"
+ onClick={() => {
+ onSelect(index);
+ requestClose();
+ }}
+ >
+ <Text size="T300">{menuItem.name}</Text>
+ </MenuItem>
+ ))}
+ </Menu>
+ </FocusTrap>
+ );
+}
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const CutoutCard = style({
+ borderRadius: config.radii.R300,
+ borderWidth: config.borderWidth.B300,
+ overflow: 'hidden',
+});
--- /dev/null
+import { as, ContainerColor as TContainerColor } from 'folds';
+import React from 'react';
+import classNames from 'classnames';
+import { ContainerColor } from '../../styles/ContainerColor.css';
+import * as css from './CutoutCard.css';
+
+export const CutoutCard = as<'div', { variant?: TContainerColor }>(
+ ({ as: AsCutoutCard = 'div', className, variant = 'Surface', ...props }, ref) => (
+ <AsCutoutCard
+ className={classNames(ContainerColor({ variant }), css.CutoutCard, className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
--- /dev/null
+export * from './CutoutCard';
onCustomEmojiSelect,
onStickerSelect,
allowTextCustomEmoji,
+ addToRecentEmoji = true,
}: {
tab?: EmojiBoardTab;
onTabChange?: (tab: EmojiBoardTab) => void;
onCustomEmojiSelect?: (mxc: string, shortcode: string) => void;
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
allowTextCustomEmoji?: boolean;
+ addToRecentEmoji?: boolean;
}) {
const emojiTab = tab === EmojiBoardTab.Emoji;
const stickerTab = tab === EmojiBoardTab.Sticker;
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey) {
- addRecentEmoji(mx, emojiInfo.data);
+ if (addToRecentEmoji) {
+ addRecentEmoji(mx, emojiInfo.data);
+ }
requestClose();
}
}
--- /dev/null
+import React, { ReactNode } from 'react';
+import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
+import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { UserAvatar } from '../user-avatar';
+import * as css from './style.css';
+
+const getName = (room: Room, member: RoomMember) =>
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+type MemberTileProps = {
+ mx: MatrixClient;
+ room: Room;
+ member: RoomMember;
+ useAuthentication: boolean;
+ after?: ReactNode;
+};
+export const MemberTile = as<'button', MemberTileProps>(
+ ({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => {
+ const name = getName(room, member);
+ const username = getMxIdLocalPart(member.userId);
+
+ const avatarMxcUrl = member.getMxcAvatarUrl();
+ const avatarUrl = avatarMxcUrl
+ ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
+ : undefined;
+
+ return (
+ <AsMemberTile className={css.MemberTile} {...props} ref={ref}>
+ <Avatar size="300" radii="400">
+ <UserAvatar
+ userId={member.userId}
+ src={avatarUrl ?? undefined}
+ alt={name}
+ renderFallback={() => <Icon size="300" src={Icons.User} filled />}
+ />
+ </Avatar>
+ <Box grow="Yes" as="span" direction="Column">
+ <Text as="span" size="T300" truncate>
+ <b>{name}</b>
+ </Text>
+ <Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
+ <Text as="span" size="T200" priority="300" truncate>
+ {username}
+ </Text>
+ </Box>
+ </Box>
+ {after}
+ </AsMemberTile>
+ );
+ }
+);
--- /dev/null
+export * from './MemberTile';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { color, config, DefaultReset, Disabled, FocusOutline } from 'folds';
+
+export const MemberTile = style([
+ DefaultReset,
+ {
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ gap: config.space.S200,
+
+ padding: config.space.S100,
+ borderRadius: config.radii.R500,
+
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ '&[aria-pressed=true]': {
+ backgroundColor: color.Surface.ContainerActive,
+ },
+ 'button&:hover, &:focus-visible': {
+ backgroundColor: color.Surface.ContainerHover,
+ },
+ 'button&:active': {
+ backgroundColor: color.Surface.ContainerActive,
+ },
+ },
+ },
+ FocusOutline,
+ Disabled,
+]);
--- /dev/null
+import React from 'react';
+import { as } from 'folds';
+import classNames from 'classnames';
+import * as css from './style.css';
+
+type PowerColorBadgeProps = {
+ color?: string;
+};
+export const PowerColorBadge = as<'span', PowerColorBadgeProps>(
+ ({ as: AsPowerColorBadge = 'span', color, className, style, ...props }, ref) => (
+ <AsPowerColorBadge
+ className={classNames(css.PowerColorBadge, className)}
+ style={{
+ backgroundColor: color,
+ ...style,
+ }}
+ {...props}
+ ref={ref}
+ />
+ )
+);
--- /dev/null
+import React from 'react';
+import * as css from './style.css';
+import { JUMBO_EMOJI_REG } from '../../utils/regex';
+
+type PowerIconProps = css.PowerIconVariants & {
+ iconSrc: string;
+ name?: string;
+};
+export function PowerIcon({ size, iconSrc, name }: PowerIconProps) {
+ return JUMBO_EMOJI_REG.test(iconSrc) ? (
+ <span className={css.PowerIcon({ size })}>{iconSrc}</span>
+ ) : (
+ <img className={css.PowerIcon({ size })} src={iconSrc} alt={name} />
+ );
+}
--- /dev/null
+import React, { forwardRef, MouseEventHandler, ReactNode, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import { Box, config, Menu, MenuItem, PopOut, Scroll, Text, toRem, RectCords } from 'folds';
+import { getPowers, PowerLevelTags } from '../../hooks/usePowerLevelTags';
+import { PowerColorBadge } from './PowerColorBadge';
+import { stopPropagation } from '../../utils/keyboard';
+
+type PowerSelectorProps = {
+ powerLevelTags: PowerLevelTags;
+ value: number;
+ onChange: (value: number) => void;
+};
+export const PowerSelector = forwardRef<HTMLDivElement, PowerSelectorProps>(
+ ({ powerLevelTags, value, onChange }, ref) => (
+ <Menu
+ ref={ref}
+ style={{
+ maxHeight: '75vh',
+ maxWidth: toRem(300),
+ display: 'flex',
+ }}
+ >
+ <Box grow="Yes">
+ <Scroll size="0" hideTrack visibility="Hover">
+ <div style={{ padding: config.space.S100 }}>
+ {getPowers(powerLevelTags).map((power) => {
+ const selected = value === power;
+ const tag = powerLevelTags[power];
+
+ return (
+ <MenuItem
+ key={power}
+ aria-pressed={selected}
+ radii="300"
+ onClick={selected ? undefined : () => onChange(power)}
+ before={<PowerColorBadge color={tag.color} />}
+ after={<Text size="L400">{power}</Text>}
+ >
+ <Text style={{ flexGrow: 1 }} size="B400" truncate>
+ {tag.name}
+ </Text>
+ </MenuItem>
+ );
+ })}
+ </div>
+ </Scroll>
+ </Box>
+ </Menu>
+ )
+);
+
+type PowerSwitcherProps = PowerSelectorProps & {
+ children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
+};
+export function PowerSwitcher({ powerLevelTags, value, onChange, children }: PowerSwitcherProps) {
+ const [menuCords, setMenuCords] = useState<RectCords>();
+
+ const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <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,
+ }}
+ >
+ <PowerSelector
+ powerLevelTags={powerLevelTags}
+ value={value}
+ onChange={(v) => {
+ onChange(v);
+ setMenuCords(undefined);
+ }}
+ />
+ </FocusTrap>
+ }
+ >
+ {children(handleOpen, !!menuCords)}
+ </PopOut>
+ );
+}
--- /dev/null
+export * from './PowerColorBadge';
+export * from './PowerIcon';
+export * from './PowerSelector';
--- /dev/null
+import { createVar, style } from '@vanilla-extract/css';
+import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
+import { color, config, DefaultReset, toRem } from 'folds';
+
+export const PowerColorBadge = style({
+ display: 'inline-block',
+ flexShrink: 0,
+ width: toRem(16),
+ height: toRem(16),
+ backgroundColor: color.Surface.OnContainer,
+ borderRadius: config.radii.Pill,
+ border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+});
+
+const PowerIconSize = createVar();
+export const PowerIcon = recipe({
+ base: [
+ DefaultReset,
+ {
+ display: 'inline-flex',
+ height: PowerIconSize,
+ minWidth: PowerIconSize,
+ fontSize: PowerIconSize,
+ lineHeight: PowerIconSize,
+ borderRadius: config.radii.R300,
+ cursor: 'default',
+ },
+ ],
+ variants: {
+ size: {
+ '50': {
+ vars: {
+ [PowerIconSize]: config.size.X50,
+ },
+ },
+ '100': {
+ vars: {
+ [PowerIconSize]: config.size.X100,
+ },
+ },
+ '200': {
+ vars: {
+ [PowerIconSize]: config.size.X200,
+ },
+ },
+ '300': {
+ vars: {
+ [PowerIconSize]: config.size.X300,
+ },
+ },
+ '400': {
+ vars: {
+ [PowerIconSize]: config.size.X400,
+ },
+ },
+ '500': {
+ vars: {
+ [PowerIconSize]: config.size.X500,
+ },
+ },
+ '600': {
+ vars: {
+ [PowerIconSize]: config.size.X600,
+ },
+ },
+ },
+ },
+ defaultVariants: {
+ size: '400',
+ },
+});
+
+export type PowerIconVariants = RecipeVariants<typeof PowerIcon>;
--- /dev/null
+import React from 'react';
+import { as, Badge, Text } from 'folds';
+
+export const ServerBadge = as<
+ 'div',
+ {
+ server: string;
+ fill?: 'Solid' | 'None';
+ }
+>(({ as: AsServerBadge = 'div', fill, server, ...props }, ref) => (
+ <Badge as={AsServerBadge} variant="Secondary" fill={fill} radii="300" {...props} ref={ref}>
+ <Text as="span" size="L400" truncate>
+ {server}
+ </Text>
+ </Badge>
+));
--- /dev/null
+export * from './ServerBadge';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
-import {
- openInviteUser,
- openSpaceSettings,
- toggleRoomSettings,
-} from '../../../client/action/navigation';
+import { openInviteUser, openSpaceSettings } from '../../../client/action/navigation';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { stopPropagation } from '../../utils/keyboard';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
+import { useSpaceOptionally } from '../../hooks/useSpace';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
requestClose: () => void;
disabled?: boolean;
}) {
+ const openRoomSettings = useOpenRoomSettings();
+ const space = useSpaceOptionally();
+
const handleSettings = () => {
if ('space' in item) {
openSpaceSettings(item.roomId);
} else {
- toggleRoomSettings(item.roomId);
+ openRoomSettings(item.roomId, space?.roomId);
}
requestClose();
};
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { copyToClipboard } from '../../utils/dom';
import { markAsRead } from '../../../client/action/notifications';
-import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
+import { useSpaceOptionally } from '../../hooks/useSpace';
type RoomNavItemMenuProps = {
room: Room;
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
+ const openRoomSettings = useOpenRoomSettings();
+ const space = useSpaceOptionally();
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
};
const handleRoomSettings = () => {
- toggleRoomSettings(room.roomId);
+ openRoomSettings(room.roomId, space?.roomId);
requestClose();
};
--- /dev/null
+import React, { useMemo, useState } from 'react';
+import { useAtomValue } from 'jotai';
+import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
+import { JoinRule } from 'matrix-js-sdk';
+import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
+import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { mxcUrlToHttp } from '../../utils/matrix';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
+import { mDirectAtom } from '../../state/mDirectList';
+import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
+import { General } from './general';
+import { Members } from './members';
+import { EmojisStickers } from './emojis-stickers';
+import { Permissions } from './permissions';
+import { RoomSettingsPage } from '../../state/roomSettings';
+import { useRoom } from '../../hooks/useRoom';
+import { DeveloperTools } from './developer-tools';
+
+type RoomSettingsMenuItem = {
+ page: RoomSettingsPage;
+ name: string;
+ icon: IconSrc;
+};
+
+const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] =>
+ useMemo(
+ () => [
+ {
+ page: RoomSettingsPage.GeneralPage,
+ name: 'General',
+ icon: Icons.Setting,
+ },
+ {
+ page: RoomSettingsPage.MembersPage,
+ name: 'Members',
+ icon: Icons.User,
+ },
+ {
+ page: RoomSettingsPage.PermissionsPage,
+ name: 'Permissions',
+ icon: Icons.Lock,
+ },
+ {
+ page: RoomSettingsPage.EmojisStickersPage,
+ name: 'Emojis & Stickers',
+ icon: Icons.Smile,
+ },
+ {
+ page: RoomSettingsPage.DeveloperToolsPage,
+ name: 'Developer Tools',
+ icon: Icons.Terminal,
+ },
+ ],
+ []
+ );
+
+type RoomSettingsProps = {
+ initialPage?: RoomSettingsPage;
+ requestClose: () => void;
+};
+export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
+ const room = useRoom();
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const mDirects = useAtomValue(mDirectAtom);
+
+ const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
+ const roomName = useRoomName(room);
+ const joinRuleContent = useRoomJoinRule(room);
+
+ const avatarUrl = roomAvatar
+ ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
+ : undefined;
+
+ const screenSize = useScreenSizeContext();
+ const [activePage, setActivePage] = useState<RoomSettingsPage | undefined>(() => {
+ if (initialPage) return initialPage;
+ return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
+ });
+ const menuItems = useRoomSettingsMenuItems();
+
+ const handlePageRequestClose = () => {
+ if (screenSize === ScreenSize.Mobile) {
+ setActivePage(undefined);
+ return;
+ }
+ requestClose();
+ };
+
+ return (
+ <PageRoot
+ nav={
+ screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
+ <PageNav size="300">
+ <PageNavHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Avatar size="200" radii="300">
+ <RoomAvatar
+ roomId={room.roomId}
+ src={avatarUrl}
+ alt={roomName}
+ renderFallback={() => (
+ <RoomIcon
+ size="50"
+ joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
+ filled
+ />
+ )}
+ />
+ </Avatar>
+ <Text size="H4" truncate>
+ {roomName}
+ </Text>
+ </Box>
+ <Box shrink="No">
+ {screenSize === ScreenSize.Mobile && (
+ <IconButton onClick={requestClose} variant="Background">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ )}
+ </Box>
+ </PageNavHeader>
+ <Box grow="Yes" direction="Column">
+ <PageNavContent>
+ <div style={{ flexGrow: 1 }}>
+ {menuItems.map((item) => (
+ <MenuItem
+ key={item.name}
+ variant="Background"
+ radii="400"
+ aria-pressed={activePage === item.page}
+ before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
+ onClick={() => setActivePage(item.page)}
+ >
+ <Text
+ style={{
+ fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
+ }}
+ size="T300"
+ truncate
+ >
+ {item.name}
+ </Text>
+ </MenuItem>
+ ))}
+ </div>
+ </PageNavContent>
+ </Box>
+ </PageNav>
+ )
+ }
+ >
+ {activePage === RoomSettingsPage.GeneralPage && (
+ <General requestClose={handlePageRequestClose} />
+ )}
+ {activePage === RoomSettingsPage.MembersPage && (
+ <Members requestClose={handlePageRequestClose} />
+ )}
+ {activePage === RoomSettingsPage.PermissionsPage && (
+ <Permissions requestClose={handlePageRequestClose} />
+ )}
+ {activePage === RoomSettingsPage.EmojisStickersPage && (
+ <EmojisStickers requestClose={handlePageRequestClose} />
+ )}
+ {activePage === RoomSettingsPage.DeveloperToolsPage && (
+ <DeveloperTools requestClose={handlePageRequestClose} />
+ )}
+ </PageRoot>
+ );
+}
--- /dev/null
+import React from 'react';
+import { RoomSettings } from './RoomSettings';
+import { Modal500 } from '../../components/Modal500';
+import { useCloseRoomSettings, useRoomSettingsState } from '../../state/hooks/roomSettings';
+import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
+import { RoomSettingsState } from '../../state/roomSettings';
+import { RoomProvider } from '../../hooks/useRoom';
+import { SpaceProvider } from '../../hooks/useSpace';
+
+type RenderSettingsProps = {
+ state: RoomSettingsState;
+};
+function RenderSettings({ state }: RenderSettingsProps) {
+ const { roomId, spaceId, page } = state;
+ const closeSettings = useCloseRoomSettings();
+ const allJoinedRooms = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allJoinedRooms);
+ const room = getRoom(roomId);
+ const space = spaceId ? getRoom(spaceId) : undefined;
+
+ if (!room) return null;
+
+ return (
+ <Modal500 requestClose={closeSettings}>
+ <SpaceProvider value={space ?? null}>
+ <RoomProvider value={room}>
+ <RoomSettings initialPage={page} requestClose={closeSettings} />
+ </RoomProvider>
+ </SpaceProvider>
+ </Modal500>
+ );
+}
+
+export function RoomSettingsRenderer() {
+ const state = useRoomSettingsState();
+
+ if (!state) return null;
+ return <RenderSettings state={state} />;
+}
--- /dev/null
+import React, { useCallback, useState } from 'react';
+import {
+ Box,
+ Text,
+ IconButton,
+ Icon,
+ Icons,
+ Scroll,
+ Switch,
+ Button,
+ MenuItem,
+ config,
+ color,
+} from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { copyToClipboard } from '../../../utils/dom';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomState } from '../../../hooks/useRoomState';
+import { StateEventEditor, StateEventInfo } from './StateEventEditor';
+import { SendRoomEvent } from './SendRoomEvent';
+import { useRoomAccountData } from '../../../hooks/useRoomAccountData';
+import { CutoutCard } from '../../../components/cutout-card';
+import {
+ AccountDataEditor,
+ AccountDataSubmitCallback,
+} from '../../../components/AccountDataEditor';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+
+type DeveloperToolsProps = {
+ requestClose: () => void;
+};
+export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
+ const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
+ const mx = useMatrixClient();
+ const room = useRoom();
+
+ const roomState = useRoomState(room);
+ const accountData = useRoomAccountData(room);
+
+ const [expandState, setExpandState] = useState(false);
+ const [expandStateType, setExpandStateType] = useState<string>();
+ const [openStateEvent, setOpenStateEvent] = useState<StateEventInfo>();
+ const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>();
+
+ const [expandAccountData, setExpandAccountData] = useState(false);
+ const [accountDataType, setAccountDataType] = useState<string | null>();
+
+ const handleClose = useCallback(() => {
+ setOpenStateEvent(undefined);
+ setComposeEvent(undefined);
+ setAccountDataType(undefined);
+ }, []);
+
+ const submitAccountData: AccountDataSubmitCallback = useCallback(
+ async (type, content) => {
+ await mx.setRoomAccountData(room.roomId, type, content);
+ },
+ [mx, room.roomId]
+ );
+
+ if (accountDataType !== undefined) {
+ return (
+ <AccountDataEditor
+ type={accountDataType ?? undefined}
+ content={accountDataType ? accountData.get(accountDataType) : undefined}
+ submitChange={submitAccountData}
+ requestClose={handleClose}
+ />
+ );
+ }
+
+ if (composeEvent) {
+ return <SendRoomEvent {...composeEvent} requestClose={handleClose} />;
+ }
+
+ if (openStateEvent) {
+ return <StateEventEditor {...openStateEvent} requestClose={handleClose} />;
+ }
+
+ return (
+ <Page>
+ <PageHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ Developer Tools
+ </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">
+ <Box direction="Column" gap="100">
+ <Text size="L400">Options</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Enable Developer Tools"
+ after={
+ <Switch
+ variant="Primary"
+ value={developerTools}
+ onChange={setDeveloperTools}
+ />
+ }
+ />
+ </SequenceCard>
+ {developerTools && (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Room ID"
+ description={`Copy room ID to clipboard. ("${room.roomId}")`}
+ after={
+ <Button
+ onClick={() => copyToClipboard(room.roomId ?? '<NO_ROOM_ID_FOUND>')}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ >
+ <Text size="B300">Copy</Text>
+ </Button>
+ }
+ />
+ </SequenceCard>
+ )}
+ </Box>
+
+ {developerTools && (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Data</Text>
+
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="New Message Event"
+ description="Create and send a new message event within the room."
+ after={
+ <Button
+ onClick={() => setComposeEvent({})}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ >
+ <Text size="B300">Compose</Text>
+ </Button>
+ }
+ />
+ </SequenceCard>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Room State"
+ description="State events of the room."
+ after={
+ <Button
+ onClick={() => setExpandState(!expandState)}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ before={
+ <Icon
+ src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
+ size="100"
+ filled
+ />
+ }
+ >
+ <Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text>
+ </Button>
+ }
+ />
+ {expandState && (
+ <Box direction="Column" gap="100">
+ <Box justifyContent="SpaceBetween">
+ <Text size="L400">Events</Text>
+ <Text size="L400">Total: {roomState.size}</Text>
+ </Box>
+ <CutoutCard>
+ <MenuItem
+ onClick={() => setComposeEvent({ stateKey: '' })}
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ before={<Icon size="50" src={Icons.Plus} />}
+ >
+ <Box grow="Yes">
+ <Text size="T200" truncate>
+ Add New
+ </Text>
+ </Box>
+ </MenuItem>
+ {Array.from(roomState.keys())
+ .sort()
+ .map((eventType) => {
+ const expanded = eventType === expandStateType;
+ const stateKeyToEvents = roomState.get(eventType);
+ if (!stateKeyToEvents) return null;
+
+ return (
+ <Box id={eventType} key={eventType} direction="Column" gap="100">
+ <MenuItem
+ onClick={() =>
+ setExpandStateType(expanded ? undefined : eventType)
+ }
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ before={
+ <Icon
+ size="50"
+ src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
+ />
+ }
+ after={<Text size="L400">{stateKeyToEvents.size}</Text>}
+ >
+ <Box grow="Yes">
+ <Text size="T200" truncate>
+ {eventType}
+ </Text>
+ </Box>
+ </MenuItem>
+ {expanded && (
+ <div
+ style={{
+ marginLeft: config.space.S400,
+ borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
+ }}
+ >
+ <MenuItem
+ onClick={() =>
+ setComposeEvent({ type: eventType, stateKey: '' })
+ }
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ before={<Icon size="50" src={Icons.Plus} />}
+ >
+ <Box grow="Yes">
+ <Text size="T200" truncate>
+ Add New
+ </Text>
+ </Box>
+ </MenuItem>
+ {Array.from(stateKeyToEvents.keys())
+ .sort()
+ .map((stateKey) => (
+ <MenuItem
+ onClick={() => {
+ setOpenStateEvent({
+ type: eventType,
+ stateKey,
+ });
+ }}
+ key={stateKey}
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ after={<Icon size="50" src={Icons.ChevronRight} />}
+ >
+ <Box grow="Yes">
+ <Text size="T200" truncate>
+ {stateKey ? `"${stateKey}"` : 'Default'}
+ </Text>
+ </Box>
+ </MenuItem>
+ ))}
+ </div>
+ )}
+ </Box>
+ );
+ })}
+ </CutoutCard>
+ </Box>
+ )}
+ </SequenceCard>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Account Data"
+ description="Private personalization data stored within room."
+ after={
+ <Button
+ onClick={() => setExpandAccountData(!expandAccountData)}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ before={
+ <Icon
+ src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
+ size="100"
+ filled
+ />
+ }
+ >
+ <Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
+ </Button>
+ }
+ />
+ {expandAccountData && (
+ <Box direction="Column" gap="100">
+ <Box justifyContent="SpaceBetween">
+ <Text size="L400">Events</Text>
+ <Text size="L400">Total: {accountData.size}</Text>
+ </Box>
+ <CutoutCard>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ before={<Icon size="50" src={Icons.Plus} />}
+ onClick={() => setAccountDataType(null)}
+ >
+ <Box grow="Yes">
+ <Text size="T200" truncate>
+ Add New
+ </Text>
+ </Box>
+ </MenuItem>
+ {Array.from(accountData.keys())
+ .sort()
+ .map((type) => (
+ <MenuItem
+ key={type}
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ after={<Icon size="50" src={Icons.ChevronRight} />}
+ onClick={() => setAccountDataType(type)}
+ >
+ <Box grow="Yes">
+ <Text size="T200" truncate>
+ {type}
+ </Text>
+ </Box>
+ </MenuItem>
+ ))}
+ </CutoutCard>
+ </Box>
+ )}
+ </SequenceCard>
+ </Box>
+ )}
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
+import { MatrixError } from 'matrix-js-sdk';
+import {
+ Box,
+ Chip,
+ Icon,
+ Icons,
+ IconButton,
+ Text,
+ config,
+ Button,
+ Spinner,
+ color,
+ TextArea as TextAreaComponent,
+ Input,
+} from 'folds';
+import { Page, PageHeader } from '../../../components/page';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRoom } from '../../../hooks/useRoom';
+import { useAlive } from '../../../hooks/useAlive';
+import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { syntaxErrorPosition } from '../../../utils/dom';
+import { Cursor } from '../../../plugins/text-area';
+
+const EDITOR_INTENT_SPACE_COUNT = 2;
+
+export type SendRoomEventProps = {
+ type?: string;
+ stateKey?: string;
+ requestClose: () => void;
+};
+export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const alive = useAlive();
+ const composeStateEvent = typeof stateKey === 'string';
+
+ const textAreaRef = useRef<HTMLTextAreaElement>(null);
+ const [jsonError, setJSONError] = useState<SyntaxError>();
+ const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
+ textAreaRef,
+ EDITOR_INTENT_SPACE_COUNT
+ );
+
+ const [submitState, submit] = useAsyncCallback<
+ object,
+ MatrixError,
+ [string, string | undefined, object]
+ >(
+ useCallback(
+ (evtType, evtStateKey, evtContent) => {
+ if (typeof evtStateKey === 'string') {
+ return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
+ }
+ return mx.sendEvent(room.roomId, evtType as any, evtContent);
+ },
+ [mx, room]
+ )
+ );
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (submitting) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const typeInput = target?.typeInput as HTMLInputElement | undefined;
+ const stateKeyInput = target?.stateKeyInput as HTMLInputElement | undefined;
+ const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+ if (!typeInput || !contentTextArea) return;
+
+ const evtType = typeInput.value;
+ const evtStateKey = stateKeyInput?.value;
+ const contentStr = contentTextArea.value.trim();
+
+ let parsedContent: object;
+ try {
+ parsedContent = JSON.parse(contentStr);
+ } catch (e) {
+ setJSONError(e as SyntaxError);
+ return;
+ }
+ setJSONError(undefined);
+
+ if (parsedContent === null) {
+ return;
+ }
+
+ submit(evtType, evtStateKey, parsedContent).then(() => {
+ if (alive()) {
+ requestClose();
+ }
+ });
+ };
+
+ useEffect(() => {
+ if (jsonError) {
+ const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
+ const cursor = new Cursor(errorPosition, errorPosition, 'none');
+ operations.select(cursor);
+ getTarget()?.focus();
+ }
+ }, [jsonError, operations, getTarget]);
+
+ return (
+ <Page>
+ <PageHeader outlined={false} balance>
+ <Box alignItems="Center" grow="Yes" gap="200">
+ <Box alignItems="Inherit" grow="Yes" gap="200">
+ <Chip
+ size="500"
+ radii="Pill"
+ onClick={requestClose}
+ before={<Icon size="100" src={Icons.ArrowLeft} />}
+ >
+ <Text size="T300">Developer Tools</Text>
+ </Chip>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes" direction="Column">
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ grow="Yes"
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ aria-disabled={submitting}
+ >
+ <Box shrink="No" direction="Column" gap="100">
+ <Text size="L400">{composeStateEvent ? 'State Event Type' : 'Message Event Type'}</Text>
+ <Box gap="300">
+ <Box grow="Yes" direction="Column">
+ <Input
+ variant="Background"
+ name="typeInput"
+ size="400"
+ radii="300"
+ readOnly={submitting}
+ defaultValue={type}
+ required
+ />
+ </Box>
+ <Button
+ variant="Success"
+ size="400"
+ radii="300"
+ type="submit"
+ disabled={submitting}
+ before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+ >
+ <Text size="B400">Send</Text>
+ </Button>
+ </Box>
+
+ {submitState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>{submitState.error.message}</b>
+ </Text>
+ )}
+ </Box>
+ {composeStateEvent && (
+ <Box shrink="No" direction="Column" gap="100">
+ <Text size="L400">State Key (Optional)</Text>
+ <Input
+ variant="Background"
+ name="stateKeyInput"
+ size="400"
+ radii="300"
+ readOnly={submitting}
+ defaultValue={stateKey}
+ />
+ </Box>
+ )}
+ <Box grow="Yes" direction="Column" gap="100">
+ <Box shrink="No">
+ <Text size="L400">JSON Content</Text>
+ </Box>
+ <TextAreaComponent
+ ref={textAreaRef}
+ name="contentTextArea"
+ style={{ fontFamily: 'monospace' }}
+ onKeyDown={handleKeyDown}
+ resize="None"
+ spellCheck="false"
+ required
+ readOnly={submitting}
+ />
+ {jsonError && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>
+ {jsonError.name}: {jsonError.message}
+ </b>
+ </Text>
+ )}
+ </Box>
+ </Box>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Box,
+ Text,
+ Icon,
+ Icons,
+ IconButton,
+ Chip,
+ Scroll,
+ config,
+ TextArea as TextAreaComponent,
+ color,
+ Spinner,
+ Button,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { Page, PageHeader } from '../../../components/page';
+import { SequenceCard } from '../../../components/sequence-card';
+import { TextViewerContent } from '../../../components/text-viewer';
+import { useStateEvent } from '../../../hooks/useStateEvent';
+import { useRoom } from '../../../hooks/useRoom';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useAlive } from '../../../hooks/useAlive';
+import { Cursor } from '../../../plugins/text-area';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { syntaxErrorPosition } from '../../../utils/dom';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCardStyle } from '../styles.css';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
+
+const EDITOR_INTENT_SPACE_COUNT = 2;
+
+type StateEventEditProps = {
+ type: string;
+ stateKey: string;
+ content: object;
+ requestClose: () => void;
+};
+function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEditProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const alive = useAlive();
+
+ const defaultContentStr = useMemo(
+ () => JSON.stringify(content, undefined, EDITOR_INTENT_SPACE_COUNT),
+ [content]
+ );
+
+ const textAreaRef = useRef<HTMLTextAreaElement>(null);
+ const [jsonError, setJSONError] = useState<SyntaxError>();
+ const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor(
+ textAreaRef,
+ EDITOR_INTENT_SPACE_COUNT
+ );
+
+ const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
+ useCallback(
+ (c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
+ [mx, room, type, stateKey]
+ )
+ );
+ const submitting = submitState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (submitting) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
+ if (!contentTextArea) return;
+
+ const contentStr = contentTextArea.value.trim();
+
+ let parsedContent: object;
+ try {
+ parsedContent = JSON.parse(contentStr);
+ } catch (e) {
+ setJSONError(e as SyntaxError);
+ return;
+ }
+ setJSONError(undefined);
+
+ if (
+ parsedContent === null ||
+ defaultContentStr === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
+ ) {
+ return;
+ }
+
+ submit(parsedContent).then(() => {
+ if (alive()) {
+ requestClose();
+ }
+ });
+ };
+
+ useEffect(() => {
+ if (jsonError) {
+ const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
+ const cursor = new Cursor(errorPosition, errorPosition, 'none');
+ operations.select(cursor);
+ getTarget()?.focus();
+ }
+ }, [jsonError, operations, getTarget]);
+
+ return (
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ grow="Yes"
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ aria-disabled={submitting}
+ >
+ <Box shrink="No" direction="Column" gap="100">
+ <Text size="L400">State Event</Text>
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={type}
+ description={stateKey}
+ after={
+ <Box gap="200">
+ <Button
+ variant="Success"
+ size="300"
+ radii="300"
+ type="submit"
+ disabled={submitting}
+ before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
+ >
+ <Text size="B300">Save</Text>
+ </Button>
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ onClick={requestClose}
+ disabled={submitting}
+ >
+ <Text size="B300">Cancel</Text>
+ </Button>
+ </Box>
+ }
+ />
+ </SequenceCard>
+
+ {submitState.status === AsyncStatus.Error && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>{submitState.error.message}</b>
+ </Text>
+ )}
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <Box shrink="No">
+ <Text size="L400">JSON Content</Text>
+ </Box>
+ <TextAreaComponent
+ ref={textAreaRef}
+ name="contentTextArea"
+ style={{ fontFamily: 'monospace' }}
+ onKeyDown={handleKeyDown}
+ defaultValue={defaultContentStr}
+ resize="None"
+ spellCheck="false"
+ required
+ readOnly={submitting}
+ />
+ {jsonError && (
+ <Text size="T200" style={{ color: color.Critical.Main }}>
+ <b>
+ {jsonError.name}: {jsonError.message}
+ </b>
+ </Text>
+ )}
+ </Box>
+ </Box>
+ );
+}
+
+type StateEventViewProps = {
+ content: object;
+ eventJSONStr: string;
+ onEditContent?: (content: object) => void;
+};
+function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) {
+ return (
+ <Box direction="Column" style={{ padding: config.space.S400 }} gap="400">
+ <Box grow="Yes" direction="Column" gap="100">
+ <Box gap="200" alignItems="End">
+ <Box grow="Yes">
+ <Text size="L400">State Event</Text>
+ </Box>
+ {onEditContent && (
+ <Box shrink="No" gap="200">
+ <Chip
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ outlined
+ onClick={() => onEditContent(content)}
+ >
+ <Text size="B300">Edit</Text>
+ </Chip>
+ </Box>
+ )}
+ </Box>
+ <SequenceCard variant="SurfaceVariant">
+ <Scroll visibility="Always" size="300" hideTrack>
+ <TextViewerContent
+ size="T300"
+ style={{
+ padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
+ }}
+ text={eventJSONStr}
+ langName="JSON"
+ />
+ </Scroll>
+ </SequenceCard>
+ </Box>
+ </Box>
+ );
+}
+
+export type StateEventInfo = {
+ type: string;
+ stateKey: string;
+};
+export type StateEventEditorProps = StateEventInfo & {
+ requestClose: () => void;
+};
+
+export function StateEventEditor({ type, stateKey, requestClose }: StateEventEditorProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const stateEvent = useStateEvent(room, type as unknown as StateEvent, stateKey);
+ const [editContent, setEditContent] = useState<object>();
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+ const canEdit = canSendStateEvent(type, getPowerLevel(mx.getSafeUserId()));
+
+ const eventJSONStr = useMemo(() => {
+ if (!stateEvent) return '';
+ return JSON.stringify(stateEvent.event, null, EDITOR_INTENT_SPACE_COUNT);
+ }, [stateEvent]);
+
+ const handleCloseEdit = useCallback(() => {
+ setEditContent(undefined);
+ }, []);
+
+ return (
+ <Page>
+ <PageHeader outlined={false} balance>
+ <Box alignItems="Center" grow="Yes" gap="200">
+ <Box alignItems="Inherit" grow="Yes" gap="200">
+ <Chip
+ size="500"
+ radii="Pill"
+ onClick={requestClose}
+ before={<Icon size="100" src={Icons.ArrowLeft} />}
+ >
+ <Text size="T300">Developer Tools</Text>
+ </Chip>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes" direction="Column">
+ {editContent ? (
+ <StateEventEdit
+ type={type}
+ stateKey={stateKey}
+ content={editContent}
+ requestClose={handleCloseEdit}
+ />
+ ) : (
+ <StateEventView
+ content={stateEvent?.getContent() ?? {}}
+ onEditContent={canEdit ? setEditContent : undefined}
+ eventJSONStr={eventJSONStr}
+ />
+ )}
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './DevelopTools';
--- /dev/null
+import React, { useState } from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { ImagePack } from '../../../plugins/custom-emoji';
+import { ImagePackView } from '../../../components/image-pack-view';
+import { RoomPacks } from './RoomPacks';
+
+type EmojisStickersProps = {
+ requestClose: () => void;
+};
+export function EmojisStickers({ requestClose }: EmojisStickersProps) {
+ const [imagePack, setImagePack] = useState<ImagePack>();
+
+ const handleImagePackViewClose = () => {
+ setImagePack(undefined);
+ };
+
+ if (imagePack) {
+ return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
+ }
+
+ return (
+ <Page>
+ <PageHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ Emojis & Stickers
+ </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">
+ <RoomPacks onViewPack={setImagePack} />
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+ Box,
+ Text,
+ Button,
+ Icon,
+ Icons,
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ toRem,
+ config,
+ Input,
+ Spinner,
+ color,
+ IconButton,
+ Menu,
+} from 'folds';
+import { MatrixError } from 'matrix-js-sdk';
+import { SequenceCard } from '../../../components/sequence-card';
+import {
+ ImagePack,
+ ImageUsage,
+ PackAddress,
+ packAddressEqual,
+ PackContent,
+} from '../../../plugins/custom-emoji';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomImagePacks } from '../../../hooks/useImagePacks';
+import { LineClamp2 } from '../../../styles/Text.css';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCardStyle } from '../styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { mxcUrlToHttp } from '../../../utils/matrix';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { StateEvent } from '../../../../types/matrix/room';
+import { suffixRename } from '../../../utils/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+type CreatePackTileProps = {
+ packs: ImagePack[];
+ roomId: string;
+};
+function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
+ const mx = useMatrixClient();
+ const alive = useAlive();
+
+ const [addState, addPack] = useAsyncCallback<void, MatrixError, [string, string]>(
+ useCallback(
+ async (stateKey, name) => {
+ const content: PackContent = {
+ pack: {
+ display_name: name,
+ },
+ };
+ await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
+ },
+ [mx, roomId]
+ )
+ );
+
+ const creating = addState.status === AsyncStatus.Loading;
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (creating) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const nameInput = target?.nameInput as HTMLInputElement | undefined;
+ if (!nameInput) return;
+ const name = nameInput?.value.trim();
+ if (!name) return;
+
+ let packKey = name.replace(/\s/g, '-');
+
+ const hasPack = (k: string): boolean => !!packs.find((pack) => pack.address?.stateKey === k);
+ if (hasPack(packKey)) {
+ packKey = suffixRename(packKey, hasPack);
+ }
+
+ addPack(packKey, name).then(() => {
+ if (alive()) {
+ nameInput.value = '';
+ }
+ });
+ };
+
+ return (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="New Pack"
+ description="Add your own emoji and sticker pack to use in room."
+ >
+ <Box
+ style={{ marginTop: config.space.S200 }}
+ as="form"
+ onSubmit={handleSubmit}
+ gap="200"
+ alignItems="End"
+ >
+ <Box direction="Column" gap="100" grow="Yes">
+ <Text size="L400">Name</Text>
+ <Input
+ name="nameInput"
+ required
+ size="400"
+ variant="Secondary"
+ radii="300"
+ readOnly={creating}
+ />
+ {addState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ {addState.error.message}
+ </Text>
+ )}
+ </Box>
+ <Button
+ variant="Success"
+ radii="300"
+ type="submit"
+ disabled={creating}
+ before={creating && <Spinner size="200" variant="Success" fill="Solid" />}
+ >
+ <Text size="B400">Create</Text>
+ </Button>
+ </Box>
+ </SettingTile>
+ </SequenceCard>
+ );
+}
+
+type RoomPacksProps = {
+ onViewPack: (imagePack: ImagePack) => void;
+};
+export function RoomPacks({ onViewPack }: RoomPacksProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = useRoom();
+ const alive = useAlive();
+
+ const powerLevels = usePowerLevels(room);
+ const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels);
+ const canEdit = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(mx.getSafeUserId()));
+
+ const unfilteredPacks = useRoomImagePacks(room);
+ const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]);
+
+ const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
+ const hasChanges = removedPacks.length > 0;
+
+ const [applyState, applyChanges] = useAsyncCallback(
+ useCallback(async () => {
+ for (let i = 0; i < removedPacks.length; i += 1) {
+ const addr = removedPacks[i];
+ // eslint-disable-next-line no-await-in-loop
+ await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
+ }
+ }, [mx, room, removedPacks])
+ );
+ const applyingChanges = applyState.status === AsyncStatus.Loading;
+
+ const handleRemove = (address: PackAddress) => {
+ setRemovedPacks((addresses) => [...addresses, address]);
+ };
+
+ const handleUndoRemove = (address: PackAddress) => {
+ setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
+ };
+
+ const handleCancelChanges = () => setRemovedPacks([]);
+
+ const handleApplyChanges = () => {
+ applyChanges().then(() => {
+ if (alive()) {
+ setRemovedPacks([]);
+ }
+ });
+ };
+
+ const renderPack = (pack: ImagePack) => {
+ const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
+ const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
+ const { address } = pack;
+ if (!address) return null;
+ const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
+
+ return (
+ <SequenceCard
+ key={pack.id}
+ className={SequenceCardStyle}
+ variant={removed ? 'Critical' : 'SurfaceVariant'}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={
+ <span style={{ textDecoration: removed ? 'line-through' : undefined }}>
+ {pack.meta.name ?? 'Unknown'}
+ </span>
+ }
+ description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
+ before={
+ <Box alignItems="Center" gap="300">
+ {canEdit &&
+ (removed ? (
+ <IconButton
+ size="300"
+ radii="Pill"
+ variant="Critical"
+ onClick={() => handleUndoRemove(address)}
+ disabled={applyingChanges}
+ >
+ <Icon src={Icons.Plus} size="100" />
+ </IconButton>
+ ) : (
+ <IconButton
+ size="300"
+ radii="Pill"
+ variant="Secondary"
+ onClick={() => handleRemove(address)}
+ disabled={applyingChanges}
+ >
+ <Icon src={Icons.Cross} size="100" />
+ </IconButton>
+ ))}
+ <Avatar size="300" radii="300">
+ {avatarUrl ? (
+ <AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
+ ) : (
+ <AvatarFallback>
+ <Icon size="400" src={Icons.Sticker} filled />
+ </AvatarFallback>
+ )}
+ </Avatar>
+ </Box>
+ }
+ after={
+ !removed && (
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ onClick={() => onViewPack(pack)}
+ >
+ <Text size="B300">View</Text>
+ </Button>
+ )
+ }
+ />
+ </SequenceCard>
+ );
+ };
+
+ return (
+ <>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Packs</Text>
+ {canEdit && <CreatePackTile roomId={room.roomId} packs={packs} />}
+ {packs.map(renderPack)}
+ {packs.length === 0 && (
+ <SequenceCard
+ className={SequenceCardStyle}
+ variant="SurfaceVariant"
+ direction="Column"
+ gap="400"
+ >
+ <Box
+ justifyContent="Center"
+ direction="Column"
+ gap="200"
+ style={{
+ padding: `${config.space.S700} ${config.space.S400}`,
+ maxWidth: toRem(300),
+ margin: 'auto',
+ }}
+ >
+ <Text size="H5" align="Center">
+ No Packs
+ </Text>
+ <Text size="T200" align="Center">
+ There are no emoji or sticker packs to display at the moment.
+ </Text>
+ </Box>
+ </SequenceCard>
+ )}
+ </Box>
+
+ {hasChanges && (
+ <Menu
+ style={{
+ position: 'sticky',
+ padding: config.space.S200,
+ paddingLeft: config.space.S400,
+ bottom: config.space.S400,
+ left: config.space.S400,
+ right: 0,
+ zIndex: 1,
+ }}
+ variant="Critical"
+ >
+ <Box alignItems="Center" gap="400">
+ <Box grow="Yes" direction="Column">
+ {applyState.status === AsyncStatus.Error ? (
+ <Text size="T200">
+ <b>Failed to remove packs! Please try again.</b>
+ </Text>
+ ) : (
+ <Text size="T200">
+ <b>Delete selected packs. ({removedPacks.length} selected)</b>
+ </Text>
+ )}
+ </Box>
+ <Box shrink="No" gap="200">
+ <Button
+ size="300"
+ variant="Critical"
+ fill="None"
+ radii="300"
+ disabled={applyingChanges}
+ onClick={handleCancelChanges}
+ >
+ <Text size="B300">Cancel</Text>
+ </Button>
+ <Button
+ size="300"
+ variant="Critical"
+ radii="300"
+ disabled={applyingChanges}
+ before={applyingChanges && <Spinner variant="Critical" fill="Solid" size="100" />}
+ onClick={handleApplyChanges}
+ >
+ <Text size="B300">Delete</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Menu>
+ )}
+ </>
+ );
+}
--- /dev/null
+export * from './EmojisStickers';
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+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>
+ );
+}
--- /dev/null
+export * from './General';
--- /dev/null
+export * from './RoomSettings';
+export * from './RoomSettingsRenderer';
--- /dev/null
+import React, {
+ ChangeEventHandler,
+ MouseEventHandler,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Box,
+ Chip,
+ config,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ PopOut,
+ RectCords,
+ Scroll,
+ Spinner,
+ Text,
+ toRem,
+} from 'folds';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { RoomMember } from 'matrix-js-sdk';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { useRoom } from '../../../hooks/useRoom';
+import { useRoomMembers } from '../../../hooks/useRoomMembers';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import {
+ useFlattenPowerLevelTagMembers,
+ usePowerLevelTags,
+} from '../../../hooks/usePowerLevelTags';
+import { VirtualTile } from '../../../components/virtualizer';
+import { MemberTile } from '../../../components/member-tile';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
+import { ServerBadge } from '../../../components/server-badge';
+import { openProfileViewer } from '../../../../client/action/navigation';
+import { useDebounce } from '../../../hooks/useDebounce';
+import {
+ SearchItemStrGetter,
+ useAsyncSearch,
+ UseAsyncSearchOptions,
+} from '../../../hooks/useAsyncSearch';
+import { getMemberSearchStr } from '../../../utils/room';
+import { useMembershipFilter, useMembershipFilterMenu } from '../../../hooks/useMemberFilter';
+import { useMemberSort, useMemberSortMenu } from '../../../hooks/useMemberSort';
+import { settingsAtom } from '../../../state/settings';
+import { useSetting } from '../../../state/hooks/settings';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { MembershipFilterMenu } from '../../../components/MembershipFilterMenu';
+import { MemberSortMenu } from '../../../components/MemberSortMenu';
+import { ScrollTopContainer } from '../../../components/scroll-top-container';
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+ limit: 1000,
+ matchOptions: {
+ contain: true,
+ },
+ normalizeOptions: {
+ ignoreWhitespace: false,
+ },
+};
+
+const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
+const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
+ getMemberSearchStr(m, query, mxIdToName);
+
+type MembersProps = {
+ requestClose: () => void;
+};
+export function Members({ requestClose }: MembersProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = useRoom();
+ const members = useRoomMembers(mx, room.roomId);
+ const fetchingMembers = members.length < room.getJoinedMemberCount();
+
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
+ const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+
+ const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
+ const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
+ const membershipFilter = useMembershipFilter(membershipFilterIndex, useMembershipFilterMenu());
+ const memberSort = useMemberSort(sortFilterIndex, useMemberSortMenu());
+
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
+
+ const sortedMembers = useMemo(
+ () =>
+ Array.from(members)
+ .filter(membershipFilter.filterFn)
+ .sort(memberSort.sortFn)
+ .sort((a, b) => b.powerLevel - a.powerLevel),
+ [members, membershipFilter, memberSort]
+ );
+
+ const [result, search, resetSearch] = useAsyncSearch(
+ sortedMembers,
+ getRoomMemberStr,
+ SEARCH_OPTIONS
+ );
+ if (!result && searchInputRef.current?.value) search(searchInputRef.current.value);
+
+ const flattenTagMembers = useFlattenPowerLevelTagMembers(
+ result?.items ?? sortedMembers,
+ getPowerLevel,
+ getPowerLevelTag
+ );
+
+ const virtualizer = useVirtualizer({
+ count: flattenTagMembers.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 40,
+ overscan: 10,
+ });
+
+ const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useDebounce(
+ useCallback(
+ (evt) => {
+ if (evt.target.value) search(evt.target.value);
+ else resetSearch();
+ },
+ [search, resetSearch]
+ ),
+ { wait: 200 }
+ );
+
+ const handleSearchReset = () => {
+ if (searchInputRef.current) {
+ searchInputRef.current.value = '';
+ searchInputRef.current.focus();
+ }
+ resetSearch();
+ };
+
+ const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ const btn = evt.currentTarget as HTMLButtonElement;
+ const userId = btn.getAttribute('data-user-id');
+ openProfileViewer(userId, room.roomId);
+ requestClose();
+ };
+
+ return (
+ <Page>
+ <PageHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ {room.getJoinedMemberCount()} Members
+ </Text>
+ </Box>
+ <Box shrink="No">
+ <IconButton onClick={requestClose} variant="Surface">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Box>
+ </Box>
+ </PageHeader>
+ <Box grow="Yes" style={{ position: 'relative' }}>
+ <Scroll ref={scrollRef} hideTrack visibility="Hover">
+ <PageContent>
+ <Box direction="Column" gap="200">
+ <Box
+ style={{ position: 'sticky', top: config.space.S100, zIndex: 1 }}
+ direction="Column"
+ gap="100"
+ >
+ <Input
+ ref={searchInputRef}
+ onChange={handleSearchChange}
+ before={<Icon size="200" src={Icons.Search} />}
+ variant="SurfaceVariant"
+ size="500"
+ placeholder="Search"
+ outlined
+ after={
+ result && (
+ <Chip
+ variant={result.items.length > 0 ? 'Success' : 'Critical'}
+ outlined
+ size="400"
+ radii="Pill"
+ aria-pressed
+ onClick={handleSearchReset}
+ after={<Icon size="50" src={Icons.Cross} />}
+ >
+ <Text size="B300">
+ {result.items.length === 0
+ ? 'No Results'
+ : `${result.items.length} Results`}
+ </Text>
+ </Chip>
+ )
+ }
+ />
+ </Box>
+ <Box ref={scrollTopAnchorRef} alignItems="Center" justifyContent="End" gap="200">
+ <UseStateProvider initial={undefined}>
+ {(anchor: RectCords | undefined, setAnchor) => (
+ <PopOut
+ anchor={anchor}
+ position="Bottom"
+ align="Start"
+ offset={4}
+ content={
+ <MembershipFilterMenu
+ selected={membershipFilterIndex}
+ onSelect={setMembershipFilterIndex}
+ requestClose={() => setAnchor(undefined)}
+ />
+ }
+ >
+ <Chip
+ onClick={
+ ((evt) =>
+ setAnchor(
+ evt.currentTarget.getBoundingClientRect()
+ )) as MouseEventHandler<HTMLButtonElement>
+ }
+ variant="SurfaceVariant"
+ size="400"
+ radii="300"
+ before={<Icon src={Icons.Filter} size="50" />}
+ >
+ <Text size="T200">{membershipFilter.name}</Text>
+ </Chip>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ <UseStateProvider initial={undefined}>
+ {(anchor: RectCords | undefined, setAnchor) => (
+ <PopOut
+ anchor={anchor}
+ position="Bottom"
+ align="End"
+ offset={4}
+ content={
+ <MemberSortMenu
+ selected={sortFilterIndex}
+ onSelect={setSortFilterIndex}
+ requestClose={() => setAnchor(undefined)}
+ />
+ }
+ >
+ <Chip
+ onClick={
+ ((evt) =>
+ setAnchor(
+ evt.currentTarget.getBoundingClientRect()
+ )) as MouseEventHandler<HTMLButtonElement>
+ }
+ variant="SurfaceVariant"
+ size="400"
+ radii="300"
+ after={<Icon src={Icons.Sort} size="50" />}
+ >
+ <Text size="T200">{memberSort.name}</Text>
+ </Chip>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ </Box>
+ <ScrollTopContainer
+ style={{ top: toRem(64) }}
+ scrollRef={scrollRef}
+ anchorRef={scrollTopAnchorRef}
+ >
+ <IconButton
+ onClick={() => virtualizer.scrollToOffset(0)}
+ variant="Surface"
+ radii="Pill"
+ outlined
+ size="300"
+ aria-label="Scroll to Top"
+ >
+ <Icon src={Icons.ChevronTop} size="300" />
+ </IconButton>
+ </ScrollTopContainer>
+ {fetchingMembers && (
+ <Box justifyContent="Center">
+ <Spinner />
+ </Box>
+ )}
+ <Box
+ style={{
+ position: 'relative',
+ height: virtualizer.getTotalSize(),
+ }}
+ direction="Column"
+ gap="100"
+ >
+ {virtualizer.getVirtualItems().map((vItem) => {
+ const tagOrMember = flattenTagMembers[vItem.index];
+
+ if ('userId' in tagOrMember) {
+ const server = getMxIdServer(tagOrMember.userId);
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ key={`${tagOrMember.userId}-${vItem.index}`}
+ ref={virtualizer.measureElement}
+ >
+ <div style={{ paddingTop: config.space.S200 }}>
+ <MemberTile
+ data-user-id={tagOrMember.userId}
+ onClick={handleMemberClick}
+ mx={mx}
+ room={room}
+ member={tagOrMember}
+ useAuthentication={useAuthentication}
+ after={
+ server && (
+ <Box as="span" shrink="No" alignSelf="End">
+ <ServerBadge server={server} fill="None" />
+ </Box>
+ )
+ }
+ />
+ </div>
+ </VirtualTile>
+ );
+ }
+
+ return (
+ <VirtualTile
+ virtualItem={vItem}
+ key={vItem.index}
+ ref={virtualizer.measureElement}
+ >
+ <div
+ style={{
+ paddingTop: vItem.index === 0 ? 0 : config.space.S500,
+ }}
+ >
+ <Text size="L400">{tagOrMember.name}</Text>
+ </div>
+ </VirtualTile>
+ );
+ })}
+ </Box>
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './Members';
--- /dev/null
+/* eslint-disable react/no-array-index-key */
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
+import produce from 'immer';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import {
+ applyPermissionPower,
+ getPermissionPower,
+ IPowerLevels,
+ PermissionLocation,
+ usePowerLevelsAPI,
+} from '../../../hooks/usePowerLevels';
+import { usePermissionGroups } from './usePermissionItems';
+import { getPowers, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
+import { useRoom } from '../../../hooks/useRoom';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { PowerSwitcher } from '../../../components/power';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useAlive } from '../../../hooks/useAlive';
+
+const USER_DEFAULT_LOCATION: PermissionLocation = {
+ user: true,
+};
+
+type PermissionGroupsProps = {
+ powerLevels: IPowerLevels;
+};
+export function PermissionGroups({ powerLevels }: PermissionGroupsProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const alive = useAlive();
+ const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+ const canChangePermission = canSendStateEvent(
+ StateEvent.RoomPowerLevels,
+ getPowerLevel(mx.getSafeUserId())
+ );
+ const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
+ const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]);
+
+ const permissionGroups = usePermissionGroups();
+
+ const [permissionUpdate, setPermissionUpdate] = useState<Map<PermissionLocation, number>>(
+ new Map()
+ );
+
+ useEffect(() => {
+ // reset permission update if component rerender
+ // as permission location object reference has changed
+ setPermissionUpdate(new Map());
+ }, [permissionGroups]);
+
+ const handleChangePermission = (
+ location: PermissionLocation,
+ newPower: number,
+ currentPower: number
+ ) => {
+ setPermissionUpdate((p) => {
+ const up: typeof p = new Map();
+ p.forEach((value, key) => {
+ up.set(key, value);
+ });
+ if (newPower === currentPower) {
+ up.delete(location);
+ } else {
+ up.set(location, newPower);
+ }
+ return up;
+ });
+ };
+
+ const [applyState, applyChanges] = useAsyncCallback(
+ useCallback(async () => {
+ const editedPowerLevels = produce(powerLevels, (draftPowerLevels) => {
+ permissionGroups.forEach((group) =>
+ group.items.forEach((item) => {
+ const power = getPermissionPower(powerLevels, item.location);
+ applyPermissionPower(draftPowerLevels, item.location, power);
+ })
+ );
+ permissionUpdate.forEach((power, location) =>
+ applyPermissionPower(draftPowerLevels, location, power)
+ );
+ return draftPowerLevels;
+ });
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
+ }, [mx, room, powerLevels, permissionUpdate, permissionGroups])
+ );
+
+ const resetChanges = useCallback(() => {
+ setPermissionUpdate(new Map());
+ }, []);
+
+ const handleApplyChanges = () => {
+ applyChanges().then(() => {
+ if (alive()) {
+ resetChanges();
+ }
+ });
+ };
+
+ const applyingChanges = applyState.status === AsyncStatus.Loading;
+ const hasChanges = permissionUpdate.size > 0;
+
+ const renderUserGroup = () => {
+ const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION);
+ const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION);
+ const value = powerUpdate ?? power;
+
+ const tag = getPowerLevelTag(value);
+ const powerChanges = value !== power;
+
+ return (
+ <Box direction="Column" gap="100">
+ <Text size="L400">Users</Text>
+ <SequenceCard
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Default Power"
+ description="Default power level for all users."
+ after={
+ <PowerSwitcher
+ powerLevelTags={powerLevelTags}
+ value={value}
+ onChange={(v) => handleChangePermission(USER_DEFAULT_LOCATION, v, power)}
+ >
+ {(handleOpen, opened) => (
+ <Chip
+ variant={powerChanges ? 'Success' : 'Secondary'}
+ outlined={powerChanges}
+ fill="Soft"
+ radii="Pill"
+ aria-selected={opened}
+ disabled={!canChangePermission || applyingChanges}
+ after={
+ powerChanges && (
+ <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
+ )
+ }
+ before={
+ canChangePermission && (
+ <Icon size="50" src={opened ? Icons.ChevronTop : Icons.ChevronBottom} />
+ )
+ }
+ onClick={handleOpen}
+ >
+ <Text size="B300" truncate>
+ {tag.name}
+ </Text>
+ </Chip>
+ )}
+ </PowerSwitcher>
+ }
+ />
+ </SequenceCard>
+ </Box>
+ );
+ };
+
+ return (
+ <>
+ {renderUserGroup()}
+ {permissionGroups.map((group, groupIndex) => (
+ <Box key={groupIndex} direction="Column" gap="100">
+ <Text size="L400">{group.name}</Text>
+ {group.items.map((item, itemIndex) => {
+ const power = getPermissionPower(powerLevels, item.location);
+ const powerUpdate = permissionUpdate.get(item.location);
+ const value = powerUpdate ?? power;
+
+ const tag = getPowerLevelTag(value);
+ const powerChanges = value !== power;
+
+ return (
+ <SequenceCard
+ key={itemIndex}
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title={item.name}
+ description={item.description}
+ after={
+ <PowerSwitcher
+ powerLevelTags={powerLevelTags}
+ value={value}
+ onChange={(v) => handleChangePermission(item.location, v, power)}
+ >
+ {(handleOpen, opened) => (
+ <Chip
+ variant={powerChanges ? 'Success' : 'Secondary'}
+ outlined={powerChanges}
+ fill="Soft"
+ radii="Pill"
+ aria-selected={opened}
+ disabled={!canChangePermission || applyingChanges}
+ after={
+ powerChanges && (
+ <Badge size="200" variant="Success" fill="Solid" radii="Pill" />
+ )
+ }
+ before={
+ canChangePermission && (
+ <Icon
+ size="50"
+ src={opened ? Icons.ChevronTop : Icons.ChevronBottom}
+ />
+ )
+ }
+ onClick={handleOpen}
+ >
+ <Text size="B300" truncate>
+ {tag.name}
+ </Text>
+ {value < maxPower && <Text size="T200">& Above</Text>}
+ </Chip>
+ )}
+ </PowerSwitcher>
+ }
+ />
+ </SequenceCard>
+ );
+ })}
+ </Box>
+ ))}
+
+ {hasChanges && (
+ <Menu
+ style={{
+ position: 'sticky',
+ padding: config.space.S200,
+ paddingLeft: config.space.S400,
+ bottom: config.space.S400,
+ left: config.space.S400,
+ right: 0,
+ zIndex: 1,
+ }}
+ variant="Success"
+ >
+ <Box alignItems="Center" gap="400">
+ <Box grow="Yes" direction="Column">
+ {applyState.status === AsyncStatus.Error ? (
+ <Text size="T200">
+ <b>Failed to apply changes! Please try again.</b>
+ </Text>
+ ) : (
+ <Text size="T200">
+ <b>Changes saved! Apply when ready.</b>
+ </Text>
+ )}
+ </Box>
+ <Box shrink="No" gap="200">
+ <Button
+ size="300"
+ variant="Success"
+ fill="None"
+ radii="300"
+ disabled={applyingChanges}
+ onClick={resetChanges}
+ >
+ <Text size="B300">Reset</Text>
+ </Button>
+ <Button
+ size="300"
+ variant="Success"
+ radii="300"
+ disabled={applyingChanges}
+ before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
+ onClick={handleApplyChanges}
+ >
+ <Text size="B300">Apply Changes</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Menu>
+ )}
+ </>
+ );
+}
--- /dev/null
+import React, { useState } from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { Powers } from './Powers';
+import { useRoom } from '../../../hooks/useRoom';
+import { usePowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { StateEvent } from '../../../../types/matrix/room';
+import { PowersEditor } from './PowersEditor';
+import { PermissionGroups } from './PermissionGroups';
+
+type PermissionsProps = {
+ requestClose: () => void;
+};
+export function Permissions({ requestClose }: PermissionsProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const powerLevels = usePowerLevels(room);
+ const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
+ const canEditPowers = canSendStateEvent(
+ StateEvent.PowerLevelTags,
+ getPowerLevel(mx.getSafeUserId())
+ );
+
+ const [powerEditor, setPowerEditor] = useState(false);
+
+ const handleEditPowers = () => {
+ setPowerEditor(true);
+ };
+
+ if (canEditPowers && powerEditor) {
+ return <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
+ }
+
+ return (
+ <Page>
+ <PageHeader outlined={false}>
+ <Box grow="Yes" gap="200">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <Text size="H3" truncate>
+ Permissions
+ </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">
+ <Powers
+ powerLevels={powerLevels}
+ onEdit={canEditPowers ? handleEditPowers : undefined}
+ />
+ <PermissionGroups powerLevels={powerLevels} />
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+/* eslint-disable react/no-array-index-key */
+import React, { useState, MouseEventHandler, ReactNode } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ Chip,
+ Text,
+ RectCords,
+ PopOut,
+ Menu,
+ Scroll,
+ toRem,
+ config,
+ color,
+} from 'folds';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
+import { SettingTile } from '../../../components/setting-tile';
+import { getPermissionPower, IPowerLevels } from '../../../hooks/usePowerLevels';
+import { useRoom } from '../../../hooks/useRoom';
+import { PowerColorBadge, PowerIcon } from '../../../components/power';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { stopPropagation } from '../../../utils/keyboard';
+import { usePermissionGroups } from './usePermissionItems';
+
+type PeekPermissionsProps = {
+ powerLevels: IPowerLevels;
+ power: number;
+ children: (handleOpen: MouseEventHandler<HTMLButtonElement>, opened: boolean) => ReactNode;
+};
+function PeekPermissions({ powerLevels, power, children }: PeekPermissionsProps) {
+ const [menuCords, setMenuCords] = useState<RectCords>();
+ const permissionGroups = usePermissionGroups();
+
+ const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ return (
+ <PopOut
+ anchor={menuCords}
+ offset={5}
+ position="Bottom"
+ align="Center"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ <Menu
+ style={{
+ maxHeight: '75vh',
+ maxWidth: toRem(300),
+ display: 'flex',
+ }}
+ >
+ <Box grow="Yes" tabIndex={0}>
+ <Scroll size="0" hideTrack visibility="Hover">
+ <Box style={{ padding: config.space.S200 }} direction="Column" gap="400">
+ {permissionGroups.map((group, groupIndex) => (
+ <Box key={groupIndex} direction="Column" gap="100">
+ <Text size="L400">{group.name}</Text>
+ <div>
+ {group.items.map((item, itemIndex) => {
+ const requiredPower = getPermissionPower(powerLevels, item.location);
+ const hasPower = requiredPower <= power;
+
+ return (
+ <Text
+ key={itemIndex}
+ size="T200"
+ style={{
+ color: hasPower ? undefined : color.Critical.Main,
+ }}
+ >
+ {hasPower ? '✅' : '❌'} {item.name}
+ </Text>
+ );
+ })}
+ </div>
+ </Box>
+ ))}
+ </Box>
+ </Scroll>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {children(handleOpen, !!menuCords)}
+ </PopOut>
+ );
+}
+
+type PowersProps = {
+ powerLevels: IPowerLevels;
+ onEdit?: () => void;
+};
+export function Powers({ powerLevels, onEdit }: PowersProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = useRoom();
+ const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
+
+ return (
+ <Box direction="Column" gap="100">
+ <SequenceCard
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="Power Levels"
+ description="Manage and customize incremental power levels for users."
+ after={
+ onEdit && (
+ <Box gap="200">
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ onClick={onEdit}
+ >
+ <Text size="B300">Edit</Text>
+ </Button>
+ </Box>
+ )
+ }
+ />
+ <SettingTile>
+ <Box gap="200" wrap="Wrap">
+ {getPowers(powerLevelTags).map((power) => {
+ const tag = powerLevelTags[power];
+ const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
+
+ return (
+ <PeekPermissions key={power} powerLevels={powerLevels} power={power}>
+ {(openMenu, opened) => (
+ <Chip
+ onClick={openMenu}
+ variant="Secondary"
+ aria-pressed={opened}
+ radii="300"
+ before={<PowerColorBadge color={tag.color} />}
+ after={tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
+ >
+ <Text size="T300" truncate>
+ <b>{tag.name}</b>
+ </Text>
+ </Chip>
+ )}
+ </PeekPermissions>
+ );
+ })}
+ </Box>
+ </SettingTile>
+ </SequenceCard>
+ </Box>
+ );
+}
--- /dev/null
+import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react';
+import {
+ Box,
+ Text,
+ Chip,
+ Icon,
+ Icons,
+ IconButton,
+ Scroll,
+ Button,
+ Input,
+ RectCords,
+ PopOut,
+ Menu,
+ config,
+ Spinner,
+ toRem,
+ TooltipProvider,
+ Tooltip,
+} from 'folds';
+import { HexColorPicker } from 'react-colorful';
+import { useAtomValue } from 'jotai';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { IPowerLevels } from '../../../hooks/usePowerLevels';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SequenceCardStyle } from '../styles.css';
+import { SettingTile } from '../../../components/setting-tile';
+import {
+ getPowers,
+ getTagIconSrc,
+ getUsedPowers,
+ PowerLevelTag,
+ PowerLevelTagIcon,
+ PowerLevelTags,
+ usePowerLevelTags,
+} from '../../../hooks/usePowerLevelTags';
+import { useRoom } from '../../../hooks/useRoom';
+import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut';
+import { PowerColorBadge, PowerIcon } from '../../../components/power';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useFilePicker } from '../../../hooks/useFilePicker';
+import { CompactUploadCardRenderer } from '../../../components/upload-card';
+import { createUploadAtom, UploadSuccess } from '../../../state/upload';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { StateEvent } from '../../../../types/matrix/room';
+import { useAlive } from '../../../hooks/useAlive';
+import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
+
+type EditPowerProps = {
+ maxPower: number;
+ power?: number;
+ tag?: PowerLevelTag;
+ onSave: (power: number, tag: PowerLevelTag) => void;
+ onClose: () => void;
+};
+function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
+ const mx = useMatrixClient();
+ const room = useRoom();
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const useAuthentication = useMediaAuthentication();
+
+ const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
+
+ const [iconFile, setIconFile] = useState<File>();
+ const pickFile = useFilePicker(setIconFile, false);
+
+ const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
+ const [tagIcon, setTagIcon] = useState<PowerLevelTagIcon | undefined>(tag?.icon);
+ const uploadingIcon = iconFile && !tagIcon;
+ const tagIconSrc = tagIcon && getTagIconSrc(mx, useAuthentication, tagIcon);
+
+ const iconUploadAtom = useMemo(() => {
+ if (iconFile) return createUploadAtom(iconFile);
+ return undefined;
+ }, [iconFile]);
+
+ const handleRemoveIconUpload = useCallback(() => {
+ setIconFile(undefined);
+ }, []);
+
+ const handleIconUploaded = useCallback((upload: UploadSuccess) => {
+ setTagIcon({
+ key: upload.mxc,
+ });
+ setIconFile(undefined);
+ }, []);
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (uploadingIcon) return;
+
+ const target = evt.target as HTMLFormElement | undefined;
+ const powerInput = target?.powerInput as HTMLInputElement | undefined;
+ const nameInput = target?.nameInput as HTMLInputElement | undefined;
+ if (!powerInput || !nameInput) return;
+
+ const tagPower = parseInt(powerInput.value, 10);
+ if (Number.isNaN(tagPower)) return;
+ if (tagPower > maxPower) return;
+ const tagName = nameInput.value.trim();
+ if (!tagName) return;
+
+ const editedTag: PowerLevelTag = {
+ name: tagName,
+ color: tagColor,
+ icon: tagIcon,
+ };
+
+ onSave(power ?? tagPower, editedTag);
+ onClose();
+ };
+
+ return (
+ <Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
+ <Box direction="Column" gap="300">
+ <Box gap="200">
+ <Box shrink="No" direction="Column" gap="100">
+ <Text size="L400">Color</Text>
+ <Box gap="200">
+ <HexColorPickerPopOut
+ picker={<HexColorPicker color={tagColor} onChange={setTagColor} />}
+ onRemove={() => setTagColor(undefined)}
+ >
+ {(openPicker, opened) => (
+ <Button
+ aria-pressed={opened}
+ onClick={openPicker}
+ size="300"
+ type="button"
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ before={<PowerColorBadge color={tagColor} />}
+ >
+ <Text size="B300">Pick</Text>
+ </Button>
+ )}
+ </HexColorPickerPopOut>
+ </Box>
+ </Box>
+ <Box grow="Yes" direction="Column" gap="100">
+ <Text size="L400">Name</Text>
+ <Input
+ name="nameInput"
+ defaultValue={tag?.name}
+ placeholder="Bot"
+ size="300"
+ variant="Secondary"
+ radii="300"
+ required
+ />
+ </Box>
+ <Box style={{ maxWidth: toRem(74) }} grow="Yes" direction="Column" gap="100">
+ <Text size="L400">Power</Text>
+ <Input
+ defaultValue={power}
+ name="powerInput"
+ size="300"
+ variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
+ radii="300"
+ type="number"
+ placeholder="75"
+ max={maxPower}
+ outlined={typeof power === 'number'}
+ readOnly={typeof power === 'number'}
+ required
+ />
+ </Box>
+ </Box>
+ </Box>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Icon</Text>
+ {iconUploadAtom && !tagIconSrc ? (
+ <CompactUploadCardRenderer
+ uploadAtom={iconUploadAtom}
+ onRemove={handleRemoveIconUpload}
+ onComplete={handleIconUploaded}
+ />
+ ) : (
+ <Box gap="200" alignItems="Center">
+ {tagIconSrc ? (
+ <>
+ <PowerIcon size="500" iconSrc={tagIconSrc} />
+ <Button
+ onClick={() => setTagIcon(undefined)}
+ type="button"
+ size="300"
+ variant="Critical"
+ fill="None"
+ radii="300"
+ >
+ <Text size="B300">Remove</Text>
+ </Button>
+ </>
+ ) : (
+ <>
+ <UseStateProvider initial={undefined}>
+ {(cords: RectCords | undefined, setCords) => (
+ <PopOut
+ position="Bottom"
+ anchor={cords}
+ content={
+ <EmojiBoard
+ imagePackRooms={imagePackRooms}
+ returnFocusOnDeactivate={false}
+ allowTextCustomEmoji={false}
+ addToRecentEmoji={false}
+ onEmojiSelect={(key) => {
+ setTagIcon({ key });
+ setCords(undefined);
+ }}
+ onCustomEmojiSelect={(mxc) => {
+ setTagIcon({ key: mxc });
+ setCords(undefined);
+ }}
+ requestClose={() => {
+ setCords(undefined);
+ }}
+ />
+ }
+ >
+ <Button
+ onClick={
+ ((evt) =>
+ setCords(
+ evt.currentTarget.getBoundingClientRect()
+ )) as MouseEventHandler<HTMLButtonElement>
+ }
+ type="button"
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ before={<Icon size="50" src={Icons.SmilePlus} />}
+ >
+ <Text size="B300">Pick</Text>
+ </Button>
+ </PopOut>
+ )}
+ </UseStateProvider>
+ <Button
+ onClick={() => pickFile('image/*')}
+ type="button"
+ size="300"
+ variant="Secondary"
+ fill="None"
+ radii="300"
+ >
+ <Text size="B300">Import</Text>
+ </Button>
+ </>
+ )}
+ </Box>
+ )}
+ </Box>
+ <Box direction="Row" gap="200" justifyContent="Start">
+ <Button
+ style={{ minWidth: toRem(64) }}
+ type="submit"
+ size="300"
+ variant="Success"
+ radii="300"
+ disabled={uploadingIcon}
+ >
+ <Text size="B300">Save</Text>
+ </Button>
+ <Button
+ type="button"
+ size="300"
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ onClick={onClose}
+ >
+ <Text size="B300">Cancel</Text>
+ </Button>
+ </Box>
+ </Box>
+ );
+}
+
+type PowersEditorProps = {
+ powerLevels: IPowerLevels;
+ requestClose: () => void;
+};
+export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = useRoom();
+ const alive = useAlive();
+ const [usedPowers, maxPower] = useMemo(() => {
+ const up = getUsedPowers(powerLevels);
+ return [up, Math.max(...Array.from(up))];
+ }, [powerLevels]);
+
+ const [powerLevelTags] = usePowerLevelTags(room, powerLevels);
+ const [editedPowerTags, setEditedPowerTags] = useState<PowerLevelTags>();
+ const [deleted, setDeleted] = useState<Set<number>>(new Set());
+
+ const [createTag, setCreateTag] = useState(false);
+
+ const handleToggleDelete = useCallback((power: number) => {
+ setDeleted((powers) => {
+ const newIds = new Set(powers);
+ if (newIds.has(power)) {
+ newIds.delete(power);
+ } else {
+ newIds.add(power);
+ }
+ return newIds;
+ });
+ }, []);
+
+ const handleSaveTag = useCallback(
+ (power: number, tag: PowerLevelTag) => {
+ setEditedPowerTags((tags) => {
+ const editedTags = { ...(tags ?? powerLevelTags) };
+ editedTags[power] = tag;
+ return editedTags;
+ });
+ },
+ [powerLevelTags]
+ );
+
+ const [applyState, applyChanges] = useAsyncCallback(
+ useCallback(async () => {
+ const content: PowerLevelTags = { ...(editedPowerTags ?? powerLevelTags) };
+ deleted.forEach((power) => {
+ delete content[power];
+ });
+ await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content);
+ }, [mx, room, powerLevelTags, editedPowerTags, deleted])
+ );
+
+ const resetChanges = useCallback(() => {
+ setEditedPowerTags(undefined);
+ setDeleted(new Set());
+ }, []);
+
+ const handleApplyChanges = () => {
+ applyChanges().then(() => {
+ if (alive()) {
+ resetChanges();
+ }
+ });
+ };
+
+ const applyingChanges = applyState.status === AsyncStatus.Loading;
+ const hasChanges = editedPowerTags || deleted.size > 0;
+
+ const powerTags = editedPowerTags ?? powerLevelTags;
+ return (
+ <Page>
+ <PageHeader outlined={false} balance>
+ <Box alignItems="Center" grow="Yes" gap="200">
+ <Box alignItems="Inherit" grow="Yes" gap="200">
+ <Chip
+ size="500"
+ radii="Pill"
+ onClick={requestClose}
+ before={<Icon size="100" src={Icons.ArrowLeft} />}
+ >
+ <Text size="T300">Permissions</Text>
+ </Chip>
+ </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">
+ <Box direction="Column" gap="100">
+ <Box alignItems="Baseline" gap="200" justifyContent="SpaceBetween">
+ <Text size="L400">Power Levels</Text>
+ <BetaNoticeBadge />
+ </Box>
+ <SequenceCard
+ variant="SurfaceVariant"
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <SettingTile
+ title="New Power Level"
+ description="Create a new power level."
+ after={
+ !createTag && (
+ <Button
+ onClick={() => setCreateTag(true)}
+ variant="Secondary"
+ fill="Soft"
+ size="300"
+ radii="300"
+ outlined
+ disabled={applyingChanges}
+ >
+ <Text size="B300">Create</Text>
+ </Button>
+ )
+ }
+ />
+ {createTag && (
+ <EditPower
+ maxPower={maxPower}
+ onSave={handleSaveTag}
+ onClose={() => setCreateTag(false)}
+ />
+ )}
+ </SequenceCard>
+ {getPowers(powerTags).map((power) => {
+ const tag = powerTags[power];
+ const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
+
+ return (
+ <SequenceCard
+ key={power}
+ variant={deleted.has(power) ? 'Critical' : 'SurfaceVariant'}
+ className={SequenceCardStyle}
+ direction="Column"
+ gap="400"
+ >
+ <UseStateProvider initial={false}>
+ {(edit, setEdit) =>
+ edit ? (
+ <EditPower
+ maxPower={maxPower}
+ power={power}
+ tag={tag}
+ onSave={handleSaveTag}
+ onClose={() => setEdit(false)}
+ />
+ ) : (
+ <SettingTile
+ before={<PowerColorBadge color={tag.color} />}
+ title={
+ <Box as="span" alignItems="Center" gap="200">
+ <b>{deleted.has(power) ? <s>{tag.name}</s> : tag.name}</b>
+ <Box as="span" shrink="No" alignItems="Inherit" gap="Inherit">
+ {tagIconSrc && <PowerIcon size="50" iconSrc={tagIconSrc} />}
+ <Text as="span" size="T200" priority="300">
+ ({power})
+ </Text>
+ </Box>
+ </Box>
+ }
+ after={
+ deleted.has(power) ? (
+ <Chip
+ variant="Critical"
+ radii="Pill"
+ disabled={applyingChanges}
+ onClick={() => handleToggleDelete(power)}
+ >
+ <Text size="B300">Undo</Text>
+ </Chip>
+ ) : (
+ <Box shrink="No" alignItems="Center" gap="200">
+ <TooltipProvider
+ tooltip={
+ <Tooltip style={{ maxWidth: toRem(200) }}>
+ {usedPowers.has(power) ? (
+ <Box direction="Column">
+ <Text size="L400">Used Power Level</Text>
+ <Text size="T200">
+ You have to remove its use before you can delete it.
+ </Text>
+ </Box>
+ ) : (
+ <Text>Delete</Text>
+ )}
+ </Tooltip>
+ }
+ >
+ {(triggerRef) => (
+ <Chip
+ ref={triggerRef}
+ variant="Secondary"
+ fill="None"
+ radii="Pill"
+ disabled={applyingChanges}
+ aria-disabled={usedPowers.has(power)}
+ onClick={
+ usedPowers.has(power)
+ ? undefined
+ : () => handleToggleDelete(power)
+ }
+ >
+ <Icon size="50" src={Icons.Delete} />
+ </Chip>
+ )}
+ </TooltipProvider>
+ <Chip
+ variant="Secondary"
+ radii="Pill"
+ disabled={applyingChanges}
+ onClick={() => setEdit(true)}
+ >
+ <Text size="B300">Edit</Text>
+ </Chip>
+ </Box>
+ )
+ }
+ />
+ )
+ }
+ </UseStateProvider>
+ </SequenceCard>
+ );
+ })}
+ </Box>
+ {hasChanges && (
+ <Menu
+ style={{
+ position: 'sticky',
+ padding: config.space.S200,
+ paddingLeft: config.space.S400,
+ bottom: config.space.S400,
+ left: config.space.S400,
+ right: 0,
+ zIndex: 1,
+ }}
+ variant="Success"
+ >
+ <Box alignItems="Center" gap="400">
+ <Box grow="Yes" direction="Column">
+ {applyState.status === AsyncStatus.Error ? (
+ <Text size="T200">
+ <b>Failed to apply changes! Please try again.</b>
+ </Text>
+ ) : (
+ <Text size="T200">
+ <b>Changes saved! Apply when ready.</b>
+ </Text>
+ )}
+ </Box>
+ <Box shrink="No" gap="200">
+ <Button
+ size="300"
+ variant="Success"
+ fill="None"
+ radii="300"
+ disabled={applyingChanges}
+ onClick={resetChanges}
+ >
+ <Text size="B300">Reset</Text>
+ </Button>
+ <Button
+ size="300"
+ variant="Success"
+ radii="300"
+ disabled={applyingChanges}
+ before={
+ applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />
+ }
+ onClick={handleApplyChanges}
+ >
+ <Text size="B300">Apply Changes</Text>
+ </Button>
+ </Box>
+ </Box>
+ </Menu>
+ )}
+ </Box>
+ </PageContent>
+ </Scroll>
+ </Box>
+ </Page>
+ );
+}
--- /dev/null
+export * from './Permissions';
--- /dev/null
+import { useMemo } from 'react';
+import { PermissionLocation } from '../../../hooks/usePowerLevels';
+import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
+
+export type PermissionItem = {
+ location: PermissionLocation;
+ name: string;
+ description?: string;
+};
+
+export type PermissionGroup = {
+ name: string;
+ items: PermissionItem[];
+};
+
+export const usePermissionGroups = (): PermissionGroup[] => {
+ const groups: PermissionGroup[] = useMemo(() => {
+ const messagesGroup: PermissionGroup = {
+ name: 'Messages',
+ items: [
+ {
+ location: {
+ key: MessageEvent.RoomMessage,
+ },
+ name: 'Send Messages',
+ },
+ {
+ location: {
+ key: MessageEvent.Sticker,
+ },
+ name: 'Send Stickers',
+ },
+ {
+ location: {
+ key: MessageEvent.Reaction,
+ },
+ name: 'Send Reactions',
+ },
+ {
+ location: {
+ notification: true,
+ key: 'room',
+ },
+ name: 'Ping @room',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomPinnedEvents,
+ },
+ name: 'Pin Messages',
+ },
+ {
+ location: {},
+ name: 'Other Message Events',
+ },
+ ],
+ };
+
+ const moderationGroup: PermissionGroup = {
+ name: 'Moderation',
+ items: [
+ {
+ location: {
+ action: true,
+ key: 'invite',
+ },
+ name: 'Invite',
+ },
+ {
+ location: {
+ action: true,
+ key: 'kick',
+ },
+ name: 'Kick',
+ },
+ {
+ location: {
+ action: true,
+ key: 'ban',
+ },
+ name: 'Ban',
+ },
+ {
+ location: {
+ action: true,
+ key: 'redact',
+ },
+ name: 'Delete Others Messages',
+ },
+ {
+ location: {
+ key: MessageEvent.RoomRedaction,
+ },
+ name: 'Delete Self Messages',
+ },
+ ],
+ };
+
+ const roomOverviewGroup: PermissionGroup = {
+ name: 'Room Overview',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomAvatar,
+ },
+ name: 'Room Avatar',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomName,
+ },
+ name: 'Room Name',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomTopic,
+ },
+ name: 'Room Topic',
+ },
+ ],
+ };
+
+ const roomSettingsGroup: PermissionGroup = {
+ name: 'Settings',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomJoinRules,
+ },
+ name: 'Change Room Access',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomCanonicalAlias,
+ },
+ name: 'Publish Address',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomPowerLevels,
+ },
+ name: 'Change All Permission',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.PowerLevelTags,
+ },
+ name: 'Edit Power Levels',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomEncryption,
+ },
+ name: 'Enable Encryption',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomHistoryVisibility,
+ },
+ name: 'History Visibility',
+ },
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomTombstone,
+ },
+ name: 'Upgrade Room',
+ },
+ {
+ location: {
+ state: true,
+ },
+ name: 'Other Settings',
+ },
+ ],
+ };
+
+ const otherSettingsGroup: PermissionGroup = {
+ name: 'Other',
+ items: [
+ {
+ location: {
+ state: true,
+ key: StateEvent.RoomServerAcl,
+ },
+ name: 'Change Server ACLs',
+ },
+ {
+ location: {
+ state: true,
+ key: 'im.vector.modular.widgets',
+ },
+ name: 'Modify Widgets',
+ },
+ ],
+ };
+
+ return [
+ messagesGroup,
+ moderationGroup,
+ roomOverviewGroup,
+ roomSettingsGroup,
+ otherSettingsGroup,
+ ];
+ }, []);
+
+ return groups;
+};
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const SequenceCardStyle = style({
+ padding: config.space.S300,
+});
Badge,
Box,
Chip,
- ContainerColor,
Header,
Icon,
IconButton,
Icons,
Input,
- Menu,
MenuItem,
PopOut,
RectCords,
} from 'folds';
import { Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
-import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { openProfileViewer } from '../../../client/action/navigation';
import * as css from './MembersDrawer.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { Membership } from '../../../types/matrix/room';
import { UseStateProvider } from '../../components/UseStateProvider';
import {
SearchItemStrGetter,
useAsyncSearch,
} from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
-import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { usePowerLevelTags, useFlattenPowerLevelTagMembers } from '../../hooks/usePowerLevelTags';
import { TypingIndicator } from '../../components/typing-indicator';
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { ScrollTopContainer } from '../../components/scroll-top-container';
import { UserAvatar } from '../../components/user-avatar';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
-import { stopPropagation } from '../../utils/keyboard';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-
-export const MembershipFilters = {
- filterJoined: (m: RoomMember) => m.membership === Membership.Join,
- filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
- filterLeaved: (m: RoomMember) =>
- m.membership === Membership.Leave &&
- m.events.member?.getStateKey() === m.events.member?.getSender(),
- filterKicked: (m: RoomMember) =>
- m.membership === Membership.Leave &&
- m.events.member?.getStateKey() !== m.events.member?.getSender(),
- filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
-};
-
-export type MembershipFilterFn = (m: RoomMember) => boolean;
-
-export type MembershipFilter = {
- name: string;
- filterFn: MembershipFilterFn;
- color: ContainerColor;
-};
-
-const useMembershipFilterMenu = (): MembershipFilter[] =>
- useMemo(
- () => [
- {
- name: 'Joined',
- filterFn: MembershipFilters.filterJoined,
- color: 'Background',
- },
- {
- name: 'Invited',
- filterFn: MembershipFilters.filterInvited,
- color: 'Success',
- },
- {
- name: 'Left',
- filterFn: MembershipFilters.filterLeaved,
- color: 'Secondary',
- },
- {
- name: 'Kicked',
- filterFn: MembershipFilters.filterKicked,
- color: 'Warning',
- },
- {
- name: 'Banned',
- filterFn: MembershipFilters.filterBanned,
- color: 'Critical',
- },
- ],
- []
- );
-
-export const SortFilters = {
- filterAscending: (a: RoomMember, b: RoomMember) =>
- a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
- filterDescending: (a: RoomMember, b: RoomMember) =>
- a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
- filterNewestFirst: (a: RoomMember, b: RoomMember) =>
- (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
- filterOldest: (a: RoomMember, b: RoomMember) =>
- (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
-};
-
-export type SortFilterFn = (a: RoomMember, b: RoomMember) => number;
-
-export type SortFilter = {
- name: string;
- filterFn: SortFilterFn;
-};
-
-const useSortFilterMenu = (): SortFilter[] =>
- useMemo(
- () => [
- {
- name: 'A to Z',
- filterFn: SortFilters.filterAscending,
- },
- {
- name: 'Z to A',
- filterFn: SortFilters.filterDescending,
- },
- {
- name: 'Newest',
- filterFn: SortFilters.filterNewestFirst,
- },
- {
- name: 'Oldest',
- filterFn: SortFilters.filterOldest,
- },
- ],
- []
- );
-
-export type MembersFilterOptions = {
- membershipFilter: MembershipFilter;
- sortFilter: SortFilter;
-};
+import { useMembershipFilter, useMembershipFilterMenu } from '../../hooks/useMemberFilter';
+import { useMemberSort, useMemberSortMenu } from '../../hooks/useMemberSort';
+import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
+import { MembershipFilterMenu } from '../../components/MembershipFilterMenu';
+import { MemberSortMenu } from '../../components/MemberSortMenu';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
const scrollRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
- const getPowerLevelTag = usePowerLevelTags();
+ const powerLevels = usePowerLevelsContext();
+ const [, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const fetchingMembers = members.length < room.getJoinedMemberCount();
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const membershipFilterMenu = useMembershipFilterMenu();
- const sortFilterMenu = useSortFilterMenu();
+ const sortFilterMenu = useMemberSortMenu();
const [sortFilterIndex, setSortFilterIndex] = useSetting(settingsAtom, 'memberSortFilterIndex');
const [membershipFilterIndex, setMembershipFilterIndex] = useState(0);
+ const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
- const membershipFilter = membershipFilterMenu[membershipFilterIndex] ?? membershipFilterMenu[0];
- const sortFilter = sortFilterMenu[sortFilterIndex] ?? sortFilterMenu[0];
+ const membershipFilter = useMembershipFilter(membershipFilterIndex, membershipFilterMenu);
+ const memberSort = useMemberSort(sortFilterIndex, sortFilterMenu);
const typingMembers = useRoomTypingMember(room.roomId);
() =>
members
.filter(membershipFilter.filterFn)
- .sort(sortFilter.filterFn)
+ .sort(memberSort.sortFn)
.sort((a, b) => b.powerLevel - a.powerLevel),
- [members, membershipFilter, sortFilter]
+ [members, membershipFilter, memberSort]
);
const [result, search, resetSearch] = useAsyncSearch(
const processMembers = result ? result.items : filteredMembers;
- const PLTagOrRoomMember = useMemo(() => {
- let prevTag: PowerLevelTag | undefined;
- const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
- processMembers.forEach((m) => {
- const plTag = getPowerLevelTag(m.powerLevel);
- if (plTag !== prevTag) {
- prevTag = plTag;
- tagOrMember.push(plTag);
- }
- tagOrMember.push(m);
- });
- return tagOrMember;
- }, [processMembers, getPowerLevelTag]);
+ const PLTagOrRoomMember = useFlattenPowerLevelTagMembers(
+ processMembers,
+ getPowerLevel,
+ getPowerLevelTag
+ );
const virtualizer = useVirtualizer({
count: PLTagOrRoomMember.length,
align="Start"
offset={4}
content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setAnchor(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
- <Menu style={{ padding: config.space.S100 }}>
- {membershipFilterMenu.map((menuItem, index) => (
- <MenuItem
- key={menuItem.name}
- variant={
- menuItem.name === membershipFilter.name
- ? menuItem.color
- : 'Surface'
- }
- aria-pressed={menuItem.name === membershipFilter.name}
- size="300"
- radii="300"
- onClick={() => {
- setMembershipFilterIndex(index);
- setAnchor(undefined);
- }}
- >
- <Text size="T300">{menuItem.name}</Text>
- </MenuItem>
- ))}
- </Menu>
- </FocusTrap>
+ <MembershipFilterMenu
+ selected={membershipFilterIndex}
+ onSelect={setMembershipFilterIndex}
+ requestClose={() => setAnchor(undefined)}
+ />
}
>
<Chip
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
- variant={membershipFilter.color}
+ variant="Background"
size="400"
radii="300"
before={<Icon src={Icons.Filter} size="50" />}
align="End"
offset={4}
content={
- <FocusTrap
- focusTrapOptions={{
- initialFocus: false,
- onDeactivate: () => setAnchor(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
- <Menu style={{ padding: config.space.S100 }}>
- {sortFilterMenu.map((menuItem, index) => (
- <MenuItem
- key={menuItem.name}
- variant="Surface"
- aria-pressed={menuItem.name === sortFilter.name}
- size="300"
- radii="300"
- onClick={() => {
- setSortFilterIndex(index);
- setAnchor(undefined);
- }}
- >
- <Text size="T300">{menuItem.name}</Text>
- </MenuItem>
- ))}
- </Menu>
- </FocusTrap>
+ <MemberSortMenu
+ selected={sortFilterIndex}
+ onSelect={setSortFilterIndex}
+ requestClose={() => setAnchor(undefined)}
+ />
}
>
<Chip
radii="300"
after={<Icon src={Icons.Sort} size="50" />}
>
- <Text size="T200">{sortFilter.name}</Text>
+ <Text size="T200">{memberSort.name}</Text>
</Chip>
</PopOut>
)}
forwardRef,
useCallback,
useEffect,
- useMemo,
useRef,
useState,
} from 'react';
getVideoMsgContent,
} from './msgContent';
import colorMXID from '../../../util/colorMXID';
-import {
- getAllParents,
- getMemberDisplayName,
- getMentionContent,
- trimReplyFromBody,
-} from '../../utils/room';
+import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useImagePackRooms } from '../../hooks/useImagePackRooms';
interface RoomInputProps {
editor: Editor;
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
- const imagePackRooms: Room[] = useMemo(() => {
- const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
- return allParentSpaces.reduce<Room[]>((list, rId) => {
- const r = mx.getRoom(rId);
- if (r) list.push(r);
- return list;
- }, []);
- }, [mx, roomId, roomToParents]);
+ const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] =
import {
canEditEvent,
decryptAllTimelineEvent,
- getAllParents,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
+import { useImagePackRooms } from '../../hooks/useImagePackRooms';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
- const imagePackRooms: Room[] = useMemo(() => {
- const allParentSpaces = [room.roomId].concat(
- Array.from(getAllParents(roomToParents, room.roomId))
- );
- return allParentSpaces.reduce<Room[]>((list, rId) => {
- const r = mx.getRoom(rId);
- if (r) list.push(r);
- return list;
- }, []);
- }, [mx, room, roomToParents]);
+ const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
const readUptoEventIdRef = useRef<string>();
import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../../client/action/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
-import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
+import { openInviteUser } from '../../../client/action/navigation';
import { copyToClipboard } from '../../utils/dom';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
+import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
type RoomMenuProps = {
room: Room;
requestClose();
};
- const handleRoomSettings = () => {
- toggleRoomSettings(room.roomId);
+ const openSettings = useOpenRoomSettings();
+ const parentSpace = useSpaceOptionally();
+ const handleOpenSettings = () => {
+ openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
</Text>
</MenuItem>
<MenuItem
- onClick={handleRoomSettings}
+ onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
import React, { useCallback, useState } from 'react';
-import { Box, Text, Icon, Icons, Chip, Button } from 'folds';
+import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
+import { CutoutCard } from '../../../components/cutout-card';
type AccountDataProps = {
expand: boolean;
};
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
const mx = useMatrixClient();
- const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values()));
+ const [accountDataTypes, setAccountDataKeys] = useState(() =>
+ Array.from(mx.store.accountData.keys())
+ );
useAccountDataCallback(
mx,
- useCallback(
- () => setAccountData(Array.from(mx.store.accountData.values())),
- [mx, setAccountData]
- )
+ useCallback(() => {
+ setAccountDataKeys(Array.from(mx.store.accountData.keys()));
+ }, [mx])
);
return (
}
/>
{expand && (
- <SettingTile>
- <Box direction="Column" gap="200">
- <Text size="L400">Types</Text>
- <Box gap="200" wrap="Wrap">
- <Chip
- variant="Secondary"
- fill="Soft"
- radii="Pill"
- before={<Icon size="50" src={Icons.Plus} />}
- onClick={() => onSelect(null)}
- >
+ <Box direction="Column" gap="100">
+ <Box justifyContent="SpaceBetween">
+ <Text size="L400">Events</Text>
+ <Text size="L400">Total: {accountDataTypes.length}</Text>
+ </Box>
+ <CutoutCard>
+ <MenuItem
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ before={<Icon size="50" src={Icons.Plus} />}
+ onClick={() => onSelect(null)}
+ >
+ <Box grow="Yes">
<Text size="T200" truncate>
Add New
</Text>
- </Chip>
- {accountData.map((mEvent) => (
- <Chip
- key={mEvent.getType()}
- variant="Secondary"
- fill="Soft"
- radii="Pill"
- onClick={() => onSelect(mEvent.getType())}
- >
+ </Box>
+ </MenuItem>
+ {accountDataTypes.sort().map((type) => (
+ <MenuItem
+ key={type}
+ variant="Surface"
+ fill="None"
+ size="300"
+ radii="0"
+ after={<Icon size="50" src={Icons.ChevronRight} />}
+ onClick={() => onSelect(type)}
+ >
+ <Box grow="Yes">
<Text size="T200" truncate>
- {mEvent.getType()}
+ {type}
</Text>
- </Chip>
- ))}
- </Box>
- </Box>
- </SettingTile>
+ </Box>
+ </MenuItem>
+ ))}
+ </CutoutCard>
+ </Box>
)}
</SequenceCard>
</Box>
+++ /dev/null
-import React, {
- FormEventHandler,
- KeyboardEventHandler,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import {
- Box,
- Text,
- Icon,
- Icons,
- IconButton,
- Input,
- Button,
- TextArea as TextAreaComponent,
- color,
- Spinner,
- Chip,
- Scroll,
- config,
-} from 'folds';
-import { isKeyHotkey } from 'is-hotkey';
-import { MatrixError } from 'matrix-js-sdk';
-import * as css from './styles.css';
-import { useTextAreaIntentHandler } from '../../../hooks/useTextAreaIntent';
-import { Cursor, Intent, TextArea, TextAreaOperations } from '../../../plugins/text-area';
-import { GetTarget } from '../../../plugins/text-area/type';
-import { syntaxErrorPosition } from '../../../utils/dom';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { Page, PageHeader } from '../../../components/page';
-import { useAlive } from '../../../hooks/useAlive';
-import { SequenceCard } from '../../../components/sequence-card';
-import { TextViewerContent } from '../../../components/text-viewer';
-
-const EDITOR_INTENT_SPACE_COUNT = 2;
-
-type AccountDataInfo = {
- type: string;
- content: object;
-};
-
-type AccountDataEditProps = {
- type: string;
- defaultContent: string;
- onCancel: () => void;
- onSave: (info: AccountDataInfo) => void;
-};
-function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) {
- const mx = useMatrixClient();
- const alive = useAlive();
-
- const textAreaRef = useRef<HTMLTextAreaElement>(null);
- const [jsonError, setJSONError] = useState<SyntaxError>();
-
- const getTarget: GetTarget = useCallback(() => {
- const target = textAreaRef.current;
- if (!target) throw new Error('TextArea element not found!');
- return target;
- }, []);
-
- const { textArea, operations, intent } = useMemo(() => {
- const ta = new TextArea(getTarget);
- const op = new TextAreaOperations(getTarget);
- return {
- textArea: ta,
- operations: op,
- intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op),
- };
- }, [getTarget]);
-
- const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
-
- const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
- intentHandler(evt);
- if (isKeyHotkey('escape', evt)) {
- const cursor = Cursor.fromTextAreaElement(getTarget());
- operations.deselect(cursor);
- }
- };
-
- const [submitState, submit] = useAsyncCallback<object, MatrixError, [string, object]>(
- useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx])
- );
- const submitting = submitState.status === AsyncStatus.Loading;
-
- const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
- evt.preventDefault();
- if (submitting) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const typeInput = target?.typeInput as HTMLInputElement | undefined;
- const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined;
- if (!typeInput || !contentTextArea) return;
-
- const typeStr = typeInput.value.trim();
- const contentStr = contentTextArea.value.trim();
-
- let parsedContent: object;
- try {
- parsedContent = JSON.parse(contentStr);
- } catch (e) {
- setJSONError(e as SyntaxError);
- return;
- }
- setJSONError(undefined);
-
- if (
- !typeStr ||
- parsedContent === null ||
- defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
- ) {
- return;
- }
-
- submit(typeStr, parsedContent).then(() => {
- if (alive()) {
- onSave({
- type: typeStr,
- content: parsedContent,
- });
- }
- });
- };
-
- useEffect(() => {
- if (jsonError) {
- const errorPosition = syntaxErrorPosition(jsonError) ?? 0;
- const cursor = new Cursor(errorPosition, errorPosition, 'none');
- operations.select(cursor);
- getTarget()?.focus();
- }
- }, [jsonError, operations, getTarget]);
-
- return (
- <Box
- as="form"
- onSubmit={handleSubmit}
- grow="Yes"
- className={css.EditorContent}
- direction="Column"
- gap="400"
- aria-disabled={submitting}
- >
- <Box shrink="No" direction="Column" gap="100">
- <Text size="L400">Account Data</Text>
- <Box gap="300">
- <Box grow="Yes" direction="Column">
- <Input
- variant={type.length > 0 || submitting ? 'SurfaceVariant' : 'Background'}
- name="typeInput"
- size="400"
- radii="300"
- readOnly={type.length > 0 || submitting}
- defaultValue={type}
- required
- />
- </Box>
- <Button
- variant="Success"
- size="400"
- radii="300"
- type="submit"
- disabled={submitting}
- before={submitting && <Spinner variant="Primary" fill="Solid" size="300" />}
- >
- <Text size="B400">Save</Text>
- </Button>
- <Button
- variant="Secondary"
- fill="Soft"
- size="400"
- radii="300"
- onClick={onCancel}
- disabled={submitting}
- >
- <Text size="B400">Cancel</Text>
- </Button>
- </Box>
-
- {submitState.status === AsyncStatus.Error && (
- <Text size="T200" style={{ color: color.Critical.Main }}>
- <b>{submitState.error.message}</b>
- </Text>
- )}
- </Box>
- <Box grow="Yes" direction="Column" gap="100">
- <Box shrink="No">
- <Text size="L400">JSON Content</Text>
- </Box>
- <TextAreaComponent
- ref={textAreaRef}
- name="contentTextArea"
- className={css.EditorTextArea}
- onKeyDown={handleKeyDown}
- defaultValue={defaultContent}
- resize="None"
- spellCheck="false"
- required
- readOnly={submitting}
- />
- {jsonError && (
- <Text size="T200" style={{ color: color.Critical.Main }}>
- <b>
- {jsonError.name}: {jsonError.message}
- </b>
- </Text>
- )}
- </Box>
- </Box>
- );
-}
-
-type AccountDataViewProps = {
- type: string;
- defaultContent: string;
- onEdit: () => void;
-};
-function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
- return (
- <Box direction="Column" className={css.EditorContent} gap="400">
- <Box shrink="No" gap="300" alignItems="End">
- <Box grow="Yes" direction="Column" gap="100">
- <Text size="L400">Account Data</Text>
- <Input
- variant="SurfaceVariant"
- size="400"
- radii="300"
- readOnly
- defaultValue={type}
- required
- />
- </Box>
- <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
- <Text size="B400">Edit</Text>
- </Button>
- </Box>
- <Box grow="Yes" direction="Column" gap="100">
- <Text size="L400">JSON Content</Text>
- <SequenceCard variant="SurfaceVariant">
- <Scroll visibility="Always" size="300" hideTrack>
- <TextViewerContent
- size="T300"
- style={{
- padding: `${config.space.S300} ${config.space.S100} ${config.space.S300} ${config.space.S300}`,
- }}
- text={defaultContent}
- langName="JSON"
- />
- </Scroll>
- </SequenceCard>
- </Box>
- </Box>
- );
-}
-
-export type AccountDataEditorProps = {
- type?: string;
- requestClose: () => void;
-};
-
-export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) {
- const mx = useMatrixClient();
-
- const [data, setData] = useState<AccountDataInfo>({
- type: type ?? '',
- content: mx.getAccountData(type ?? '')?.getContent() ?? {},
- });
-
- const [edit, setEdit] = useState(!type);
-
- const closeEdit = useCallback(() => {
- if (!type) {
- requestClose();
- return;
- }
- setEdit(false);
- }, [type, requestClose]);
-
- const handleSave = useCallback((info: AccountDataInfo) => {
- setData(info);
- setEdit(false);
- }, []);
-
- const contentJSONStr = useMemo(
- () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT),
- [data.content]
- );
-
- return (
- <Page>
- <PageHeader outlined={false} balance>
- <Box alignItems="Center" grow="Yes" gap="200">
- <Box alignItems="Inherit" grow="Yes" gap="200">
- <Chip
- size="500"
- radii="Pill"
- onClick={requestClose}
- before={<Icon size="100" src={Icons.ArrowLeft} />}
- >
- <Text size="T300">Developer Tools</Text>
- </Chip>
- </Box>
- <Box shrink="No">
- <IconButton onClick={requestClose} variant="Surface">
- <Icon src={Icons.Cross} />
- </IconButton>
- </Box>
- </Box>
- </PageHeader>
- <Box grow="Yes" direction="Column">
- {edit ? (
- <AccountDataEdit
- type={data.type}
- defaultContent={contentJSONStr}
- onCancel={closeEdit}
- onSave={handleSave}
- />
- ) : (
- <AccountDataView
- type={data.type}
- defaultContent={contentJSONStr}
- onEdit={() => setEdit(true)}
- />
- )}
- </Box>
- </Page>
- );
-}
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { AccountDataEditor } from './AccountDataEditor';
+import {
+ AccountDataEditor,
+ AccountDataSubmitCallback,
+} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();
+ const submitAccountData: AccountDataSubmitCallback = useCallback(
+ async (type, content) => {
+ await mx.setAccountData(type, content);
+ },
+ [mx]
+ );
+
if (accountDataType !== undefined) {
return (
<AccountDataEditor
type={accountDataType ?? undefined}
+ content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
+ submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
/>
);
+++ /dev/null
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, config } from 'folds';
-
-export const EditorHeader = style([
- DefaultReset,
- {
- paddingLeft: config.space.S400,
- paddingRight: config.space.S200,
- borderBottomWidth: config.borderWidth.B300,
- flexShrink: 0,
- gap: config.space.S200,
- },
-]);
-
-export const EditorContent = style([
- DefaultReset,
- {
- padding: config.space.S400,
- },
-]);
-
-export const EditorTextArea = style({
- fontFamily: 'monospace',
-});
--- /dev/null
+import { Room } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
+import { useCallback, useMemo } from 'react';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { useMatrixClient } from './useMatrixClient';
+
+export const useAllJoinedRoomsSet = () => {
+ const allRooms = useAtomValue(allRoomsAtom);
+ const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
+
+ return allJoinedRooms;
+};
+
+export type GetRoomCallback = (roomId: string) => Room | undefined;
+export const useGetRoom = (rooms: Set<string>): GetRoomCallback => {
+ const mx = useMatrixClient();
+
+ const getRoom: GetRoomCallback = useCallback(
+ (rId: string) => {
+ if (rooms.has(rId)) {
+ return mx.getRoom(rId) ?? undefined;
+ }
+ return undefined;
+ },
+ [mx, rooms]
+ );
+
+ return getRoom;
+};
--- /dev/null
+import { Room } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { getAllParents } from '../utils/room';
+import { useMatrixClient } from './useMatrixClient';
+
+export const useImagePackRooms = (
+ roomId: string,
+ roomToParents: Map<string, Set<string>>
+): Room[] => {
+ const mx = useMatrixClient();
+
+ const imagePackRooms: Room[] = useMemo(() => {
+ const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
+ return allParentSpaces.reduce<Room[]>((list, rId) => {
+ const r = mx.getRoom(rId);
+ if (r) list.push(r);
+ return list;
+ }, []);
+ }, [mx, roomId, roomToParents]);
+
+ return imagePackRooms;
+};
--- /dev/null
+import { useMemo } from 'react';
+import { RoomMember } from 'matrix-js-sdk';
+import { Membership } from '../../types/matrix/room';
+
+export const MembershipFilter = {
+ filterJoined: (m: RoomMember) => m.membership === Membership.Join,
+ filterInvited: (m: RoomMember) => m.membership === Membership.Invite,
+ filterLeaved: (m: RoomMember) =>
+ m.membership === Membership.Leave &&
+ m.events.member?.getStateKey() === m.events.member?.getSender(),
+ filterKicked: (m: RoomMember) =>
+ m.membership === Membership.Leave &&
+ m.events.member?.getStateKey() !== m.events.member?.getSender(),
+ filterBanned: (m: RoomMember) => m.membership === Membership.Ban,
+};
+
+export type MembershipFilterFn = (m: RoomMember) => boolean;
+
+export type MembershipFilterItem = {
+ name: string;
+ filterFn: MembershipFilterFn;
+};
+
+export const useMembershipFilterMenu = (): MembershipFilterItem[] =>
+ useMemo(
+ () => [
+ {
+ name: 'Joined',
+ filterFn: MembershipFilter.filterJoined,
+ },
+ {
+ name: 'Invited',
+ filterFn: MembershipFilter.filterInvited,
+ },
+ {
+ name: 'Left',
+ filterFn: MembershipFilter.filterLeaved,
+ },
+ {
+ name: 'Kicked',
+ filterFn: MembershipFilter.filterKicked,
+ },
+ {
+ name: 'Banned',
+ filterFn: MembershipFilter.filterBanned,
+ },
+ ],
+ []
+ );
+
+export const useMembershipFilter = (
+ index: number,
+ membershipFilter: MembershipFilterItem[]
+): MembershipFilterItem => {
+ const filter = membershipFilter[index] ?? membershipFilter[0];
+ return filter;
+};
--- /dev/null
+import { RoomMember } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+
+export const MemberSort = {
+ Ascending: (a: RoomMember, b: RoomMember) =>
+ a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
+ Descending: (a: RoomMember, b: RoomMember) =>
+ a.name.toLowerCase() > b.name.toLowerCase() ? -1 : 1,
+ NewestFirst: (a: RoomMember, b: RoomMember) =>
+ (b.events.member?.getTs() ?? 0) - (a.events.member?.getTs() ?? 0),
+ Oldest: (a: RoomMember, b: RoomMember) =>
+ (a.events.member?.getTs() ?? 0) - (b.events.member?.getTs() ?? 0),
+};
+
+export type MemberSortFn = (a: RoomMember, b: RoomMember) => number;
+
+export type MemberSortItem = {
+ name: string;
+ sortFn: MemberSortFn;
+};
+
+export const useMemberSortMenu = (): MemberSortItem[] =>
+ useMemo(
+ () => [
+ {
+ name: 'A to Z',
+ sortFn: MemberSort.Ascending,
+ },
+ {
+ name: 'Z to A',
+ sortFn: MemberSort.Descending,
+ },
+ {
+ name: 'Newest',
+ sortFn: MemberSort.NewestFirst,
+ },
+ {
+ name: 'Oldest',
+ sortFn: MemberSort.Oldest,
+ },
+ ],
+ []
+ );
+
+export const useMemberSort = (index: number, memberSort: MemberSortItem[]): MemberSortItem => {
+ const item = memberSort[index] ?? memberSort[0];
+ return item;
+};
+import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
+import { IPowerLevels } from './usePowerLevels';
+import { useStateEvent } from './useStateEvent';
+import { StateEvent } from '../../types/matrix/room';
+import { IImageInfo } from '../../types/matrix/common';
+export type PowerLevelTagIcon = {
+ key?: string;
+ info?: IImageInfo;
+};
export type PowerLevelTag = {
name: string;
+ color?: string;
+ icon?: PowerLevelTagIcon;
+};
+
+export type PowerLevelTags = Record<number, PowerLevelTag>;
+
+export const powerSortFn = (a: number, b: number) => b - a;
+export const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
+
+export const getPowers = (tags: PowerLevelTags): number[] => {
+ const powers: number[] = Object.keys(tags).map((p) => parseInt(p, 10));
+
+ return sortPowers(powers);
+};
+
+export const getUsedPowers = (powerLevels: IPowerLevels): Set<number> => {
+ const powers: Set<number> = new Set();
+
+ const findAndAddPower = (data: Record<string, unknown>) => {
+ Object.keys(data).forEach((key) => {
+ const powerOrAny: unknown = data[key];
+
+ if (typeof powerOrAny === 'number') {
+ powers.add(powerOrAny);
+ return;
+ }
+ if (powerOrAny && typeof powerOrAny === 'object') {
+ findAndAddPower(powerOrAny as Record<string, unknown>);
+ }
+ });
+ };
+
+ findAndAddPower(powerLevels);
+
+ return powers;
+};
+
+const DEFAULT_TAGS: PowerLevelTags = {
+ 9001: {
+ name: 'Goku',
+ color: '#ff6a00',
+ },
+ 102: {
+ name: 'Goku Reborn',
+ color: '#ff6a7f',
+ },
+ 101: {
+ name: 'Founder',
+ color: '#0000ff',
+ },
+ 100: {
+ name: 'Admin',
+ color: '#a000e4',
+ },
+ 50: {
+ name: 'Moderator',
+ color: '#1fd81f',
+ },
+ 0: {
+ name: 'Member',
+ },
+ [-1]: {
+ name: 'Muted',
+ },
};
-export const usePowerLevelTags = () => {
- const powerLevelTags = useMemo(
- () => ({
- 9000: {
- name: 'Goku',
- },
- 101: {
- name: 'Founder',
- },
- 100: {
- name: 'Admin',
- },
- 50: {
- name: 'Moderator',
- },
- 0: {
- name: 'Default',
- },
- }),
- []
- );
- return useCallback(
- (powerLevel: number): PowerLevelTag => {
- if (powerLevel >= 9000) return powerLevelTags[9000];
- if (powerLevel >= 101) return powerLevelTags[101];
- if (powerLevel === 100) return powerLevelTags[100];
- if (powerLevel >= 50) return powerLevelTags[50];
- return powerLevelTags[0];
+const generateFallbackTag = (powerLevelTags: PowerLevelTags, power: number): PowerLevelTag => {
+ const highToLow = sortPowers(getPowers(powerLevelTags));
+
+ const tagPower = highToLow.find((p) => p < power);
+ const tag = typeof tagPower === 'number' ? powerLevelTags[tagPower] : undefined;
+
+ return {
+ name: tag ? `${tag.name} ${power}` : `Team ${power}`,
+ };
+};
+
+export type GetPowerLevelTag = (powerLevel: number) => PowerLevelTag;
+
+export const usePowerLevelTags = (
+ room: Room,
+ powerLevels: IPowerLevels
+): [PowerLevelTags, GetPowerLevelTag] => {
+ const tagsEvent = useStateEvent(room, StateEvent.PowerLevelTags);
+
+ const powerLevelTags: PowerLevelTags = useMemo(() => {
+ const content = tagsEvent?.getContent<PowerLevelTags>();
+ const powerToTags: PowerLevelTags = { ...content };
+
+ const powers = getUsedPowers(powerLevels);
+ Array.from(powers).forEach((power) => {
+ if (powerToTags[power]?.name === undefined) {
+ powerToTags[power] = DEFAULT_TAGS[power] ?? generateFallbackTag(DEFAULT_TAGS, power);
+ }
+ });
+
+ return powerToTags;
+ }, [powerLevels, tagsEvent]);
+
+ const getTag: GetPowerLevelTag = useCallback(
+ (power) => {
+ const tag: PowerLevelTag | undefined = powerLevelTags[power];
+ return tag ?? generateFallbackTag(DEFAULT_TAGS, power);
},
[powerLevelTags]
);
+
+ return [powerLevelTags, getTag];
};
+
+export const useFlattenPowerLevelTagMembers = (
+ members: RoomMember[],
+ getPowerLevel: (userId: string) => number,
+ getTag: GetPowerLevelTag
+): Array<PowerLevelTag | RoomMember> => {
+ const PLTagOrRoomMember = useMemo(() => {
+ let prevTag: PowerLevelTag | undefined;
+ const tagOrMember: Array<PowerLevelTag | RoomMember> = [];
+ members.forEach((member) => {
+ const memberPL = getPowerLevel(member.userId);
+ const tag = getTag(memberPL);
+ if (tag !== prevTag) {
+ prevTag = tag;
+ tagOrMember.push(tag);
+ }
+ tagOrMember.push(member);
+ });
+ return tagOrMember;
+ }, [members, getTag, getPowerLevel]);
+
+ return PLTagOrRoomMember;
+};
+
+export const getTagIconSrc = (
+ mx: MatrixClient,
+ useAuthentication: boolean,
+ icon: PowerLevelTagIcon
+): string | undefined =>
+ icon?.key?.startsWith('mxc://')
+ ? mx.mxcUrlToHttp(icon.key, 96, 96, 'scale', undefined, undefined, useAuthentication) ?? '🌻'
+ : icon?.key;
-import { Room } from 'matrix-js-sdk';
-import { createContext, useCallback, useContext, useMemo } from 'react';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { createContext, useCallback, useContext, useMemo, useState } from 'react';
+import produce from 'immer';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
-import { useForceUpdate } from './useForceUpdate';
import { useStateEventCallback } from './useStateEventCallback';
import { useMatrixClient } from './useMatrixClient';
import { getStateEvent } from '../utils/room';
export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
+export type PowerLevelNotificationsAction = 'room';
-enum DefaultPowerLevels {
- usersDefault = 0,
- stateDefault = 50,
- eventsDefault = 0,
- invite = 0,
- redact = 50,
- kick = 50,
- ban = 50,
- historical = 0,
-}
-
-export interface IPowerLevels {
+export type IPowerLevels = {
users_default?: number;
state_default?: number;
events_default?: number;
events?: Record<string, number>;
users?: Record<string, number>;
notifications?: Record<string, number>;
-}
+};
+
+const DEFAULT_POWER_LEVELS: Required<IPowerLevels> = {
+ users_default: 0,
+ state_default: 50,
+ events_default: 0,
+ invite: 0,
+ redact: 50,
+ kick: 50,
+ ban: 50,
+ historical: 0,
+ events: {},
+ users: {},
+ notifications: {
+ room: 50,
+ },
+};
+
+const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
+ produce(powerLevels, (draftPl: IPowerLevels) => {
+ const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
+ keys.forEach((key) => {
+ if (draftPl[key] === undefined) {
+ // eslint-disable-next-line no-param-reassign
+ draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
+ }
+ });
+ if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
+ // eslint-disable-next-line no-param-reassign
+ draftPl.notifications.room = DEFAULT_POWER_LEVELS.notifications.room;
+ }
+ return draftPl;
+ });
+
+const getPowersLevelFromMatrixEvent = (mEvent?: MatrixEvent): IPowerLevels => {
+ const pl = mEvent?.getContent<IPowerLevels>();
+ if (!pl) return DEFAULT_POWER_LEVELS;
+
+ return fillMissingPowers(pl);
+};
export function usePowerLevels(room: Room): IPowerLevels {
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
- const powerLevels: IPowerLevels =
- powerLevelsEvent?.getContent<IPowerLevels>() ?? DefaultPowerLevels;
+ const powerLevels: IPowerLevels = useMemo(
+ () => getPowersLevelFromMatrixEvent(powerLevelsEvent),
+ [powerLevelsEvent]
+ );
return powerLevels;
}
export const useRoomsPowerLevels = (rooms: Room[]): Map<string, IPowerLevels> => {
const mx = useMatrixClient();
- const [updateCount, forceUpdate] = useForceUpdate();
+ const getRoomsPowerLevels = useCallback(() => {
+ const rToPl = new Map<string, IPowerLevels>();
+
+ rooms.forEach((room) => {
+ const mEvent = getStateEvent(room, StateEvent.RoomPowerLevels, '');
+ rToPl.set(room.roomId, getPowersLevelFromMatrixEvent(mEvent));
+ });
+
+ return rToPl;
+ }, [rooms]);
+
+ const [roomToPowerLevels, setRoomToPowerLevels] = useState(() => getRoomsPowerLevels());
useStateEventCallback(
mx,
event.getStateKey() === '' &&
rooms.find((r) => r.roomId === roomId)
) {
- forceUpdate();
+ setRoomToPowerLevels(getRoomsPowerLevels());
}
},
- [rooms, forceUpdate]
+ [rooms, getRoomsPowerLevels]
)
);
- const roomToPowerLevels = useMemo(
- () => {
- const rToPl = new Map<string, IPowerLevels>();
-
- rooms.forEach((room) => {
- const pl = getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent<IPowerLevels>();
- if (pl) rToPl.set(room.roomId, pl);
- });
-
- return rToPl;
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [rooms, updateCount]
- );
-
return roomToPowerLevels;
};
action: PowerLevelActions,
powerLevel: number
) => boolean;
+export type CanDoNotificationAction = (
+ powerLevels: IPowerLevels,
+ action: PowerLevelNotificationsAction,
+ powerLevel: number
+) => boolean;
export type PowerLevelsAPI = {
getPowerLevel: GetPowerLevel;
canSendEvent: CanSend;
canSendStateEvent: CanSend;
canDoAction: CanDoAction;
+ canDoNotificationAction: CanDoNotificationAction;
};
-export const powerLevelAPI: PowerLevelsAPI = {
- getPowerLevel: (powerLevels, userId) => {
+export type ReadPowerLevelAPI = {
+ user: GetPowerLevel;
+ event: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
+ state: (powerLevels: IPowerLevels, eventType: string | undefined) => number;
+ action: (powerLevels: IPowerLevels, action: PowerLevelActions) => number;
+ notification: (powerLevels: IPowerLevels, action: PowerLevelNotificationsAction) => number;
+};
+
+export const readPowerLevel: ReadPowerLevelAPI = {
+ user: (powerLevels, userId) => {
const { users_default: usersDefault, users } = powerLevels;
if (userId && users && typeof users[userId] === 'number') {
return users[userId];
}
- return usersDefault ?? DefaultPowerLevels.usersDefault;
+ return usersDefault ?? DEFAULT_POWER_LEVELS.users_default;
},
- canSendEvent: (powerLevels, eventType, powerLevel) => {
+ event: (powerLevels, eventType) => {
const { events, events_default: eventsDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
- return powerLevel >= events[eventType];
+ return events[eventType];
}
- return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
+ return eventsDefault ?? DEFAULT_POWER_LEVELS.events_default;
},
- canSendStateEvent: (powerLevels, eventType, powerLevel) => {
+ state: (powerLevels, eventType) => {
const { events, state_default: stateDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
- return powerLevel >= events[eventType];
+ return events[eventType];
}
- return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
+ return stateDefault ?? DEFAULT_POWER_LEVELS.state_default;
},
- canDoAction: (powerLevels, action, powerLevel) => {
- const requiredPL = powerLevels[action];
- if (typeof requiredPL === 'number') {
- return powerLevel >= requiredPL;
+ action: (powerLevels, action) => {
+ const powerLevel = powerLevels[action];
+ if (typeof powerLevel === 'number') {
+ return powerLevel;
}
- return powerLevel >= DefaultPowerLevels[action];
+ return DEFAULT_POWER_LEVELS[action];
+ },
+ notification: (powerLevels, action) => {
+ const powerLevel = powerLevels.notifications?.[action];
+ if (typeof powerLevel === 'number') {
+ return powerLevel;
+ }
+ return DEFAULT_POWER_LEVELS.notifications[action];
+ },
+};
+
+export const powerLevelAPI: PowerLevelsAPI = {
+ getPowerLevel: (powerLevels, userId) => readPowerLevel.user(powerLevels, userId),
+ canSendEvent: (powerLevels, eventType, powerLevel) => {
+ const requiredPL = readPowerLevel.event(powerLevels, eventType);
+ return powerLevel >= requiredPL;
+ },
+ canSendStateEvent: (powerLevels, eventType, powerLevel) => {
+ const requiredPL = readPowerLevel.state(powerLevels, eventType);
+ return powerLevel >= requiredPL;
+ },
+ canDoAction: (powerLevels, action, powerLevel) => {
+ const requiredPL = readPowerLevel.action(powerLevels, action);
+ return powerLevel >= requiredPL;
+ },
+ canDoNotificationAction: (powerLevels, action, powerLevel) => {
+ const requiredPL = readPowerLevel.notification(powerLevels, action);
+ return powerLevel >= requiredPL;
},
};
[powerLevels]
);
+ const canDoNotificationAction = useCallback(
+ (action: PowerLevelNotificationsAction, powerLevel: number) =>
+ powerLevelAPI.canDoNotificationAction(powerLevels, action, powerLevel),
+ [powerLevels]
+ );
+
return {
getPowerLevel,
canSendEvent,
canSendStateEvent,
canDoAction,
+ canDoNotificationAction,
};
};
+
+/**
+ * Permissions
+ */
+
+type DefaultPermissionLocation = {
+ user: true;
+ key?: string;
+};
+
+type ActionPermissionLocation = {
+ action: true;
+ key: PowerLevelActions;
+};
+
+type EventPermissionLocation = {
+ state?: true;
+ key?: string;
+};
+
+type NotificationPermissionLocation = {
+ notification: true;
+ key: PowerLevelNotificationsAction;
+};
+
+export type PermissionLocation =
+ | DefaultPermissionLocation
+ | ActionPermissionLocation
+ | EventPermissionLocation
+ | NotificationPermissionLocation;
+
+export const getPermissionPower = (
+ powerLevels: IPowerLevels,
+ location: PermissionLocation
+): number => {
+ if ('user' in location) {
+ return readPowerLevel.user(powerLevels, location.key);
+ }
+ if ('action' in location) {
+ return readPowerLevel.action(powerLevels, location.key);
+ }
+ if ('notification' in location) {
+ return readPowerLevel.notification(powerLevels, location.key);
+ }
+ if ('state' in location) {
+ return readPowerLevel.state(powerLevels, location.key);
+ }
+
+ return readPowerLevel.event(powerLevels, location.key);
+};
+
+export const applyPermissionPower = (
+ powerLevels: IPowerLevels,
+ location: PermissionLocation,
+ power: number
+): IPowerLevels => {
+ if ('user' in location) {
+ if (typeof location.key === 'string') {
+ const users = powerLevels.users ?? {};
+ users[location.key] = power;
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.users = users;
+ return powerLevels;
+ }
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.users_default = power;
+ return powerLevels;
+ }
+ if ('action' in location) {
+ // eslint-disable-next-line no-param-reassign
+ powerLevels[location.key] = power;
+ return powerLevels;
+ }
+ if ('notification' in location) {
+ const notifications = powerLevels.notifications ?? {};
+ notifications[location.key] = power;
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.notifications = notifications;
+ return powerLevels;
+ }
+ if ('state' in location) {
+ if (typeof location.key === 'string') {
+ const events = powerLevels.events ?? {};
+ events[location.key] = power;
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.events = events;
+ return powerLevels;
+ }
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.state_default = power;
+ return powerLevels;
+ }
+
+ if (typeof location.key === 'string') {
+ const events = powerLevels.events ?? {};
+ events[location.key] = power;
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.events = events;
+ return powerLevels;
+ }
+ // eslint-disable-next-line no-param-reassign
+ powerLevels.events_default = power;
+ return powerLevels;
+};
--- /dev/null
+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { useCallback, useEffect, useState } from 'react';
+
+export const useRoomAccountData = (room: Room): Map<string, object> => {
+ const getAccountData = useCallback((): Map<string, object> => {
+ const accountData = new Map<string, object>();
+
+ Array.from(room.accountData.entries()).forEach(([type, mEvent]) => {
+ const content = mEvent.getContent();
+ accountData.set(type, content);
+ });
+
+ return accountData;
+ }, [room]);
+
+ const [accountData, setAccountData] = useState<Map<string, object>>(getAccountData);
+
+ useEffect(() => {
+ const handleEvent: RoomEventHandlerMap[RoomEvent.AccountData] = () => {
+ setAccountData(getAccountData());
+ };
+ room.on(RoomEvent.AccountData, handleEvent);
+ return () => {
+ room.removeListener(RoomEvent.AccountData, handleEvent);
+ };
+ }, [room, getAccountData]);
+
+ return accountData;
+};
--- /dev/null
+import { useCallback, useEffect, useMemo } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { RoomCanonicalAliasEventContent } from 'matrix-js-sdk/lib/types';
+import { AsyncState, useAsyncCallback } from './useAsyncCallback';
+import { useMatrixClient } from './useMatrixClient';
+import { useAlive } from './useAlive';
+import { useStateEvent } from './useStateEvent';
+import { StateEvent } from '../../types/matrix/room';
+import { getStateEvent } from '../utils/room';
+
+export const usePublishedAliases = (room: Room): [string | undefined, string[]] => {
+ const aliasContent = useStateEvent(
+ room,
+ StateEvent.RoomCanonicalAlias
+ )?.getContent<RoomCanonicalAliasEventContent>();
+
+ const canonicalAlias = aliasContent?.alias;
+
+ const publishedAliases = useMemo(() => {
+ const aliases: string[] = [];
+ if (typeof aliasContent?.alias === 'string') {
+ aliases.push(aliasContent.alias);
+ }
+ aliasContent?.alt_aliases?.forEach((alias) => {
+ if (typeof alias === 'string') {
+ aliases.push(alias);
+ }
+ });
+ return aliases;
+ }, [aliasContent]);
+
+ return [canonicalAlias, publishedAliases];
+};
+
+export const useSetMainAlias = (room: Room): ((alias: string | undefined) => Promise<void>) => {
+ const mx = useMatrixClient();
+ const mainAlias = useCallback(
+ async (alias: string | undefined) => {
+ const content = getStateEvent(
+ room,
+ StateEvent.RoomCanonicalAlias
+ )?.getContent<RoomCanonicalAliasEventContent>();
+
+ const altAliases: string[] = [];
+ if (content?.alias && content.alias !== alias) {
+ altAliases.push(content.alias);
+ }
+ content?.alt_aliases?.forEach((a) => {
+ if (a !== alias) {
+ altAliases.push(a);
+ }
+ });
+
+ const newContent: RoomCanonicalAliasEventContent = {
+ alias,
+ alt_aliases: altAliases,
+ };
+
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
+ },
+ [mx, room]
+ );
+
+ return mainAlias;
+};
+
+export const usePublishUnpublishAliases = (
+ room: Room
+): {
+ publishAliases: (aliases: string[]) => Promise<void>;
+ unpublishAliases: (aliases: string[]) => Promise<void>;
+} => {
+ const mx = useMatrixClient();
+ const publishAliases = useCallback(
+ async (aliases: string[]) => {
+ const content = getStateEvent(
+ room,
+ StateEvent.RoomCanonicalAlias
+ )?.getContent<RoomCanonicalAliasEventContent>();
+ const altAliases = content?.alt_aliases ?? [];
+
+ aliases.forEach((alias) => {
+ if (!altAliases.includes(alias)) {
+ altAliases.push(alias);
+ }
+ });
+
+ const newContent: RoomCanonicalAliasEventContent = {
+ alias: content?.alias,
+ alt_aliases: altAliases,
+ };
+
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
+ },
+ [mx, room]
+ );
+
+ const unpublishAliases = useCallback(
+ async (aliases: string[]) => {
+ const content = getStateEvent(
+ room,
+ StateEvent.RoomCanonicalAlias
+ )?.getContent<RoomCanonicalAliasEventContent>();
+ const altAliases: string[] = [];
+
+ content?.alt_aliases?.forEach((alias) => {
+ if (!aliases.includes(alias)) {
+ altAliases.push(alias);
+ }
+ });
+
+ const newContent: RoomCanonicalAliasEventContent = {
+ alias: content?.alias,
+ alt_aliases: altAliases,
+ };
+
+ await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
+ },
+ [mx, room]
+ );
+
+ return {
+ publishAliases,
+ unpublishAliases,
+ };
+};
+
+export const useLocalAliases = (
+ roomId: string
+): {
+ localAliasesState: AsyncState<string[], MatrixError>;
+ addLocalAlias: (alias: string) => Promise<void>;
+ removeLocalAlias: (alias: string) => Promise<void>;
+} => {
+ const mx = useMatrixClient();
+ const alive = useAlive();
+
+ const [aliasesState, loadAliases] = useAsyncCallback<string[], MatrixError, []>(
+ useCallback(async () => {
+ const content = await mx.getLocalAliases(roomId);
+ return content.aliases;
+ }, [mx, roomId])
+ );
+
+ useEffect(() => {
+ loadAliases();
+ }, [loadAliases]);
+
+ const addLocalAlias = useCallback(
+ async (alias: string) => {
+ await mx.createAlias(alias, roomId);
+ if (alive()) await loadAliases();
+ },
+ [mx, roomId, loadAliases, alive]
+ );
+
+ const removeLocalAlias = useCallback(
+ async (alias: string) => {
+ await mx.deleteAlias(alias);
+ if (alive()) await loadAliases();
+ },
+ [mx, loadAliases, alive]
+ );
+
+ return {
+ localAliasesState: aliasesState,
+ addLocalAlias,
+ removeLocalAlias,
+ };
+};
import { useEffect, useState } from 'react';
+import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
return topic;
};
+
+export const useRoomJoinRule = (room: Room): RoomJoinRulesEventContent | undefined => {
+ const mEvent = useStateEvent(room, StateEvent.RoomJoinRules);
+ const joinRuleContent = mEvent?.getContent<RoomJoinRulesEventContent>();
+ return joinRuleContent;
+};
--- /dev/null
+import {
+ Direction,
+ MatrixEvent,
+ Room,
+ RoomStateEvent,
+ RoomStateEventHandlerMap,
+} from 'matrix-js-sdk';
+import { useCallback, useEffect, useState } from 'react';
+import { StateEvent } from '../../types/matrix/room';
+
+export type StateKeyToEvents = Map<string, MatrixEvent>;
+export type StateTypeToState = Map<string, StateKeyToEvents>;
+
+export const useRoomState = (room: Room): StateTypeToState => {
+ const getState = useCallback((): StateTypeToState => {
+ const roomState = room.getLiveTimeline().getState(Direction.Forward);
+ const state: StateTypeToState = new Map();
+
+ if (!roomState) return state;
+
+ roomState.events.forEach((stateKeyToEvents, eventType) => {
+ if (eventType === StateEvent.RoomMember) {
+ // Ignore room members from state on purpose;
+ return;
+ }
+ const kToE: StateKeyToEvents = new Map();
+ stateKeyToEvents.forEach((mEvent, stateKey) => kToE.set(stateKey, mEvent));
+
+ state.set(eventType, kToE);
+ });
+
+ return state;
+ }, [room]);
+
+ const [state, setState] = useState(getState);
+
+ useEffect(() => {
+ const roomState = room.getLiveTimeline().getState(Direction.Forward);
+ const handler: RoomStateEventHandlerMap[RoomStateEvent.Events] = () => {
+ setState(getState());
+ };
+
+ roomState?.on(RoomStateEvent.Events, handler);
+ return () => {
+ roomState?.removeListener(RoomStateEvent.Events, handler);
+ };
+ }, [room, getState]);
+
+ return state;
+};
--- /dev/null
+import { useMemo, useCallback, KeyboardEventHandler, MutableRefObject } from 'react';
+import { isKeyHotkey } from 'is-hotkey';
+import { TextArea, Intent, TextAreaOperations, Cursor } from '../plugins/text-area';
+import { useTextAreaIntentHandler } from './useTextAreaIntent';
+import { GetTarget } from '../plugins/text-area/type';
+
+export const useTextAreaCodeEditor = (
+ textAreaRef: MutableRefObject<HTMLTextAreaElement | null>,
+ intentSpaceCount: number
+) => {
+ const getTarget: GetTarget = useCallback(() => {
+ const target = textAreaRef.current;
+ if (!target) throw new Error('TextArea element not found!');
+ return target;
+ }, [textAreaRef]);
+
+ const { textArea, operations, intent } = useMemo(() => {
+ const ta = new TextArea(getTarget);
+ const op = new TextAreaOperations(getTarget);
+ return {
+ textArea: ta,
+ operations: op,
+ intent: new Intent(intentSpaceCount, ta, op),
+ };
+ }, [getTarget, intentSpaceCount]);
+
+ const intentHandler = useTextAreaIntentHandler(textArea, operations, intent);
+
+ const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (evt) => {
+ intentHandler(evt);
+ if (isKeyHotkey('escape', evt)) {
+ const cursor = Cursor.fromTextAreaElement(getTarget());
+ operations.deselect(cursor);
+ }
+ };
+
+ return {
+ handleKeyDown,
+ textArea,
+ intent,
+ getTarget,
+ operations,
+ };
+};
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
+import { RoomSettingsRenderer } from '../features/room-settings';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
>
<Outlet />
</ClientLayout>
+ <RoomSettingsRenderer />
<ReceiveSelfDeviceVerification />
<AutoRestoreBackupOnVerification />
</ClientNonUIFeatures>
--- /dev/null
+import { useCallback } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { roomSettingsAtom, RoomSettingsPage, RoomSettingsState } from '../roomSettings';
+
+export const useRoomSettingsState = (): RoomSettingsState | undefined => {
+ const data = useAtomValue(roomSettingsAtom);
+
+ return data;
+};
+
+type CloseCallback = () => void;
+export const useCloseRoomSettings = (): CloseCallback => {
+ const setSettings = useSetAtom(roomSettingsAtom);
+
+ const close: CloseCallback = useCallback(() => {
+ setSettings(undefined);
+ }, [setSettings]);
+
+ return close;
+};
+
+type OpenCallback = (roomId: string, space?: string, page?: RoomSettingsPage) => void;
+export const useOpenRoomSettings = (): OpenCallback => {
+ const setSettings = useSetAtom(roomSettingsAtom);
+
+ const open: OpenCallback = useCallback(
+ (roomId, spaceId, page) => {
+ setSettings({ roomId, spaceId, page });
+ },
+ [setSettings]
+ );
+
+ return open;
+};
--- /dev/null
+import { atom } from 'jotai';
+
+export enum RoomSettingsPage {
+ GeneralPage,
+ MembersPage,
+ PermissionsPage,
+ EmojisStickersPage,
+ DeveloperToolsPage,
+}
+
+export type RoomSettingsState = {
+ page?: RoomSettingsPage;
+ roomId: string;
+ spaceId?: string;
+};
+
+export const roomSettingsAtom = atom<RoomSettingsState | undefined>(undefined);
import { getStateEvent } from './room';
import { StateEvent } from '../../types/matrix/room';
-export const matchMxId = (id: string): RegExpMatchArray | null =>
- id.match(/^([@!$+#])(\S+):(\S+)$/);
+export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/);
export const validMxId = (id: string): boolean => !!matchMxId(id);
SpaceParent = 'm.space.parent',
PoniesRoomEmotes = 'im.ponies.room_emotes',
+ PowerLevelTags = 'in.cinny.room.power_level_tags',
}
export enum MessageEvent {