diff --git a/package-lock.json b/package-lock.json index b173bc7..3971adb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "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", @@ -9654,6 +9655,16 @@ "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", diff --git a/package.json b/package.json index aea713e..074f4dd 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "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", diff --git a/src/app/features/settings/developer-tools/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx similarity index 75% rename from src/app/features/settings/developer-tools/AccountDataEditor.tsx rename to src/app/components/AccountDataEditor.tsx index b5ac0f8..2dbaf1f 100644 --- a/src/app/features/settings/developer-tools/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -1,12 +1,4 @@ -import React, { - FormEventHandler, - KeyboardEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Text, @@ -22,22 +14,20 @@ import { 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'; +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; + type AccountDataInfo = { type: string; content: object; @@ -46,45 +36,28 @@ type AccountDataInfo = { type AccountDataEditProps = { type: string; defaultContent: string; + submitChange: AccountDataSubmitCallback; onCancel: () => void; onSave: (info: AccountDataInfo) => void; }; -function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) { - const mx = useMatrixClient(); +function AccountDataEdit({ + type, + defaultContent, + submitChange, + onCancel, + onSave, +}: AccountDataEditProps) { const alive = useAlive(); const textAreaRef = useRef(null); const [jsonError, setJSONError] = useState(); - 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 = (evt) => { - intentHandler(evt); - if (isKeyHotkey('escape', evt)) { - const cursor = Cursor.fromTextAreaElement(getTarget()); - operations.deselect(cursor); - } - }; - - const [submitState, submit] = useAsyncCallback( - useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx]) + const { handleKeyDown, operations, getTarget } = useTextAreaCodeEditor( + textAreaRef, + EDITOR_INTENT_SPACE_COUNT ); + + const [submitState, submit] = useAsyncCallback(submitChange); const submitting = submitState.status === AsyncStatus.Loading; const handleSubmit: FormEventHandler = (evt) => { @@ -140,7 +113,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData as="form" onSubmit={handleSubmit} grow="Yes" - className={css.EditorContent} + style={{ + padding: config.space.S400, + }} direction="Column" gap="400" aria-disabled={submitting} @@ -174,6 +149,7 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData fill="Soft" size="400" radii="300" + type="button" onClick={onCancel} disabled={submitting} > @@ -194,7 +170,9 @@ function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountData + Account Data @@ -259,15 +243,20 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) export type AccountDataEditorProps = { type?: string; + content?: object; + submitChange: AccountDataSubmitCallback; requestClose: () => void; }; -export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) { - const mx = useMatrixClient(); - +export function AccountDataEditor({ + type, + content, + submitChange, + requestClose, +}: AccountDataEditorProps) { const [data, setData] = useState({ type: type ?? '', - content: mx.getAccountData(type ?? '')?.getContent() ?? {}, + content: content ?? {}, }); const [edit, setEdit] = useState(!type); @@ -316,6 +305,7 @@ export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps diff --git a/src/app/components/BetaNoticeBadge.tsx b/src/app/components/BetaNoticeBadge.tsx new file mode 100644 index 0000000..d185987 --- /dev/null +++ b/src/app/components/BetaNoticeBadge.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { TooltipProvider, Tooltip, Box, Text, Badge, toRem } from 'folds'; + +export function BetaNoticeBadge() { + return ( + + + Notice + This feature is under testing and may change over time. + + + } + > + {(triggerRef) => ( + + Beta + + )} + + ); +} diff --git a/src/app/components/HexColorPickerPopOut.tsx b/src/app/components/HexColorPickerPopOut.tsx new file mode 100644 index 0000000..d8fb4bc --- /dev/null +++ b/src/app/components/HexColorPickerPopOut.tsx @@ -0,0 +1,59 @@ +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, opened: boolean) => ReactNode; + picker: ReactNode; + onRemove?: () => void; +}; +export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPickerPopOutProps) { + const [cords, setCords] = useState(); + + const handleOpen: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + {picker} + {onRemove && ( + + )} + + + + } + > + {children(handleOpen, !!cords)} + + ); +} diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx new file mode 100644 index 0000000..ddd1903 --- /dev/null +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -0,0 +1,138 @@ +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; +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; +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 = { + icons: JoinRuleIcons; + labels: JoinRuleLabels; + rules: T; + value: T[number]; + onChange: (value: T[number]) => void; + disabled?: boolean; + changing?: boolean; +}; +export function JoinRulesSwitcher({ + icons, + labels, + rules, + value, + onChange, + disabled, + changing, +}: JoinRulesSwitcherProps) { + const [cords, setCords] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleChange = useCallback( + (selectedRule: JoinRule) => { + setCords(undefined); + onChange(selectedRule); + }, + [onChange] + ); + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {rules.map((rule) => ( + handleChange(rule)} + before={} + disabled={disabled} + > + + {labels[rule]} + + + ))} + + + + } + > + + + ); +} diff --git a/src/app/components/MemberSortMenu.tsx b/src/app/components/MemberSortMenu.tsx new file mode 100644 index 0000000..d77c80c --- /dev/null +++ b/src/app/components/MemberSortMenu.tsx @@ -0,0 +1,45 @@ +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 ( + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {memberSortMenu.map((menuItem, index) => ( + { + onSelect(index); + requestClose(); + }} + > + {menuItem.name} + + ))} + + + ); +} diff --git a/src/app/components/MembershipFilterMenu.tsx b/src/app/components/MembershipFilterMenu.tsx new file mode 100644 index 0000000..bf17677 --- /dev/null +++ b/src/app/components/MembershipFilterMenu.tsx @@ -0,0 +1,49 @@ +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 ( + evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + {membershipFilterMenu.map((menuItem, index) => ( + { + onSelect(index); + requestClose(); + }} + > + {menuItem.name} + + ))} + + + ); +} diff --git a/src/app/components/cutout-card/CutoutCard.css.ts b/src/app/components/cutout-card/CutoutCard.css.ts new file mode 100644 index 0000000..8bdf020 --- /dev/null +++ b/src/app/components/cutout-card/CutoutCard.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const CutoutCard = style({ + borderRadius: config.radii.R300, + borderWidth: config.borderWidth.B300, + overflow: 'hidden', +}); diff --git a/src/app/components/cutout-card/CutoutCard.tsx b/src/app/components/cutout-card/CutoutCard.tsx new file mode 100644 index 0000000..bf5ddf8 --- /dev/null +++ b/src/app/components/cutout-card/CutoutCard.tsx @@ -0,0 +1,15 @@ +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) => ( + + ) +); diff --git a/src/app/components/cutout-card/index.ts b/src/app/components/cutout-card/index.ts new file mode 100644 index 0000000..4ce2f8b --- /dev/null +++ b/src/app/components/cutout-card/index.ts @@ -0,0 +1 @@ +export * from './CutoutCard'; diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 2873508..72a60f2 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -654,6 +654,7 @@ export function EmojiBoard({ onCustomEmojiSelect, onStickerSelect, allowTextCustomEmoji, + addToRecentEmoji = true, }: { tab?: EmojiBoardTab; onTabChange?: (tab: EmojiBoardTab) => void; @@ -664,6 +665,7 @@ export function EmojiBoard({ 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; @@ -735,7 +737,9 @@ export function EmojiBoard({ 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(); } } diff --git a/src/app/components/member-tile/MemberTile.tsx b/src/app/components/member-tile/MemberTile.tsx new file mode 100644 index 0000000..d36d46c --- /dev/null +++ b/src/app/components/member-tile/MemberTile.tsx @@ -0,0 +1,53 @@ +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 ( + + + } + /> + + + + {name} + + + + {username} + + + + {after} + + ); + } +); diff --git a/src/app/components/member-tile/index.ts b/src/app/components/member-tile/index.ts new file mode 100644 index 0000000..463f621 --- /dev/null +++ b/src/app/components/member-tile/index.ts @@ -0,0 +1 @@ +export * from './MemberTile'; diff --git a/src/app/components/member-tile/style.css.ts b/src/app/components/member-tile/style.css.ts new file mode 100644 index 0000000..7cfe894 --- /dev/null +++ b/src/app/components/member-tile/style.css.ts @@ -0,0 +1,32 @@ +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, +]); diff --git a/src/app/components/power/PowerColorBadge.tsx b/src/app/components/power/PowerColorBadge.tsx new file mode 100644 index 0000000..a1df012 --- /dev/null +++ b/src/app/components/power/PowerColorBadge.tsx @@ -0,0 +1,21 @@ +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) => ( + + ) +); diff --git a/src/app/components/power/PowerIcon.tsx b/src/app/components/power/PowerIcon.tsx new file mode 100644 index 0000000..6f6df24 --- /dev/null +++ b/src/app/components/power/PowerIcon.tsx @@ -0,0 +1,15 @@ +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) ? ( + {iconSrc} + ) : ( + {name} + ); +} diff --git a/src/app/components/power/PowerSelector.tsx b/src/app/components/power/PowerSelector.tsx new file mode 100644 index 0000000..2b3b48c --- /dev/null +++ b/src/app/components/power/PowerSelector.tsx @@ -0,0 +1,94 @@ +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( + ({ powerLevelTags, value, onChange }, ref) => ( + + + +
+ {getPowers(powerLevelTags).map((power) => { + const selected = value === power; + const tag = powerLevelTags[power]; + + return ( + onChange(power)} + before={} + after={{power}} + > + + {tag.name} + + + ); + })} +
+
+
+
+ ) +); + +type PowerSwitcherProps = PowerSelectorProps & { + children: (handleOpen: MouseEventHandler, opened: boolean) => ReactNode; +}; +export function PowerSwitcher({ powerLevelTags, value, onChange, children }: PowerSwitcherProps) { + const [menuCords, setMenuCords] = useState(); + + const handleOpen: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + { + onChange(v); + setMenuCords(undefined); + }} + /> + + } + > + {children(handleOpen, !!menuCords)} + + ); +} diff --git a/src/app/components/power/index.ts b/src/app/components/power/index.ts new file mode 100644 index 0000000..e288780 --- /dev/null +++ b/src/app/components/power/index.ts @@ -0,0 +1,3 @@ +export * from './PowerColorBadge'; +export * from './PowerIcon'; +export * from './PowerSelector'; diff --git a/src/app/components/power/style.css.ts b/src/app/components/power/style.css.ts new file mode 100644 index 0000000..bf75298 --- /dev/null +++ b/src/app/components/power/style.css.ts @@ -0,0 +1,73 @@ +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; diff --git a/src/app/components/server-badge/ServerBadge.tsx b/src/app/components/server-badge/ServerBadge.tsx new file mode 100644 index 0000000..f61a146 --- /dev/null +++ b/src/app/components/server-badge/ServerBadge.tsx @@ -0,0 +1,16 @@ +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) => ( + + + {server} + + +)); diff --git a/src/app/components/server-badge/index.ts b/src/app/components/server-badge/index.ts new file mode 100644 index 0000000..eed8918 --- /dev/null +++ b/src/app/components/server-badge/index.ts @@ -0,0 +1 @@ +export * from './ServerBadge'; diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index d1a7ec6..195d444 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -18,16 +18,14 @@ import { 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; @@ -154,11 +152,14 @@ function SettingsMenuItem({ 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(); }; diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ffff0f4..27f7283 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -29,7 +29,7 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread'; 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'; @@ -41,6 +41,8 @@ import { getViaServers } from '../../plugins/via-servers'; 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; @@ -54,6 +56,8 @@ const RoomNavItemMenu = forwardRef( 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); @@ -73,7 +77,7 @@ const RoomNavItemMenu = forwardRef( }; const handleRoomSettings = () => { - toggleRoomSettings(room.roomId); + openRoomSettings(room.roomId, space?.roomId); requestClose(); }; diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx new file mode 100644 index 0000000..42192c0 --- /dev/null +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -0,0 +1,172 @@ +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(() => { + 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 ( + + + + + ( + + )} + /> + + + {roomName} + + + + {screenSize === ScreenSize.Mobile && ( + + + + )} + + + + +
+ {menuItems.map((item) => ( + } + onClick={() => setActivePage(item.page)} + > + + {item.name} + + + ))} +
+
+
+ + ) + } + > + {activePage === RoomSettingsPage.GeneralPage && ( + + )} + {activePage === RoomSettingsPage.MembersPage && ( + + )} + {activePage === RoomSettingsPage.PermissionsPage && ( + + )} + {activePage === RoomSettingsPage.EmojisStickersPage && ( + + )} + {activePage === RoomSettingsPage.DeveloperToolsPage && ( + + )} +
+ ); +} diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx new file mode 100644 index 0000000..bc96777 --- /dev/null +++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx @@ -0,0 +1,39 @@ +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 ( + + + + + + + + ); +} + +export function RoomSettingsRenderer() { + const state = useRoomSettingsState(); + + if (!state) return null; + return ; +} diff --git a/src/app/features/room-settings/developer-tools/DevelopTools.tsx b/src/app/features/room-settings/developer-tools/DevelopTools.tsx new file mode 100644 index 0000000..29b6aa5 --- /dev/null +++ b/src/app/features/room-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/room-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/room-settings/developer-tools/SendRoomEvent.tsx new file mode 100644 index 0000000..f25ba7c --- /dev/null +++ b/src/app/features/room-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/room-settings/developer-tools/StateEventEditor.tsx b/src/app/features/room-settings/developer-tools/StateEventEditor.tsx new file mode 100644 index 0000000..6ee19be --- /dev/null +++ b/src/app/features/room-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/room-settings/developer-tools/index.ts b/src/app/features/room-settings/developer-tools/index.ts new file mode 100644 index 0000000..1fcceff --- /dev/null +++ b/src/app/features/room-settings/developer-tools/index.ts @@ -0,0 +1 @@ +export * from './DevelopTools'; diff --git a/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/room-settings/emojis-stickers/EmojisStickers.tsx new file mode 100644 index 0000000..ad8ffae --- /dev/null +++ b/src/app/features/room-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/room-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/room-settings/emojis-stickers/RoomPacks.tsx new file mode 100644 index 0000000..56dda54 --- /dev/null +++ b/src/app/features/room-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/room-settings/emojis-stickers/index.ts b/src/app/features/room-settings/emojis-stickers/index.ts new file mode 100644 index 0000000..9c9e9f5 --- /dev/null +++ b/src/app/features/room-settings/emojis-stickers/index.ts @@ -0,0 +1 @@ +export * from './EmojisStickers'; diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx new file mode 100644 index 0000000..6d66406 --- /dev/null +++ b/src/app/features/room-settings/general/General.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '../../../components/page'; +import { RoomProfile } from './RoomProfile'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRoom } from '../../../hooks/useRoom'; +import { RoomEncryption } from './RoomEncryption'; +import { RoomHistoryVisibility } from './RoomHistoryVisibility'; +import { RoomJoinRules } from './RoomJoinRules'; +import { RoomLocalAddresses, RoomPublishedAddresses } from './RoomAddress'; + +type GeneralProps = { + requestClose: () => void; +}; +export function General({ requestClose }: GeneralProps) { + const room = useRoom(); + const powerLevels = usePowerLevels(room); + + return ( + + + + + + General + + + + + + + + + + + + + + + + Options + + + + + + Addresses + + + + + + + + + ); +} diff --git a/src/app/features/room-settings/general/RoomAddress.tsx b/src/app/features/room-settings/general/RoomAddress.tsx new file mode 100644 index 0000000..dfe6645 --- /dev/null +++ b/src/app/features/room-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 '../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 room 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/room-settings/general/RoomEncryption.tsx b/src/app/features/room-settings/general/RoomEncryption.tsx new file mode 100644 index 0000000..7d95fe3 --- /dev/null +++ b/src/app/features/room-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 '../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/room-settings/general/RoomHistoryVisibility.tsx b/src/app/features/room-settings/general/RoomHistoryVisibility.tsx new file mode 100644 index 0000000..d36e312 --- /dev/null +++ b/src/app/features/room-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 '../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/room-settings/general/RoomJoinRules.tsx b/src/app/features/room-settings/general/RoomJoinRules.tsx new file mode 100644 index 0000000..a98fee6 --- /dev/null +++ b/src/app/features/room-settings/general/RoomJoinRules.tsx @@ -0,0 +1,124 @@ +import React, { useCallback, useMemo } from 'react'; +import { color, Text } from 'folds'; +import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; +import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; +import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels'; +import { + JoinRulesSwitcher, + useRoomJoinRuleIcon, + useRoomJoinRuleLabel, +} from '../../../components/JoinRulesSwitcher'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useRoom } from '../../../hooks/useRoom'; +import { StateEvent } from '../../../../types/matrix/room'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { useSpaceOptionally } from '../../../hooks/useSpace'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { getStateEvents } from '../../../utils/room'; + +type RestrictedRoomAllowContent = { + room_id: string; + type: RestrictedAllowType; +}; + +type RoomJoinRulesProps = { + powerLevels: IPowerLevels; +}; +export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const roomVersion = parseInt(room.getVersion(), 10); + const allowRestricted = roomVersion >= 8; + const allowKnock = roomVersion >= 7; + const space = useSpaceOptionally(); + + const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId()); + const canEdit = powerLevelAPI.canSendStateEvent( + powerLevels, + StateEvent.RoomHistoryVisibility, + userPowerLevel + ); + + const joinRuleEvent = useStateEvent(room, StateEvent.RoomJoinRules); + const content = joinRuleEvent?.getContent(); + 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 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/room-settings/general/RoomProfile.tsx b/src/app/features/room-settings/general/RoomProfile.tsx new file mode 100644 index 0000000..c0d89b4 --- /dev/null +++ b/src/app/features/room-settings/general/RoomProfile.tsx @@ -0,0 +1,351 @@ +import { + Avatar, + Box, + Button, + Chip, + color, + Icon, + Icons, + Input, + Spinner, + Text, + TextArea, +} from 'folds'; +import React, { FormEventHandler, useCallback, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import Linkify from 'linkify-react'; +import classNames from 'classnames'; +import { JoinRule, MatrixError } from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { useRoom } from '../../../hooks/useRoom'; +import { + useRoomAvatar, + useRoomJoinRule, + useRoomName, + useRoomTopic, +} from '../../../hooks/useRoomMeta'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { BreakWord, LineClamp3 } from '../../../styles/Text.css'; +import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser'; +import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { mxcUrlToHttp } from '../../../utils/matrix'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { IPowerLevels, usePowerLevelsAPI } from '../../../hooks/usePowerLevels'; +import { StateEvent } from '../../../../types/matrix/room'; +import { CompactUploadCardRenderer } from '../../../components/upload-card'; +import { useObjectURL } from '../../../hooks/useObjectURL'; +import { createUploadAtom, UploadSuccess } from '../../../state/upload'; +import { useFilePicker } from '../../../hooks/useFilePicker'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useAlive } from '../../../hooks/useAlive'; + +type RoomProfileEditProps = { + canEditAvatar: boolean; + canEditName: boolean; + canEditTopic: boolean; + avatar?: string; + name?: string; + topic?: string; + onClose: () => void; +}; +export function RoomProfileEdit({ + canEditAvatar, + canEditName, + canEditTopic, + avatar, + name, + topic, + onClose, +}: RoomProfileEditProps) { + const room = useRoom(); + const mx = useMatrixClient(); + const alive = useAlive(); + const useAuthentication = useMediaAuthentication(); + const joinRule = useRoomJoinRule(room); + const [roomAvatar, setRoomAvatar] = useState(avatar); + + const avatarUrl = roomAvatar + ? mxcUrlToHttp(mx, roomAvatar, useAuthentication) ?? undefined + : undefined; + + const [imageFile, setImageFile] = useState(); + 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 = (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 ( + + + + Avatar + {uploadAtom ? ( + + + + ) : ( + + + {!roomAvatar && avatar && ( + + )} + {roomAvatar && ( + + )} + + )} + + + + ( + + )} + /> + + + + + Name + + + + Topic +