From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:54:13 +0000 (+1100) Subject: Add new space settings (#2293) X-Git-Tag: v4.6.0~7 X-Git-Url: https://git.wafflesoft.org/?a=commitdiff_plain;h=5c39a36c12a9f873848ab7f14e22591fd9766c59;p=rainny.git Add new space settings (#2293) --- diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index ddd1903..e78c19c 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -29,6 +29,17 @@ export const useRoomJoinRuleIcon = (): JoinRuleIcons => }), [] ); +export const useSpaceJoinRuleIcon = (): JoinRuleIcons => + useMemo( + () => ({ + [JoinRule.Invite]: Icons.SpaceLock, + [JoinRule.Knock]: Icons.SpaceLock, + [JoinRule.Restricted]: Icons.Space, + [JoinRule.Public]: Icons.SpaceGlobe, + [JoinRule.Private]: Icons.SpaceLock, + }), + [] + ); type JoinRuleLabels = Record; export const useRoomJoinRuleLabel = (): JoinRuleLabels => diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx new file mode 100644 index 0000000..29b6aa5 --- /dev/null +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -0,0 +1,396 @@ +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(); + const [openStateEvent, setOpenStateEvent] = useState(); + const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>(); + + const [expandAccountData, setExpandAccountData] = useState(false); + const [accountDataType, setAccountDataType] = useState(); + + 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 ( + + ); + } + + if (composeEvent) { + return ; + } + + if (openStateEvent) { + return ; + } + + return ( + + + + + + Developer Tools + + + + + + + + + + + + + + + Options + + + } + /> + + {developerTools && ( + + copyToClipboard(room.roomId ?? '')} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Copy + + } + /> + + )} + + + {developerTools && ( + + Data + + + setComposeEvent({})} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + > + Compose + + } + /> + + + setExpandState(!expandState)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expandState ? 'Collapse' : 'Expand'} + + } + /> + {expandState && ( + + + Events + Total: {roomState.size} + + + setComposeEvent({ stateKey: '' })} + variant="Surface" + fill="None" + size="300" + radii="0" + before={} + > + + + Add New + + + + {Array.from(roomState.keys()) + .sort() + .map((eventType) => { + const expanded = eventType === expandStateType; + const stateKeyToEvents = roomState.get(eventType); + if (!stateKeyToEvents) return null; + + return ( + + + setExpandStateType(expanded ? undefined : eventType) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={ + + } + after={{stateKeyToEvents.size}} + > + + + {eventType} + + + + {expanded && ( +
+ + setComposeEvent({ type: eventType, stateKey: '' }) + } + variant="Surface" + fill="None" + size="300" + radii="0" + before={} + > + + + Add New + + + + {Array.from(stateKeyToEvents.keys()) + .sort() + .map((stateKey) => ( + { + setOpenStateEvent({ + type: eventType, + stateKey, + }); + }} + key={stateKey} + variant="Surface" + fill="None" + size="300" + radii="0" + after={} + > + + + {stateKey ? `"${stateKey}"` : 'Default'} + + + + ))} +
+ )} +
+ ); + })} +
+
+ )} +
+ + setExpandAccountData(!expandAccountData)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expandAccountData ? 'Collapse' : 'Expand'} + + } + /> + {expandAccountData && ( + + + Events + Total: {accountData.size} + + + } + onClick={() => setAccountDataType(null)} + > + + + Add New + + + + {Array.from(accountData.keys()) + .sort() + .map((type) => ( + } + onClick={() => setAccountDataType(type)} + > + + + {type} + + + + ))} + + + )} + +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx new file mode 100644 index 0000000..f25ba7c --- /dev/null +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -0,0 +1,208 @@ +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(null); + const [jsonError, setJSONError] = useState(); + 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 = (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 ( + + + + + } + > + Developer Tools + + + + + + + + + + + + + {composeStateEvent ? 'State Event Type' : 'Message Event Type'} + + + + + + + + {submitState.status === AsyncStatus.Error && ( + + {submitState.error.message} + + )} + + {composeStateEvent && ( + + State Key (Optional) + + + )} + + + JSON Content + + + {jsonError && ( + + + {jsonError.name}: {jsonError.message} + + + )} + + + + + ); +} diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx new file mode 100644 index 0000000..6ee19be --- /dev/null +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -0,0 +1,298 @@ +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(null); + const [jsonError, setJSONError] = useState(); + const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor( + textAreaRef, + EDITOR_INTENT_SPACE_COUNT + ); + + const [submitState, submit] = useAsyncCallback( + useCallback( + (c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey), + [mx, room, type, stateKey] + ) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (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 ( + + + State Event + + + + + + } + /> + + + {submitState.status === AsyncStatus.Error && ( + + {submitState.error.message} + + )} + + + + JSON Content + + + {jsonError && ( + + + {jsonError.name}: {jsonError.message} + + + )} + + + ); +} + +type StateEventViewProps = { + content: object; + eventJSONStr: string; + onEditContent?: (content: object) => void; +}; +function StateEventView({ content, eventJSONStr, onEditContent }: StateEventViewProps) { + return ( + + + + + State Event + + {onEditContent && ( + + onEditContent(content)} + > + Edit + + + )} + + + + + + + + + ); +} + +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(); + 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 ( + + + + + } + > + Developer Tools + + + + + + + + + + + {editContent ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/app/features/common-settings/developer-tools/index.ts b/src/app/features/common-settings/developer-tools/index.ts new file mode 100644 index 0000000..1fcceff --- /dev/null +++ b/src/app/features/common-settings/developer-tools/index.ts @@ -0,0 +1 @@ +export * from './DevelopTools'; diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx new file mode 100644 index 0000000..ad8ffae --- /dev/null +++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx @@ -0,0 +1,49 @@ +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(); + + const handleImagePackViewClose = () => { + setImagePack(undefined); + }; + + if (imagePack) { + return ; + } + + return ( + + + + + + Emojis & Stickers + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx new file mode 100644 index 0000000..56dda54 --- /dev/null +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -0,0 +1,349 @@ +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( + 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 = (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 ( + + + + + Name + + {addState.status === AsyncStatus.Error && ( + + {addState.error.message} + + )} + + + + + + ); +} + +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([]); + 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 ( + + + {pack.meta.name ?? 'Unknown'} + + } + description={{pack.meta.attribution}} + before={ + + {canEdit && + (removed ? ( + handleUndoRemove(address)} + disabled={applyingChanges} + > + + + ) : ( + handleRemove(address)} + disabled={applyingChanges} + > + + + ))} + + {avatarUrl ? ( + + ) : ( + + + + )} + + + } + after={ + !removed && ( + + ) + } + /> + + ); + }; + + return ( + <> + + Packs + {canEdit && } + {packs.map(renderPack)} + {packs.length === 0 && ( + + + + No Packs + + + There are no emoji or sticker packs to display at the moment. + + + + )} + + + {hasChanges && ( + + + + {applyState.status === AsyncStatus.Error ? ( + + Failed to remove packs! Please try again. + + ) : ( + + Delete selected packs. ({removedPacks.length} selected) + + )} + + + + + + + + )} + + ); +} diff --git a/src/app/features/common-settings/emojis-stickers/index.ts b/src/app/features/common-settings/emojis-stickers/index.ts new file mode 100644 index 0000000..9c9e9f5 --- /dev/null +++ b/src/app/features/common-settings/emojis-stickers/index.ts @@ -0,0 +1 @@ +export * from './EmojisStickers'; diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx new file mode 100644 index 0000000..9e1f1a9 --- /dev/null +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -0,0 +1,438 @@ +import React, { FormEventHandler, useCallback, useState } from 'react'; +import { + Badge, + Box, + Button, + Checkbox, + Chip, + color, + config, + Icon, + Icons, + Input, + Spinner, + Text, + toRem, +} from 'folds'; +import { MatrixError } from 'matrix-js-sdk'; +import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; +import { SettingTile } from '../../../components/setting-tile'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../../room-settings/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 ( + + + If access is Public, Published addresses will be used to join by anyone. + + } + /> + + {publishedAliases.length === 0 ? ( + + No Addresses + + To publish an address, it needs to be set as a local address first + + + ) : ( + + {publishedAliases.map((alias) => ( + + + + {alias === canonicalAlias ? {alias} : alias} + + {alias === canonicalAlias && ( + + Main + + )} + + {canEditCanonical && ( + + {alias === canonicalAlias ? ( + setMain(undefined)} + > + Unset Main + + ) : ( + setMain(alias)} + > + Set Main + + )} + + )} + + ))} + + {mainState.status === AsyncStatus.Error && ( + + {(mainState.error as MatrixError).message} + + )} + + )} + + + ); +} + +function LocalAddressInput({ addLocalAlias }: { addLocalAlias: (alias: string) => Promise }) { + 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 = (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 ( + + + + #} + readOnly={adding} + after={ + + :{server} + + } + /> + + + + + + {addState.status === AsyncStatus.Error && ( + + {(addState.error as MatrixError).httpStatus === 409 + ? 'Address is already in use!' + : (addState.error as MatrixError).message} + + )} + + ); +} + +function LocalAddressesList({ + localAliases, + removeLocalAlias, + canEditCanonical, +}: { + localAliases: string[]; + removeLocalAlias: (alias: string) => Promise; + canEditCanonical?: boolean; +}) { + const room = useRoom(); + const alive = useAlive(); + + const [, publishedAliases] = usePublishedAliases(room); + const { publishAliases, unpublishAliases } = usePublishUnpublishAliases(room); + + const [selectedAliases, setSelectedAliases] = useState([]); + 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 ( + + {selectedAliases.length > 0 && ( + + + {selectedAliases.length} Selected + + + {canEditCanonical && + (selectHasPublished ? ( + + ) + } + > + Unpublish + + ) : ( + + ) + } + > + Publish + + ))} + + ) + } + > + Delete + + + + )} + {localAliases.map((alias) => { + const published = publishedAliases.includes(alias); + const selected = selectedAliases.includes(alias); + + return ( + + + toggleSelect(alias)} + size="50" + variant="Primary" + disabled={loading} + /> + + + + {alias} + + + + {published && ( + + Published + + )} + + + ); + })} + {error && ( + + {error.message} + + )} + + ); +} + +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 ( + + setExpand(!expand)} + size="300" + variant="Secondary" + fill="Soft" + outlined + radii="300" + before={ + + } + > + + {expand ? 'Collapse' : 'Expand'} + + + } + /> + {expand && ( + + {localAliasesState.status === AsyncStatus.Loading && ( + + + Loading... + + )} + {localAliasesState.status === AsyncStatus.Success && + (localAliasesState.data.length === 0 ? ( + + No Addresses + + ) : ( + + ))} + {localAliasesState.status === AsyncStatus.Error && ( + + + {localAliasesState.error.message} + + + )} + + )} + {expand && } + + ); +} diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx new file mode 100644 index 0000000..1bb7339 --- /dev/null +++ b/src/app/features/common-settings/general/RoomEncryption.tsx @@ -0,0 +1,150 @@ +import { + Badge, + Box, + Button, + color, + config, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, +} from 'folds'; +import React, { useCallback, useState } from 'react'; +import { MatrixError } from 'matrix-js-sdk'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../../room-settings/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 ( + + + Enabled + + ) : ( + + ) + } + > + {enableState.status === AsyncStatus.Error && ( + + {(enableState.error as MatrixError).message} + + )} + {prompt && ( + }> + + setPrompt(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + Enable Encryption + + setPrompt(false)} radii="300"> + + +
+ + + Are you sure? Once enabled, encryption cannot be disabled! + + + +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx new file mode 100644 index 0000000..7b329b1 --- /dev/null +++ b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx @@ -0,0 +1,169 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { + Button, + color, + config, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, +} from 'folds'; +import { HistoryVisibility, MatrixError } from 'matrix-js-sdk'; +import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types'; +import FocusTrap from 'focus-trap-react'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../../room-settings/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().history_visibility ?? + HistoryVisibility.Shared; + const visibilityMenu = useVisibilityMenu(); + const visibilityStr = useVisibilityStr(); + + const [menuAnchor, setMenuAnchor] = useState(); + + const handleOpenMenu: MouseEventHandler = (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 ( + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {visibilityMenu.map((visibility) => ( + handleChange(visibility)} + aria-pressed={visibility === historyVisibility} + > + + {visibilityStr[visibility]} + + + ))} + + + } + > + + + } + > + {submitState.status === AsyncStatus.Error && ( + + {(submitState.error as MatrixError).message} + + )} + + + ); +} diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx new file mode 100644 index 0000000..158ca25 --- /dev/null +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -0,0 +1,130 @@ +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, + useSpaceJoinRuleIcon, +} from '../../../components/JoinRulesSwitcher'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../../room-settings/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(); + const rule: JoinRule = content?.join_rule ?? JoinRule.Invite; + + const joinRules: Array = 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 spaceIcons = useSpaceJoinRuleIcon(); + 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 ( + + + } + > + {submitState.status === AsyncStatus.Error && ( + + {(submitState.error as MatrixError).message} + + )} + + + ); +} diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx new file mode 100644 index 0000000..a3a62e1 --- /dev/null +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -0,0 +1,361 @@ +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 '../../room-settings/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(); + 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, roomTopic?: string) => { + 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 = (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(); + + if (roomAvatar === avatar && roomName === name && roomTopic === topic) { + return; + } + + submit( + roomAvatar === avatar ? undefined : roomAvatar || null, + roomName === name ? undefined : roomName, + roomTopic === topic ? undefined : roomTopic + ).then(() => { + if (alive()) { + onClose(); + } + }); + }; + + return ( + + + + Avatar + {uploadAtom ? ( + + + + ) : ( + + + {!roomAvatar && avatar && ( + + )} + {roomAvatar && ( + + )} + + )} + + + + ( + + )} + /> + + + + + Name + + + + Topic +