diff --git a/.changeset/implement_an_interface_to_allow_roomspace_profile_customization_without_needing_to_call_the_relating_commands_directly.md b/.changeset/implement_an_interface_to_allow_roomspace_profile_customization_without_needing_to_call_the_relating_commands_directly.md new file mode 100644 index 000000000..75f66a09f --- /dev/null +++ b/.changeset/implement_an_interface_to_allow_roomspace_profile_customization_without_needing_to_call_the_relating_commands_directly.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Implement an interface to allow room/space profile customization without needing to call the relating commands directly. diff --git a/src/app/features/common-settings/cosmetics/Cosmetics.tsx b/src/app/features/common-settings/cosmetics/Cosmetics.tsx index 70c4cacd0..dbfbd1390 100644 --- a/src/app/features/common-settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/common-settings/cosmetics/Cosmetics.tsx @@ -1,5 +1,32 @@ -import { useCallback } from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll, Switch } from 'folds'; +import { + ChangeEvent, + ChangeEventHandler, + FormEventHandler, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { + Box, + Text, + IconButton, + Icon, + Icons, + Scroll, + Switch, + Avatar, + Input, + config, + Button, + Spinner, + OverlayBackdrop, + Overlay, + OverlayCenter, + Modal, + Dialog, + Header, +} from 'folds'; import { Page, PageContent, PageHeader } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -11,25 +38,352 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { createLogger } from '$utils/debug'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; +import { UserAvatar } from '$components/user-avatar'; +import { nameInitials } from '$utils/common'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { UserProfile, useUserProfile } from '$hooks/useUserProfile'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { Room, RoomMember } from '$types/matrix-sdk'; +import { Command, useCommands } from '$hooks/useCommands'; +import { useCapabilities } from '$hooks/useCapabilities'; +import { useObjectURL } from '$hooks/useObjectURL'; +import { createUploadAtom, UploadSuccess } from '$state/upload'; +import { useFilePicker } from '$hooks/useFilePicker'; +import { CompactUploadCardRenderer } from '$components/upload-card'; +import FocusTrap from 'focus-trap-react'; +import { ImageEditor } from '$components/image-editor'; +import { stopPropagation } from '$utils/keyboard'; +import { ModalWide } from '$styles/Modal.css'; +import { NameColorEditor } from '$features/settings/account/NameColorEditor'; +import { PronounEditor, PronounSet } from '$features/settings/account/PronounEditor'; -type CosmeticsProps = { - requestClose: () => void; +const log = createLogger('Cosmetics'); + +type CosmeticsSettingProps = { + profile: UserProfile; + member: RoomMember; + userId: string; + room: Room; }; +export function CosmeticsAvatar({ profile, member, userId, room }: CosmeticsSettingProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const capabilities = useCapabilities(); + const [alertRemove, setAlertRemove] = useState(false); + const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false; -const log = createLogger('Cosmetics'); + const avatarMxc = member.getMxcAvatarUrl(); + const avatarUrl = + avatarMxc && (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined); + + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + + const myRoomAvatar = useCommands(mx, room)[Command.MyRoomAvatar]; + const handleUploaded = useCallback( + (upload: UploadSuccess) => { + const { mxc } = upload; + myRoomAvatar.exe(mxc); + handleRemoveUpload(); + }, + [myRoomAvatar, handleRemoveUpload] + ); + + const handleRemoveAvatar = () => { + myRoomAvatar.exe(''); + setAlertRemove(false); + }; + + return ( + + ( + {nameInitials(room.getMember(userId)!.rawDisplayName)} + )} + /> + + } + > + {uploadAtom ? ( + + + + ) : ( + + + {avatarUrl && + avatarUrl !== + mxcUrlToHttp(mx, profile.avatarUrl ?? '', useAuthentication, 96, 96, 'crop') && ( + + )} + + )} + + {imageFileURL && ( + }> + + + + + + + + + )} + + }> + + setAlertRemove(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + Remove Room Avatar + + setAlertRemove(false)} radii="300"> + + +
+ + + Are you sure you want to remove room avatar? + + + +
+
+
+
+
+ ); +} + +export function CosmeticsNickname({ profile, member, userId, room }: CosmeticsSettingProps) { + const mx = useMatrixClient(); + + const defaultDisplayName = member.rawDisplayName; + const [displayName, setDisplayName] = useState(defaultDisplayName); + const hasChanges = displayName !== defaultDisplayName; + + const myRoomNick = useCommands(mx, room)[Command.MyRoomNick]; + const [changeState, changeDisplayName] = useAsyncCallback((name: string) => myRoomNick.exe(name)); + const changingDisplayName = changeState.status === AsyncStatus.Loading; + + useEffect(() => { + setDisplayName(defaultDisplayName); + }, [defaultDisplayName]); + + const handleChange: ChangeEventHandler = (evt) => { + const name = evt.currentTarget.value; + setDisplayName(name); + }; + + const handleReset = () => { + if (hasChanges) { + setDisplayName(defaultDisplayName); + } else { + setDisplayName(profile.displayName ?? getMxIdLocalPart(userId) ?? userId); + } + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (changingDisplayName) return; + + const target = evt.target as HTMLFormElement | undefined; + const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined; + const name = displayNameInput?.value; + changeDisplayName(name ?? ''); + }; + + return ( + + + + + + + + ) + } + /> + + + + + + ); +} + +export function CosmeticsFont({ + room, + isSpace, + font, + disabled, +}: { + room: Room; + isSpace: boolean; + font?: string; + disabled: boolean; +}) { + const mx = useMatrixClient(); + + const initialFont = (/^"?(.*?)"?, var\(--font-secondary\)$/.exec(font ?? '') ?? [''])[1]; + const [val, setVal] = useState(initialFont); + + useEffect(() => setVal(initialFont), [initialFont]); + + const fontCommand = useCommands(mx, room)[isSpace ? Command.SFont : Command.Font]; + const handleSave = () => { + if (val === initialFont) return; + fontCommand.exe(val); + }; + + const handleChange = (e: ChangeEvent) => { + setVal(e.currentTarget.value); + }; + + return ( + e.key === 'Enter' && handleSave()} + style={{ width: '232px' }} + /> + } + /> + ); +} + +type CosmeticsProps = { + requestClose: () => void; +}; export function Cosmetics({ requestClose }: CosmeticsProps) { const mx = useMatrixClient(); + const userId = mx.getUserId()!; + const profile = useUserProfile(userId); const room = useRoom(); + const roomProfile = useUserProfile(userId, room); const creators = useRoomCreators(room); + const member = room.getMember(userId)!; const powerLevels = usePowerLevels(room); const isSpace = room.isSpaceRoom(); const permissions = useRoomPermissions(creators, powerLevels); const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); + const commands = useCommands(mx, room); + const getLevel = (eventType: string) => (powerLevels as any).events?.[eventType] ?? 50; + const canHaveRoomColor = getLevel(StateEvent.RoomCosmeticsColor) === 0; + const canHaveRoomPronouns = getLevel(StateEvent.RoomCosmeticsPronouns) === 0; + const canHaveRoomFont = getLevel(StateEvent.RoomCosmeticsFont) === 0; + const handleToggle = useCallback( async (eventType: string, enabled: boolean) => { const newLevel = enabled ? 0 : 50; @@ -71,97 +425,89 @@ export function Cosmetics({ requestClose }: CosmeticsProps) { - Settings - + Profile + {!isSpace && ( + + + + )} + {!isSpace && ( + + + + )} - handleToggle(StateEvent.RoomCosmeticsColor, enabled)} - disabled={!canEditPermissions} - /> + + commands[isSpace ? Command.SColor : Command.Color].exe(color ?? 'clear') } /> - - handleToggle(StateEvent.RoomCosmeticsFont, enabled)} - disabled={!canEditPermissions} - /> + + commands[isSpace ? Command.SPronoun : Command.Pronoun].exe( + p + .map(({ language, summary }: PronounSet) => + language ? `${language}:${summary}` : summary + ) + .join() + ) } /> - - - handleToggle(StateEvent.RoomCosmeticsPronouns, enabled) - } - disabled={!canEditPermissions} - /> - } + - - {/* --- COMMAND REFERENCE SECTION --- */} - Commands - - - + Settings handleToggle(StateEvent.RoomCosmeticsColor, enabled)} + disabled={!canEditPermissions} + /> + } /> + handleToggle(StateEvent.RoomCosmeticsPronouns, enabled) + } + disabled={!canEditPermissions} + /> + } /> handleToggle(StateEvent.RoomCosmeticsFont, enabled)} + disabled={!canEditPermissions} + /> + } /> diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 8707cefa6..7c973d995 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -24,6 +24,7 @@ type RoomSettingsMenuItem = { page: RoomSettingsPage; name: string; icon: IconSrc; + activeIcon?: IconSrc; }; const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => @@ -48,6 +49,7 @@ const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => page: RoomSettingsPage.CosmeticsPage, name: 'Cosmetics', icon: Icons.Alphabet, + activeIcon: Icons.AlphabetUnderline, }, { page: RoomSettingsPage.EmojisStickersPage, @@ -141,29 +143,34 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
- {menuItems.map((item) => ( - - } - onClick={() => setActivePage(item.page)} - > - { + const currentIcon = + activePage === item.page && item.activeIcon ? item.activeIcon : item.icon; + + return ( + + } + onClick={() => setActivePage(item.page)} > - {item.name} - - - ))} + + {item.name} + + + ); + })}
diff --git a/src/app/features/settings/account/NameColorEditor.tsx b/src/app/features/settings/account/NameColorEditor.tsx index df7ce7de9..2c6e10a10 100644 --- a/src/app/features/settings/account/NameColorEditor.tsx +++ b/src/app/features/settings/account/NameColorEditor.tsx @@ -5,11 +5,20 @@ import { SettingTile } from '$components/setting-tile'; import { HexColorPickerPopOut } from '$components/HexColorPickerPopOut'; type NameColorEditorProps = { + title: string; + description?: string; current?: string; onSave: (color: string | null) => void; + disabled?: boolean; }; -export function NameColorEditor({ current, onSave }: NameColorEditorProps) { +export function NameColorEditor({ + title, + description, + current, + onSave, + disabled, +}: NameColorEditorProps) { const stripQuotes = (str?: string) => { if (!str) return ''; // to solve the silly tuwunel @@ -48,10 +57,7 @@ export function NameColorEditor({ current, onSave }: NameColorEditorProps) { return ( - + diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 85bf0bc37..42204d177 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -538,6 +538,8 @@ function ProfileExtended({ profile, userId }: ProfileProps) { gap="400" > handleSaveField('moe.sable.app.name_color', color)} /> @@ -549,6 +551,7 @@ function ProfileExtended({ profile, userId }: ProfileProps) { gap="400" > handleSaveField('io.fsky.nyx.pronouns', p)} /> diff --git a/src/app/features/settings/account/PronounEditor.tsx b/src/app/features/settings/account/PronounEditor.tsx index 29ee9467e..53aa64d27 100644 --- a/src/app/features/settings/account/PronounEditor.tsx +++ b/src/app/features/settings/account/PronounEditor.tsx @@ -3,18 +3,20 @@ import { Input } from 'folds'; import { SettingTile } from '$components/setting-tile'; import { parsePronounsInput } from '$utils/pronouns'; -type PronounSet = { +export type PronounSet = { summary: string; language?: string; grammatical_gender?: string; }; type PronounEditorProps = { + title: string; current: PronounSet[]; onSave: (p: PronounSet[]) => void; + disabled?: boolean; }; -export function PronounEditor({ current, onSave }: PronounEditorProps) { +export function PronounEditor({ title, current, onSave, disabled }: PronounEditorProps) { const initialString = current .map((p) => `${p.language ? `${p.language}:` : ''}${p.summary}`) .join(', '); @@ -35,7 +37,7 @@ export function PronounEditor({ current, onSave }: PronounEditorProps) { return ( @@ -47,6 +48,7 @@ const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] => page: SpaceSettingsPage.CosmeticsPage, name: 'Cosmetics', icon: Icons.Alphabet, + activeIcon: Icons.AlphabetUnderline, }, { page: SpaceSettingsPage.EmojisStickersPage, @@ -132,26 +134,34 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
- {menuItems.map((item) => ( - } - onClick={() => setActivePage(item.page)} - > - { + const currentIcon = + activePage === item.page && item.activeIcon ? item.activeIcon : item.icon; + + return ( + + } + onClick={() => setActivePage(item.page)} > - {item.name} - - - ))} + + {item.name} + + + ); + })}