mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Merge 13dd8fcc06 into 46c02b89de
				
					
				
			This commit is contained in:
		
						commit
						97891864e0
					
				
					 24 changed files with 1734 additions and 868 deletions
				
			
		
							
								
								
									
										12
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -62,7 +62,8 @@
 | 
			
		|||
        "slate-dom": "0.112.2",
 | 
			
		||||
        "slate-history": "0.110.3",
 | 
			
		||||
        "slate-react": "0.112.1",
 | 
			
		||||
        "ua-parser-js": "1.0.35"
 | 
			
		||||
        "ua-parser-js": "1.0.35",
 | 
			
		||||
        "zod": "4.1.8"
 | 
			
		||||
      },
 | 
			
		||||
      "devDependencies": {
 | 
			
		||||
        "@esbuild-plugins/node-globals-polyfill": "0.2.3",
 | 
			
		||||
| 
						 | 
				
			
			@ -12112,6 +12113,15 @@
 | 
			
		|||
      "funding": {
 | 
			
		||||
        "url": "https://github.com/sponsors/sindresorhus"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/zod": {
 | 
			
		||||
      "version": "4.1.8",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
 | 
			
		||||
      "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "url": "https://github.com/sponsors/colinhacks"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -73,7 +73,8 @@
 | 
			
		|||
    "slate-dom": "0.112.2",
 | 
			
		||||
    "slate-history": "0.110.3",
 | 
			
		||||
    "slate-react": "0.112.1",
 | 
			
		||||
    "ua-parser-js": "1.0.35"
 | 
			
		||||
    "ua-parser-js": "1.0.35",
 | 
			
		||||
    "zod": "4.1.8"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@esbuild-plugins/node-globals-polyfill": "0.2.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor';
 | 
			
		|||
const EDITOR_INTENT_SPACE_COUNT = 2;
 | 
			
		||||
 | 
			
		||||
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
 | 
			
		||||
export type AccountDataDeleteCallback = (type: string) => Promise<void>;
 | 
			
		||||
 | 
			
		||||
type AccountDataInfo = {
 | 
			
		||||
  type: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -83,8 +84,7 @@ function AccountDataEdit({
 | 
			
		|||
 | 
			
		||||
    if (
 | 
			
		||||
      !typeStr ||
 | 
			
		||||
      parsedContent === null ||
 | 
			
		||||
      defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT)
 | 
			
		||||
      parsedContent === null
 | 
			
		||||
    ) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +121,7 @@ function AccountDataEdit({
 | 
			
		|||
      aria-disabled={submitting}
 | 
			
		||||
    >
 | 
			
		||||
      <Box shrink="No" direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Account Data</Text>
 | 
			
		||||
        <Text size="L400">Field Name</Text>
 | 
			
		||||
        <Box gap="300">
 | 
			
		||||
          <Box grow="Yes" direction="Column">
 | 
			
		||||
            <Input
 | 
			
		||||
| 
						 | 
				
			
			@ -195,9 +195,22 @@ function AccountDataEdit({
 | 
			
		|||
type AccountDataViewProps = {
 | 
			
		||||
  type: string;
 | 
			
		||||
  defaultContent: string;
 | 
			
		||||
  onEdit: () => void;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
  onEdit?: () => void;
 | 
			
		||||
  submitDelete?: AccountDataDeleteCallback;
 | 
			
		||||
};
 | 
			
		||||
function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) {
 | 
			
		||||
function AccountDataView({ type, defaultContent, onEdit, requestClose, submitDelete }: AccountDataViewProps) {
 | 
			
		||||
  const [deleteState, deleteCallback] = useAsyncCallback<void, MatrixError, []>(useCallback(
 | 
			
		||||
    async () => {
 | 
			
		||||
      if (submitDelete !== undefined) {
 | 
			
		||||
        await submitDelete(type);
 | 
			
		||||
        requestClose();
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [type, submitDelete, requestClose],
 | 
			
		||||
  ));
 | 
			
		||||
  const deleting = deleteState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      direction="Column"
 | 
			
		||||
| 
						 | 
				
			
			@ -208,7 +221,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
 | 
			
		|||
    >
 | 
			
		||||
      <Box shrink="No" gap="300" alignItems="End">
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          <Text size="L400">Account Data</Text>
 | 
			
		||||
          <Text size="L400">Field Name</Text>
 | 
			
		||||
          <Input
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            size="400"
 | 
			
		||||
| 
						 | 
				
			
			@ -218,9 +231,23 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
 | 
			
		|||
            required
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
 | 
			
		||||
          <Text size="B400">Edit</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
        {onEdit && (
 | 
			
		||||
          <Button variant="Secondary" size="400" radii="300" onClick={onEdit}>
 | 
			
		||||
            <Text size="B400">Edit</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
        {submitDelete && (
 | 
			
		||||
          <Button
 | 
			
		||||
            variant="Critical"
 | 
			
		||||
            size="400"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={deleting}
 | 
			
		||||
            before={deleting && <Spinner variant="Critical" fill="Solid" size="300" />}
 | 
			
		||||
            onClick={deleteCallback}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B400">Delete</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">JSON Content</Text>
 | 
			
		||||
| 
						 | 
				
			
			@ -243,8 +270,9 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
 | 
			
		|||
 | 
			
		||||
export type AccountDataEditorProps = {
 | 
			
		||||
  type?: string;
 | 
			
		||||
  content?: object;
 | 
			
		||||
  submitChange: AccountDataSubmitCallback;
 | 
			
		||||
  content?: unknown;
 | 
			
		||||
  submitChange?: AccountDataSubmitCallback;
 | 
			
		||||
  submitDelete?: AccountDataDeleteCallback;
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -252,6 +280,7 @@ export function AccountDataEditor({
 | 
			
		|||
  type,
 | 
			
		||||
  content,
 | 
			
		||||
  submitChange,
 | 
			
		||||
  submitDelete,
 | 
			
		||||
  requestClose,
 | 
			
		||||
}: AccountDataEditorProps) {
 | 
			
		||||
  const [data, setData] = useState<AccountDataInfo>({
 | 
			
		||||
| 
						 | 
				
			
			@ -301,7 +330,7 @@ export function AccountDataEditor({
 | 
			
		|||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes" direction="Column">
 | 
			
		||||
        {edit ? (
 | 
			
		||||
        {(edit && submitChange) ? (
 | 
			
		||||
          <AccountDataEdit
 | 
			
		||||
            type={data.type}
 | 
			
		||||
            defaultContent={contentJSONStr}
 | 
			
		||||
| 
						 | 
				
			
			@ -313,7 +342,9 @@ export function AccountDataEditor({
 | 
			
		|||
          <AccountDataView
 | 
			
		||||
            type={data.type}
 | 
			
		||||
            defaultContent={contentJSONStr}
 | 
			
		||||
            onEdit={() => setEdit(true)}
 | 
			
		||||
            requestClose={requestClose}
 | 
			
		||||
            onEdit={submitChange ? () => setEdit(true) : undefined}
 | 
			
		||||
            submitDelete={submitDelete}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										54
									
								
								src/app/components/CollapsibleCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/app/components/CollapsibleCard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,54 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { Button, Icon, Icons, Text } from 'folds';
 | 
			
		||||
import { SequenceCard } from './sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../features/settings/styles.css';
 | 
			
		||||
import { SettingTile } from './setting-tile';
 | 
			
		||||
 | 
			
		||||
type CollapsibleCardProps = {
 | 
			
		||||
  expand: boolean;
 | 
			
		||||
  setExpand: (expand: boolean) => void;
 | 
			
		||||
  title?: ReactNode;
 | 
			
		||||
  description?: ReactNode;
 | 
			
		||||
  before?: ReactNode;
 | 
			
		||||
  children?: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function CollapsibleCard({
 | 
			
		||||
  expand,
 | 
			
		||||
  setExpand,
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  before,
 | 
			
		||||
  children,
 | 
			
		||||
}: CollapsibleCardProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SequenceCard
 | 
			
		||||
      className={SequenceCardStyle}
 | 
			
		||||
      variant="SurfaceVariant"
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      gap="400"
 | 
			
		||||
    >
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title={title}
 | 
			
		||||
        description={description}
 | 
			
		||||
        before={before}
 | 
			
		||||
        after={
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => setExpand(!expand)}
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            outlined
 | 
			
		||||
            before={
 | 
			
		||||
              <Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {expand && children}
 | 
			
		||||
    </SequenceCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { isKeyHotkey } from 'is-hotkey';
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +19,13 @@ import {
 | 
			
		|||
  Box,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  TooltipProvider,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  Badge,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { getMxIdServer } from '../../utils/matrix';
 | 
			
		||||
| 
						 | 
				
			
			@ -41,6 +48,11 @@ import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
 | 
			
		|||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
 | 
			
		||||
import { CutoutCard } from '../cutout-card';
 | 
			
		||||
import { SettingTile } from '../setting-tile';
 | 
			
		||||
import { useInterval } from '../../hooks/useInterval';
 | 
			
		||||
import { TextViewer } from '../text-viewer';
 | 
			
		||||
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
 | 
			
		||||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
import { useSetting } from '../../state/hooks/settings';
 | 
			
		||||
 | 
			
		||||
export function ServerChip({ server }: { server: string }) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
| 
						 | 
				
			
			@ -436,15 +448,24 @@ export function IgnoredUserAlert() {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function OptionsChip({ userId }: { userId: string }) {
 | 
			
		||||
export function OptionsChip({
 | 
			
		||||
  userId,
 | 
			
		||||
  extendedProfile,
 | 
			
		||||
}: {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  extendedProfile: ExtendedProfile | null;
 | 
			
		||||
}) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [cords, setCords] = useState<RectCords>();
 | 
			
		||||
  const [developerToolsEnabled] = useSetting(settingsAtom, 'developerTools');
 | 
			
		||||
 | 
			
		||||
  const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  const [profileFieldsOpen, setProfileFieldsOpen] = useState(false);
 | 
			
		||||
  const [menuCoords, setMenuCoords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const openMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCoords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const close = () => setCords(undefined);
 | 
			
		||||
  const closeMenu = () => setMenuCoords(undefined);
 | 
			
		||||
 | 
			
		||||
  const ignoredUsers = useIgnoredUsers();
 | 
			
		||||
  const ignored = ignoredUsers.includes(userId);
 | 
			
		||||
| 
						 | 
				
			
			@ -459,56 +480,163 @@ export function OptionsChip({ userId }: { userId: string }) {
 | 
			
		|||
  const ignoring = ignoreState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <PopOut
 | 
			
		||||
      anchor={cords}
 | 
			
		||||
      position="Bottom"
 | 
			
		||||
      align="Start"
 | 
			
		||||
      offset={4}
 | 
			
		||||
      content={
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: close,
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
            isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
 | 
			
		||||
            isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu>
 | 
			
		||||
            <div style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <MenuItem
 | 
			
		||||
                variant="Critical"
 | 
			
		||||
                fill="None"
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  toggleIgnore();
 | 
			
		||||
                  close();
 | 
			
		||||
                }}
 | 
			
		||||
                before={
 | 
			
		||||
                  ignoring ? (
 | 
			
		||||
                    <Spinner variant="Critical" size="50" />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <Icon size="50" src={Icons.Prohibited} />
 | 
			
		||||
                  )
 | 
			
		||||
                }
 | 
			
		||||
                disabled={ignoring}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
 | 
			
		||||
              </MenuItem>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Menu>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
 | 
			
		||||
        {ignoring ? (
 | 
			
		||||
          <Spinner variant="Secondary" size="50" />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Icon size="50" src={Icons.HorizontalDots} />
 | 
			
		||||
        )}
 | 
			
		||||
      </Chip>
 | 
			
		||||
    </PopOut>
 | 
			
		||||
    <>
 | 
			
		||||
      {extendedProfile && (
 | 
			
		||||
        <Overlay open={profileFieldsOpen} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                onDeactivate: () => setProfileFieldsOpen(false),
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal variant="Surface" size="500">
 | 
			
		||||
                <TextViewer
 | 
			
		||||
                  name="Profile Fields"
 | 
			
		||||
                  langName="json"
 | 
			
		||||
                  text={JSON.stringify(extendedProfile, null, 2)}
 | 
			
		||||
                  requestClose={() => setProfileFieldsOpen(false)}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCoords}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="Start"
 | 
			
		||||
        offset={4}
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: closeMenu,
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <div style={{ padding: config.space.S100 }}>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    toggleIgnore();
 | 
			
		||||
                    closeMenu();
 | 
			
		||||
                  }}
 | 
			
		||||
                  before={
 | 
			
		||||
                    ignoring ? (
 | 
			
		||||
                      <Spinner variant="Critical" size="50" />
 | 
			
		||||
                    ) : (
 | 
			
		||||
                      <Icon size="50" src={Icons.Prohibited} />
 | 
			
		||||
                    )
 | 
			
		||||
                  }
 | 
			
		||||
                  disabled={ignoring}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
                {extendedProfile && developerToolsEnabled && (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    variant="Surface"
 | 
			
		||||
                    fill="None"
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    onClick={() => {
 | 
			
		||||
                      setProfileFieldsOpen(true);
 | 
			
		||||
                      closeMenu();
 | 
			
		||||
                    }}
 | 
			
		||||
                    before={<Icon size="50" src={Icons.BlockCode} />}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="B300">View Profile Fields</Text>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <Chip variant="SurfaceVariant" radii="Pill" onClick={openMenu} aria-pressed={!!menuCoords}>
 | 
			
		||||
          {ignoring ? (
 | 
			
		||||
            <Spinner variant="Secondary" size="50" />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Icon size="50" src={Icons.HorizontalDots} />
 | 
			
		||||
          )}
 | 
			
		||||
        </Chip>
 | 
			
		||||
      </PopOut>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TimezoneChip({ timezone }: { timezone: string }) {
 | 
			
		||||
  const shortFormat = useMemo(
 | 
			
		||||
    () =>
 | 
			
		||||
      new Intl.DateTimeFormat(undefined, {
 | 
			
		||||
        dateStyle: undefined,
 | 
			
		||||
        timeStyle: 'short',
 | 
			
		||||
        timeZone: timezone,
 | 
			
		||||
      }),
 | 
			
		||||
    [timezone]
 | 
			
		||||
  );
 | 
			
		||||
  const longFormat = useMemo(
 | 
			
		||||
    () =>
 | 
			
		||||
      new Intl.DateTimeFormat(undefined, {
 | 
			
		||||
        dateStyle: 'long',
 | 
			
		||||
        timeStyle: 'short',
 | 
			
		||||
        timeZone: timezone,
 | 
			
		||||
      }),
 | 
			
		||||
    [timezone]
 | 
			
		||||
  );
 | 
			
		||||
  const [shortTime, setShortTime] = useState(shortFormat.format());
 | 
			
		||||
  const [longTime, setLongTime] = useState(longFormat.format());
 | 
			
		||||
  const updateTime = useCallback(() => {
 | 
			
		||||
    setShortTime(shortFormat.format());
 | 
			
		||||
    setLongTime(longFormat.format());
 | 
			
		||||
  }, [setShortTime, setLongTime, shortFormat, longFormat]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    updateTime();
 | 
			
		||||
  }, [timezone, updateTime]);
 | 
			
		||||
 | 
			
		||||
  useInterval(updateTime, 1000);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipProvider
 | 
			
		||||
      position="Top"
 | 
			
		||||
      offset={5}
 | 
			
		||||
      align="Center"
 | 
			
		||||
      tooltip={
 | 
			
		||||
        <Tooltip variant="SurfaceVariant" style={{ maxWidth: toRem(280) }}>
 | 
			
		||||
          <Box direction="Column" alignItems="Start" gap="100">
 | 
			
		||||
            <Box gap="100">
 | 
			
		||||
              <Text size="L400">Timezone:</Text>
 | 
			
		||||
              <Badge size="400" variant="Primary">
 | 
			
		||||
                <Text size="T200">{timezone}</Text>
 | 
			
		||||
              </Badge>
 | 
			
		||||
            </Box>
 | 
			
		||||
            <Text size="T200">{longTime}</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {(triggerRef) => (
 | 
			
		||||
        <Chip
 | 
			
		||||
          ref={triggerRef}
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          radii="Pill"
 | 
			
		||||
          style={{ cursor: 'initial' }}
 | 
			
		||||
          before={<Icon size="50" src={Icons.RecentClock} />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B300" truncate>
 | 
			
		||||
            {shortTime}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Chip>
 | 
			
		||||
      )}
 | 
			
		||||
    </TooltipProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence';
 | 
			
		|||
import { AvatarPresence, PresenceBadge } from '../presence';
 | 
			
		||||
import { ImageViewer } from '../image-viewer';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
import { ExtendedProfile } from '../../hooks/useExtendedProfile';
 | 
			
		||||
 | 
			
		||||
type UserHeroProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
 | 
			
		|||
type UserHeroNameProps = {
 | 
			
		||||
  displayName?: string;
 | 
			
		||||
  userId: string;
 | 
			
		||||
  extendedProfile?: ExtendedProfile;
 | 
			
		||||
};
 | 
			
		||||
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
 | 
			
		||||
export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) {
 | 
			
		||||
  const username = getMxIdLocalPart(userId);
 | 
			
		||||
  const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box grow="Yes" direction="Column" gap="0">
 | 
			
		||||
| 
						 | 
				
			
			@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
 | 
			
		|||
          {displayName ?? username ?? userId}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box alignItems="Center" gap="100" wrap="Wrap">
 | 
			
		||||
      <Box alignItems="Start" gap="100" wrap="Wrap" direction='Column'>
 | 
			
		||||
        <Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
 | 
			
		||||
          @{username}
 | 
			
		||||
          {pronouns && <span> · {pronouns.map(({ summary }) => summary).join(", ")}</span>}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { Box, Button, config, Icon, Icons, Text } from 'folds';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import React, { useEffect, useMemo } from 'react';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { UserHero, UserHeroName } from './UserHero';
 | 
			
		||||
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		|||
import { usePowerLevels } from '../../hooks/usePowerLevels';
 | 
			
		||||
import { useRoom } from '../../hooks/useRoom';
 | 
			
		||||
import { useUserPresence } from '../../hooks/useUserPresence';
 | 
			
		||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
 | 
			
		||||
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip, TimezoneChip } from './UserChips';
 | 
			
		||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
 | 
			
		||||
import { PowerChip } from './PowerChip';
 | 
			
		||||
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
 | 
			
		|||
import { CreatorChip } from './CreatorChip';
 | 
			
		||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
 | 
			
		||||
import { DirectCreateSearchParams } from '../../pages/paths';
 | 
			
		||||
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
 | 
			
		||||
 | 
			
		||||
type UserRoomProfileProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -56,9 +57,24 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 | 
			
		|||
  const displayName = getMemberDisplayName(room, userId);
 | 
			
		||||
  const avatarMxc = getMemberAvatarMxc(room, userId);
 | 
			
		||||
  const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
 | 
			
		||||
  const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
 | 
			
		||||
  const timezone = useMemo(() => {
 | 
			
		||||
    // @ts-expect-error Intl.supportedValuesOf isn't in the types yet
 | 
			
		||||
    const supportedTimezones = Intl.supportedValuesOf('timeZone') as string[];
 | 
			
		||||
    const profileTimezone = extendedProfile?.['us.cloke.msc4175.tz'];
 | 
			
		||||
    if (profileTimezone && supportedTimezones.includes(profileTimezone)) {
 | 
			
		||||
      return profileTimezone;
 | 
			
		||||
    } 
 | 
			
		||||
      return undefined;
 | 
			
		||||
    
 | 
			
		||||
  }, [extendedProfile]);
 | 
			
		||||
 | 
			
		||||
  const presence = useUserPresence(userId);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    refreshExtendedProfile();
 | 
			
		||||
  }, [refreshExtendedProfile]);
 | 
			
		||||
 | 
			
		||||
  const handleMessage = () => {
 | 
			
		||||
    closeUserRoomProfile();
 | 
			
		||||
    const directSearchParam: DirectCreateSearchParams = {
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +93,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 | 
			
		|||
      <Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
 | 
			
		||||
        <Box direction="Column" gap="400">
 | 
			
		||||
          <Box gap="400" alignItems="Start">
 | 
			
		||||
            <UserHeroName displayName={displayName} userId={userId} />
 | 
			
		||||
            <UserHeroName displayName={displayName} userId={userId} extendedProfile={extendedProfile ?? undefined} />
 | 
			
		||||
            {userId !== myUserId && (
 | 
			
		||||
              <Box shrink="No">
 | 
			
		||||
                <Button
 | 
			
		||||
| 
						 | 
				
			
			@ -96,9 +112,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 | 
			
		|||
          <Box alignItems="Center" gap="200" wrap="Wrap">
 | 
			
		||||
            {server && <ServerChip server={server} />}
 | 
			
		||||
            <ShareChip userId={userId} />
 | 
			
		||||
            {timezone && <TimezoneChip timezone={timezone} />}
 | 
			
		||||
            {creator ? <CreatorChip /> : <PowerChip userId={userId} />}
 | 
			
		||||
            {userId !== myUserId && <MutualRoomsChip userId={userId} />}
 | 
			
		||||
            {userId !== myUserId && <OptionsChip userId={userId} />}
 | 
			
		||||
            {userId !== myUserId && <OptionsChip userId={userId} extendedProfile={extendedProfile ?? null} />}
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {ignored && <IgnoredUserAlert />}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,7 @@ import {
 | 
			
		|||
  AccountDataSubmitCallback,
 | 
			
		||||
} from '../../../components/AccountDataEditor';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
 | 
			
		||||
 | 
			
		||||
type DeveloperToolsProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -175,216 +176,166 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
 | 
			
		|||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </SequenceCard>
 | 
			
		||||
                  <SequenceCard
 | 
			
		||||
                    className={SequenceCardStyle}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="400"
 | 
			
		||||
                  <CollapsibleCard
 | 
			
		||||
                    expand={expandState}
 | 
			
		||||
                    setExpand={setExpandState}
 | 
			
		||||
                    title="Room State"
 | 
			
		||||
                    description="State events of the room."
 | 
			
		||||
                  >
 | 
			
		||||
                    <SettingTile
 | 
			
		||||
                      title="Room State"
 | 
			
		||||
                      description="State events of the room."
 | 
			
		||||
                      after={
 | 
			
		||||
                        <Button
 | 
			
		||||
                          onClick={() => setExpandState(!expandState)}
 | 
			
		||||
                          variant="Secondary"
 | 
			
		||||
                          fill="Soft"
 | 
			
		||||
                    <Box direction="Column" gap="100">
 | 
			
		||||
                      <Box justifyContent="SpaceBetween">
 | 
			
		||||
                        <Text size="L400">Events</Text>
 | 
			
		||||
                        <Text size="L400">Total: {roomState.size}</Text>
 | 
			
		||||
                      </Box>
 | 
			
		||||
                      <CutoutCard>
 | 
			
		||||
                        <MenuItem
 | 
			
		||||
                          onClick={() => setComposeEvent({ stateKey: '' })}
 | 
			
		||||
                          variant="Surface"
 | 
			
		||||
                          fill="None"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          outlined
 | 
			
		||||
                          before={
 | 
			
		||||
                            <Icon
 | 
			
		||||
                              src={expandState ? Icons.ChevronTop : Icons.ChevronBottom}
 | 
			
		||||
                              size="100"
 | 
			
		||||
                              filled
 | 
			
		||||
                            />
 | 
			
		||||
                          }
 | 
			
		||||
                          radii="0"
 | 
			
		||||
                          before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text size="B300">{expandState ? 'Collapse' : 'Expand'}</Text>
 | 
			
		||||
                        </Button>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                    {expandState && (
 | 
			
		||||
                      <Box direction="Column" gap="100">
 | 
			
		||||
                        <Box justifyContent="SpaceBetween">
 | 
			
		||||
                          <Text size="L400">Events</Text>
 | 
			
		||||
                          <Text size="L400">Total: {roomState.size}</Text>
 | 
			
		||||
                        </Box>
 | 
			
		||||
                        <CutoutCard>
 | 
			
		||||
                          <MenuItem
 | 
			
		||||
                            onClick={() => setComposeEvent({ stateKey: '' })}
 | 
			
		||||
                            variant="Surface"
 | 
			
		||||
                            fill="None"
 | 
			
		||||
                            size="300"
 | 
			
		||||
                            radii="0"
 | 
			
		||||
                            before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                          >
 | 
			
		||||
                            <Box grow="Yes">
 | 
			
		||||
                              <Text size="T200" truncate>
 | 
			
		||||
                                Add New
 | 
			
		||||
                              </Text>
 | 
			
		||||
                            </Box>
 | 
			
		||||
                          </MenuItem>
 | 
			
		||||
                          {Array.from(roomState.keys())
 | 
			
		||||
                            .sort()
 | 
			
		||||
                            .map((eventType) => {
 | 
			
		||||
                              const expanded = eventType === expandStateType;
 | 
			
		||||
                              const stateKeyToEvents = roomState.get(eventType);
 | 
			
		||||
                              if (!stateKeyToEvents) return null;
 | 
			
		||||
                          <Box grow="Yes">
 | 
			
		||||
                            <Text size="T200" truncate>
 | 
			
		||||
                              Add New
 | 
			
		||||
                            </Text>
 | 
			
		||||
                          </Box>
 | 
			
		||||
                        </MenuItem>
 | 
			
		||||
                        {Array.from(roomState.keys())
 | 
			
		||||
                          .sort()
 | 
			
		||||
                          .map((eventType) => {
 | 
			
		||||
                            const expanded = eventType === expandStateType;
 | 
			
		||||
                            const stateKeyToEvents = roomState.get(eventType);
 | 
			
		||||
                            if (!stateKeyToEvents) return null;
 | 
			
		||||
 | 
			
		||||
                              return (
 | 
			
		||||
                                <Box id={eventType} key={eventType} direction="Column" gap="100">
 | 
			
		||||
                                  <MenuItem
 | 
			
		||||
                                    onClick={() =>
 | 
			
		||||
                                      setExpandStateType(expanded ? undefined : eventType)
 | 
			
		||||
                                    }
 | 
			
		||||
                                    variant="Surface"
 | 
			
		||||
                                    fill="None"
 | 
			
		||||
                                    size="300"
 | 
			
		||||
                                    radii="0"
 | 
			
		||||
                                    before={
 | 
			
		||||
                                      <Icon
 | 
			
		||||
                                        size="50"
 | 
			
		||||
                                        src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
 | 
			
		||||
                                      />
 | 
			
		||||
                                    }
 | 
			
		||||
                                    after={<Text size="L400">{stateKeyToEvents.size}</Text>}
 | 
			
		||||
                            return (
 | 
			
		||||
                              <Box id={eventType} key={eventType} direction="Column" gap="100">
 | 
			
		||||
                                <MenuItem
 | 
			
		||||
                                  onClick={() =>
 | 
			
		||||
                                    setExpandStateType(expanded ? undefined : eventType)
 | 
			
		||||
                                  }
 | 
			
		||||
                                  variant="Surface"
 | 
			
		||||
                                  fill="None"
 | 
			
		||||
                                  size="300"
 | 
			
		||||
                                  radii="0"
 | 
			
		||||
                                  before={
 | 
			
		||||
                                    <Icon
 | 
			
		||||
                                      size="50"
 | 
			
		||||
                                      src={expanded ? Icons.ChevronBottom : Icons.ChevronRight}
 | 
			
		||||
                                    />
 | 
			
		||||
                                  }
 | 
			
		||||
                                  after={<Text size="L400">{stateKeyToEvents.size}</Text>}
 | 
			
		||||
                                >
 | 
			
		||||
                                  <Box grow="Yes">
 | 
			
		||||
                                    <Text size="T200" truncate>
 | 
			
		||||
                                      {eventType}
 | 
			
		||||
                                    </Text>
 | 
			
		||||
                                  </Box>
 | 
			
		||||
                                </MenuItem>
 | 
			
		||||
                                {expanded && (
 | 
			
		||||
                                  <div
 | 
			
		||||
                                    style={{
 | 
			
		||||
                                      marginLeft: config.space.S400,
 | 
			
		||||
                                      borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
 | 
			
		||||
                                    }}
 | 
			
		||||
                                  >
 | 
			
		||||
                                    <Box grow="Yes">
 | 
			
		||||
                                      <Text size="T200" truncate>
 | 
			
		||||
                                        {eventType}
 | 
			
		||||
                                      </Text>
 | 
			
		||||
                                    </Box>
 | 
			
		||||
                                  </MenuItem>
 | 
			
		||||
                                  {expanded && (
 | 
			
		||||
                                    <div
 | 
			
		||||
                                      style={{
 | 
			
		||||
                                        marginLeft: config.space.S400,
 | 
			
		||||
                                        borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
 | 
			
		||||
                                      }}
 | 
			
		||||
                                    <MenuItem
 | 
			
		||||
                                      onClick={() =>
 | 
			
		||||
                                        setComposeEvent({ type: eventType, stateKey: '' })
 | 
			
		||||
                                      }
 | 
			
		||||
                                      variant="Surface"
 | 
			
		||||
                                      fill="None"
 | 
			
		||||
                                      size="300"
 | 
			
		||||
                                      radii="0"
 | 
			
		||||
                                      before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                                    >
 | 
			
		||||
                                      <MenuItem
 | 
			
		||||
                                        onClick={() =>
 | 
			
		||||
                                          setComposeEvent({ type: eventType, stateKey: '' })
 | 
			
		||||
                                        }
 | 
			
		||||
                                        variant="Surface"
 | 
			
		||||
                                        fill="None"
 | 
			
		||||
                                        size="300"
 | 
			
		||||
                                        radii="0"
 | 
			
		||||
                                        before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                                      >
 | 
			
		||||
                                        <Box grow="Yes">
 | 
			
		||||
                                          <Text size="T200" truncate>
 | 
			
		||||
                                            Add New
 | 
			
		||||
                                          </Text>
 | 
			
		||||
                                        </Box>
 | 
			
		||||
                                      </MenuItem>
 | 
			
		||||
                                      {Array.from(stateKeyToEvents.keys())
 | 
			
		||||
                                        .sort()
 | 
			
		||||
                                        .map((stateKey) => (
 | 
			
		||||
                                          <MenuItem
 | 
			
		||||
                                            onClick={() => {
 | 
			
		||||
                                              setOpenStateEvent({
 | 
			
		||||
                                                type: eventType,
 | 
			
		||||
                                                stateKey,
 | 
			
		||||
                                              });
 | 
			
		||||
                                            }}
 | 
			
		||||
                                            key={stateKey}
 | 
			
		||||
                                            variant="Surface"
 | 
			
		||||
                                            fill="None"
 | 
			
		||||
                                            size="300"
 | 
			
		||||
                                            radii="0"
 | 
			
		||||
                                            after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
                                          >
 | 
			
		||||
                                            <Box grow="Yes">
 | 
			
		||||
                                              <Text size="T200" truncate>
 | 
			
		||||
                                                {stateKey ? `"${stateKey}"` : 'Default'}
 | 
			
		||||
                                              </Text>
 | 
			
		||||
                                            </Box>
 | 
			
		||||
                                          </MenuItem>
 | 
			
		||||
                                        ))}
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                  )}
 | 
			
		||||
                                </Box>
 | 
			
		||||
                              );
 | 
			
		||||
                            })}
 | 
			
		||||
                        </CutoutCard>
 | 
			
		||||
                      </Box>
 | 
			
		||||
                    )}
 | 
			
		||||
                  </SequenceCard>
 | 
			
		||||
                  <SequenceCard
 | 
			
		||||
                    className={SequenceCardStyle}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="400"
 | 
			
		||||
                                      <Box grow="Yes">
 | 
			
		||||
                                        <Text size="T200" truncate>
 | 
			
		||||
                                          Add New
 | 
			
		||||
                                        </Text>
 | 
			
		||||
                                      </Box>
 | 
			
		||||
                                    </MenuItem>
 | 
			
		||||
                                    {Array.from(stateKeyToEvents.keys())
 | 
			
		||||
                                      .sort()
 | 
			
		||||
                                      .map((stateKey) => (
 | 
			
		||||
                                        <MenuItem
 | 
			
		||||
                                          onClick={() => {
 | 
			
		||||
                                            setOpenStateEvent({
 | 
			
		||||
                                              type: eventType,
 | 
			
		||||
                                              stateKey,
 | 
			
		||||
                                            });
 | 
			
		||||
                                          }}
 | 
			
		||||
                                          key={stateKey}
 | 
			
		||||
                                          variant="Surface"
 | 
			
		||||
                                          fill="None"
 | 
			
		||||
                                          size="300"
 | 
			
		||||
                                          radii="0"
 | 
			
		||||
                                          after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
                                        >
 | 
			
		||||
                                          <Box grow="Yes">
 | 
			
		||||
                                            <Text size="T200" truncate>
 | 
			
		||||
                                              {stateKey ? `"${stateKey}"` : 'Default'}
 | 
			
		||||
                                            </Text>
 | 
			
		||||
                                          </Box>
 | 
			
		||||
                                        </MenuItem>
 | 
			
		||||
                                      ))}
 | 
			
		||||
                                  </div>
 | 
			
		||||
                                )}
 | 
			
		||||
                              </Box>
 | 
			
		||||
                            );
 | 
			
		||||
                          })}
 | 
			
		||||
                      </CutoutCard>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  </CollapsibleCard>
 | 
			
		||||
                  <CollapsibleCard
 | 
			
		||||
                    expand={expandAccountData}
 | 
			
		||||
                    setExpand={setExpandAccountData}
 | 
			
		||||
                    title="Account Data"
 | 
			
		||||
                    description="Private personalization data stored within room"
 | 
			
		||||
                  >
 | 
			
		||||
                    <SettingTile
 | 
			
		||||
                      title="Account Data"
 | 
			
		||||
                      description="Private personalization data stored within room."
 | 
			
		||||
                      after={
 | 
			
		||||
                        <Button
 | 
			
		||||
                          onClick={() => setExpandAccountData(!expandAccountData)}
 | 
			
		||||
                          variant="Secondary"
 | 
			
		||||
                          fill="Soft"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          outlined
 | 
			
		||||
                          before={
 | 
			
		||||
                            <Icon
 | 
			
		||||
                              src={expandAccountData ? Icons.ChevronTop : Icons.ChevronBottom}
 | 
			
		||||
                              size="100"
 | 
			
		||||
                              filled
 | 
			
		||||
                            />
 | 
			
		||||
                          }
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text size="B300">{expandAccountData ? 'Collapse' : 'Expand'}</Text>
 | 
			
		||||
                        </Button>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                    {expandAccountData && (
 | 
			
		||||
                      <Box direction="Column" gap="100">
 | 
			
		||||
                        <Box justifyContent="SpaceBetween">
 | 
			
		||||
                          <Text size="L400">Events</Text>
 | 
			
		||||
                          <Text size="L400">Total: {accountData.size}</Text>
 | 
			
		||||
                        </Box>
 | 
			
		||||
                        <CutoutCard>
 | 
			
		||||
                          <MenuItem
 | 
			
		||||
                            variant="Surface"
 | 
			
		||||
                            fill="None"
 | 
			
		||||
                            size="300"
 | 
			
		||||
                            radii="0"
 | 
			
		||||
                            before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                            onClick={() => setAccountDataType(null)}
 | 
			
		||||
                          >
 | 
			
		||||
                            <Box grow="Yes">
 | 
			
		||||
                              <Text size="T200" truncate>
 | 
			
		||||
                                Add New
 | 
			
		||||
                              </Text>
 | 
			
		||||
                            </Box>
 | 
			
		||||
                          </MenuItem>
 | 
			
		||||
                          {Array.from(accountData.keys())
 | 
			
		||||
                            .sort()
 | 
			
		||||
                            .map((type) => (
 | 
			
		||||
                              <MenuItem
 | 
			
		||||
                                key={type}
 | 
			
		||||
                                variant="Surface"
 | 
			
		||||
                                fill="None"
 | 
			
		||||
                                size="300"
 | 
			
		||||
                                radii="0"
 | 
			
		||||
                                after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
                                onClick={() => setAccountDataType(type)}
 | 
			
		||||
                              >
 | 
			
		||||
                                <Box grow="Yes">
 | 
			
		||||
                                  <Text size="T200" truncate>
 | 
			
		||||
                                    {type}
 | 
			
		||||
                                  </Text>
 | 
			
		||||
                                </Box>
 | 
			
		||||
                              </MenuItem>
 | 
			
		||||
                            ))}
 | 
			
		||||
                        </CutoutCard>
 | 
			
		||||
                    <Box direction="Column" gap="100">
 | 
			
		||||
                      <Box justifyContent="SpaceBetween">
 | 
			
		||||
                        <Text size="L400">Events</Text>
 | 
			
		||||
                        <Text size="L400">Total: {accountData.size}</Text>
 | 
			
		||||
                      </Box>
 | 
			
		||||
                    )}
 | 
			
		||||
                  </SequenceCard>
 | 
			
		||||
                      <CutoutCard>
 | 
			
		||||
                        <MenuItem
 | 
			
		||||
                          variant="Surface"
 | 
			
		||||
                          fill="None"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          radii="0"
 | 
			
		||||
                          before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                          onClick={() => setAccountDataType(null)}
 | 
			
		||||
                        >
 | 
			
		||||
                          <Box grow="Yes">
 | 
			
		||||
                            <Text size="T200" truncate>
 | 
			
		||||
                              Add New
 | 
			
		||||
                            </Text>
 | 
			
		||||
                          </Box>
 | 
			
		||||
                        </MenuItem>
 | 
			
		||||
                        {Array.from(accountData.keys())
 | 
			
		||||
                          .sort()
 | 
			
		||||
                          .map((type) => (
 | 
			
		||||
                            <MenuItem
 | 
			
		||||
                              key={type}
 | 
			
		||||
                              variant="Surface"
 | 
			
		||||
                              fill="None"
 | 
			
		||||
                              size="300"
 | 
			
		||||
                              radii="0"
 | 
			
		||||
                              after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
                              onClick={() => setAccountDataType(type)}
 | 
			
		||||
                            >
 | 
			
		||||
                              <Box grow="Yes">
 | 
			
		||||
                                <Text size="T200" truncate>
 | 
			
		||||
                                  {type}
 | 
			
		||||
                                </Text>
 | 
			
		||||
                              </Box>
 | 
			
		||||
                            </MenuItem>
 | 
			
		||||
                          ))}
 | 
			
		||||
                      </CutoutCard>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  </CollapsibleCard>
 | 
			
		||||
                </Box>
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,8 +7,6 @@ import {
 | 
			
		|||
  Chip,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Text,
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +31,7 @@ import { useAlive } from '../../../hooks/useAlive';
 | 
			
		|||
import { StateEvent } from '../../../../types/matrix/room';
 | 
			
		||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
 | 
			
		||||
import { getMxIdServer } from '../../../utils/matrix';
 | 
			
		||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
 | 
			
		||||
 | 
			
		||||
type RoomPublishedAddressesProps = {
 | 
			
		||||
  permissions: RoomPermissionsAPI;
 | 
			
		||||
| 
						 | 
				
			
			@ -373,64 +372,40 @@ export function RoomLocalAddresses({ permissions }: { permissions: RoomPermissio
 | 
			
		|||
  const { localAliasesState, addLocalAlias, removeLocalAlias } = useLocalAliases(room.roomId);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SequenceCard
 | 
			
		||||
      className={SequenceCardStyle}
 | 
			
		||||
      variant="SurfaceVariant"
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      gap="400"
 | 
			
		||||
    <CollapsibleCard
 | 
			
		||||
      expand={expand}
 | 
			
		||||
      setExpand={setExpand}
 | 
			
		||||
      title="Local Addresses"
 | 
			
		||||
      description="Set local address so users can join through your homeserver."
 | 
			
		||||
    >
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Local Addresses"
 | 
			
		||||
        description="Set local address so users can join through your homeserver."
 | 
			
		||||
        after={
 | 
			
		||||
          <Button
 | 
			
		||||
            type="button"
 | 
			
		||||
            onClick={() => setExpand(!expand)}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            before={
 | 
			
		||||
              <Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <Text as="span" size="B300" truncate>
 | 
			
		||||
              {expand ? 'Collapse' : 'Expand'}
 | 
			
		||||
      <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
 | 
			
		||||
        {localAliasesState.status === AsyncStatus.Loading && (
 | 
			
		||||
          <Box gap="100">
 | 
			
		||||
            <Spinner variant="Secondary" size="100" />
 | 
			
		||||
            <Text size="T200">Loading...</Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
        {localAliasesState.status === AsyncStatus.Success &&
 | 
			
		||||
          (localAliasesState.data.length === 0 ? (
 | 
			
		||||
            <Box direction="Column" gap="100">
 | 
			
		||||
              <Text size="L400">No Addresses</Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <LocalAddressesList
 | 
			
		||||
              localAliases={localAliasesState.data}
 | 
			
		||||
              removeLocalAlias={removeLocalAlias}
 | 
			
		||||
              canEditCanonical={canEditCanonical}
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
        {localAliasesState.status === AsyncStatus.Error && (
 | 
			
		||||
          <Box gap="100">
 | 
			
		||||
            <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
              {localAliasesState.error.message}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {expand && (
 | 
			
		||||
        <CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
 | 
			
		||||
          {localAliasesState.status === AsyncStatus.Loading && (
 | 
			
		||||
            <Box gap="100">
 | 
			
		||||
              <Spinner variant="Secondary" size="100" />
 | 
			
		||||
              <Text size="T200">Loading...</Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
          )}
 | 
			
		||||
          {localAliasesState.status === AsyncStatus.Success &&
 | 
			
		||||
            (localAliasesState.data.length === 0 ? (
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">No Addresses</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <LocalAddressesList
 | 
			
		||||
                localAliases={localAliasesState.data}
 | 
			
		||||
                removeLocalAlias={removeLocalAlias}
 | 
			
		||||
                canEditCanonical={canEditCanonical}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          {localAliasesState.status === AsyncStatus.Error && (
 | 
			
		||||
            <Box gap="100">
 | 
			
		||||
              <Text size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
                {localAliasesState.error.message}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
          )}
 | 
			
		||||
        </CutoutCard>
 | 
			
		||||
      )}
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </CutoutCard>
 | 
			
		||||
      {expand && <LocalAddressInput addLocalAlias={addLocalAlias} />}
 | 
			
		||||
    </SequenceCard>
 | 
			
		||||
    </CollapsibleCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,10 +21,9 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
 | 
			
		|||
import { Account } from './account';
 | 
			
		||||
import { useUserProfile } from '../../hooks/useUserProfile';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import { mxcUrlToHttp } from '../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		||||
import { UserAvatar } from '../../components/user-avatar';
 | 
			
		||||
import { nameInitials } from '../../utils/common';
 | 
			
		||||
import { Notifications } from './notifications';
 | 
			
		||||
import { Devices } from './devices';
 | 
			
		||||
import { EmojisStickers } from './emojis-stickers';
 | 
			
		||||
| 
						 | 
				
			
			@ -99,9 +98,8 @@ type SettingsProps = {
 | 
			
		|||
export function Settings({ initialPage, requestClose }: SettingsProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const userId = mx.getUserId() as string;
 | 
			
		||||
  const profile = useUserProfile(userId);
 | 
			
		||||
  const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -132,7 +130,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
 | 
			
		|||
                  <UserAvatar
 | 
			
		||||
                    userId={userId}
 | 
			
		||||
                    src={avatarUrl}
 | 
			
		||||
                    renderFallback={() => <Text size="H6">{nameInitials(displayName)}</Text>}
 | 
			
		||||
                    renderFallback={() => <Icon size="100" src={Icons.User} filled />}
 | 
			
		||||
                  />
 | 
			
		||||
                </Avatar>
 | 
			
		||||
                <Text size="H4" truncate>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,324 +1,283 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Button,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
 | 
			
		||||
import { Box, Text, Button, config, Spinner, Line } from 'folds';
 | 
			
		||||
import { UserEvent, ValidatedAuthMetadata } from 'matrix-js-sdk';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
import { getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { nameInitials } from '../../../utils/common';
 | 
			
		||||
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
 | 
			
		||||
import {
 | 
			
		||||
  ExtendedProfile,
 | 
			
		||||
  profileEditsAllowed,
 | 
			
		||||
  useExtendedProfile,
 | 
			
		||||
} from '../../../hooks/useExtendedProfile';
 | 
			
		||||
import { ProfileFieldContext, ProfileFieldElementProps } from './fields/ProfileFieldContext';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
import { useObjectURL } from '../../../hooks/useObjectURL';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { ImageEditor } from '../../../components/image-editor';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
 | 
			
		||||
import { CutoutCard } from '../../../components/cutout-card';
 | 
			
		||||
import { ServerChip, ShareChip, TimezoneChip } from '../../../components/user-profile/UserChips';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
 | 
			
		||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
 | 
			
		||||
import { withSearchParam } from '../../../pages/pathUtils';
 | 
			
		||||
import { useCapabilities } from '../../../hooks/useCapabilities';
 | 
			
		||||
import { ProfileAvatar } from './fields/ProfileAvatar';
 | 
			
		||||
import { ProfileTextField } from './fields/ProfileTextField';
 | 
			
		||||
import { ProfilePronouns } from './fields/ProfilePronouns';
 | 
			
		||||
import { ProfileTimezone } from './fields/ProfileTimezone';
 | 
			
		||||
 | 
			
		||||
type ProfileProps = {
 | 
			
		||||
  profile: UserProfile;
 | 
			
		||||
  userId: string;
 | 
			
		||||
};
 | 
			
		||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const [alertRemove, setAlertRemove] = useState(false);
 | 
			
		||||
  const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
 | 
			
		||||
function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAuthMetadata }) {
 | 
			
		||||
  const accountManagementActions = useAccountManagementActions();
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
  const openProviderProfileSettings = useCallback(() => {
 | 
			
		||||
    const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
 | 
			
		||||
    if (!authUrl) return;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  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 handleUploaded = useCallback(
 | 
			
		||||
    (upload: UploadSuccess) => {
 | 
			
		||||
      const { mxc } = upload;
 | 
			
		||||
      mx.setAvatarUrl(mxc);
 | 
			
		||||
      handleRemoveUpload();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, handleRemoveUpload]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemoveAvatar = () => {
 | 
			
		||||
    mx.setAvatarUrl('');
 | 
			
		||||
    setAlertRemove(false);
 | 
			
		||||
  };
 | 
			
		||||
    window.open(
 | 
			
		||||
      withSearchParam(authUrl, {
 | 
			
		||||
        action: accountManagementActions.profile,
 | 
			
		||||
      }),
 | 
			
		||||
      '_blank'
 | 
			
		||||
    );
 | 
			
		||||
  }, [authMetadata, accountManagementActions]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Avatar
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        <Avatar size="500" radii="300">
 | 
			
		||||
          <UserAvatar
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            src={avatarUrl}
 | 
			
		||||
            renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {uploadAtom ? (
 | 
			
		||||
        <Box gap="200" direction="Column">
 | 
			
		||||
          <CompactUploadCardRenderer
 | 
			
		||||
            uploadAtom={uploadAtom}
 | 
			
		||||
            onRemove={handleRemoveUpload}
 | 
			
		||||
            onComplete={handleUploaded}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Box gap="200">
 | 
			
		||||
    <CutoutCard style={{ padding: config.space.S200 }} variant="Surface">
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        after={
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => pickFile('image/*')}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disableSetAvatar}
 | 
			
		||||
            outlined
 | 
			
		||||
            onClick={openProviderProfileSettings}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Upload</Text>
 | 
			
		||||
            <Text size="B300">Open</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          {avatarUrl && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disableSetAvatar}
 | 
			
		||||
              onClick={() => setAlertRemove(true)}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Remove</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {imageFileURL && (
 | 
			
		||||
        <Overlay open={false} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleRemoveUpload,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal className={ModalWide} variant="Surface" size="500">
 | 
			
		||||
                <ImageEditor
 | 
			
		||||
                  name={imageFile?.name ?? 'Unnamed'}
 | 
			
		||||
                  url={imageFileURL}
 | 
			
		||||
                  requestClose={handleRemoveUpload}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setAlertRemove(false),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Header
 | 
			
		||||
                style={{
 | 
			
		||||
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                }}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                size="500"
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="H4">Remove Avatar</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Header>
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Box direction="Column" gap="200">
 | 
			
		||||
                  <Text priority="400">Are you sure you want to remove profile avatar?</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Button variant="Critical" onClick={handleRemoveAvatar}>
 | 
			
		||||
                  <Text size="B400">Remove</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T200">Change profile settings in your homeserver's account dashboard.</Text>
 | 
			
		||||
      </SettingTile>
 | 
			
		||||
    </CutoutCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
 | 
			
		||||
/// Context props which are passed to every field element.
 | 
			
		||||
/// Right now this is only a flag for if the profile is being saved.
 | 
			
		||||
export type FieldContext = { busy: boolean };
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
 | 
			
		||||
/// Field editor elements for the pre-MSC4133 profile fields. This should only
 | 
			
		||||
/// ever contain keys for `displayname` and `avatar_url`.
 | 
			
		||||
const LEGACY_FIELD_ELEMENTS = {
 | 
			
		||||
  avatar_url: ProfileAvatar,
 | 
			
		||||
  displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => (
 | 
			
		||||
    <ProfileTextField label="Display Name" {...props} />
 | 
			
		||||
  ),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
  const [changeState, changeDisplayName] = useAsyncCallback(
 | 
			
		||||
    useCallback((name: string) => mx.setDisplayName(name), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const changingDisplayName = changeState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  }, [defaultDisplayName]);
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const name = evt.currentTarget.value;
 | 
			
		||||
    setDisplayName(name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (changingDisplayName) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
 | 
			
		||||
    const name = displayNameInput?.value;
 | 
			
		||||
    if (!name) return;
 | 
			
		||||
 | 
			
		||||
    changeDisplayName(name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const hasChanges = displayName !== defaultDisplayName;
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Display Name
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Box direction="Column" grow="Yes" gap="100">
 | 
			
		||||
        <Box
 | 
			
		||||
          as="form"
 | 
			
		||||
          onSubmit={handleSubmit}
 | 
			
		||||
          gap="200"
 | 
			
		||||
          aria-disabled={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
        >
 | 
			
		||||
          <Box grow="Yes" direction="Column">
 | 
			
		||||
            <Input
 | 
			
		||||
              required
 | 
			
		||||
              name="displayNameInput"
 | 
			
		||||
              value={displayName}
 | 
			
		||||
              onChange={handleChange}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
              readOnly={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
              after={
 | 
			
		||||
                hasChanges &&
 | 
			
		||||
                !changingDisplayName && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    type="reset"
 | 
			
		||||
                    onClick={handleReset}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Button
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant={hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
            fill={hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={!hasChanges || changingDisplayName}
 | 
			
		||||
            type="submit"
 | 
			
		||||
          >
 | 
			
		||||
            {changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
 | 
			
		||||
            <Text size="B400">Save</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
/// Field editor elements for MSC4133 extended profile fields.
 | 
			
		||||
/// These will appear in the UI in the order they are defined in this map.
 | 
			
		||||
const EXTENDED_FIELD_ELEMENTS = {
 | 
			
		||||
  'io.fsky.nyx.pronouns': ProfilePronouns,
 | 
			
		||||
  'us.cloke.msc4175.tz': ProfileTimezone,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function Profile() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const profile = useUserProfile(userId);
 | 
			
		||||
  const userId = mx.getUserId() as string;
 | 
			
		||||
  const server = getMxIdServer(userId);
 | 
			
		||||
  const authMetadata = useAuthMetadata();
 | 
			
		||||
  const accountManagementActions = useAccountManagementActions();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
 | 
			
		||||
  const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
 | 
			
		||||
  const extendedProfileSupported = extendedProfile !== null;
 | 
			
		||||
  const legacyProfile = useUserProfile(userId);
 | 
			
		||||
 | 
			
		||||
  // next-gen auth identity providers may provide profile settings if they want
 | 
			
		||||
  const profileEditableThroughIDP =
 | 
			
		||||
    authMetadata !== undefined &&
 | 
			
		||||
    authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
 | 
			
		||||
 | 
			
		||||
  const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => {
 | 
			
		||||
    const entries = Object.entries({
 | 
			
		||||
      ...LEGACY_FIELD_ELEMENTS,
 | 
			
		||||
      // don't show the MSC4133 elements if the HS doesn't support them
 | 
			
		||||
      ...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}),
 | 
			
		||||
    }).filter(([key]) => 
 | 
			
		||||
      // don't show fields if the HS blocks them with capabilities
 | 
			
		||||
      profileEditsAllowed(key, capabilities, extendedProfileSupported)
 | 
			
		||||
    );
 | 
			
		||||
    return [Object.fromEntries(entries), entries.length > 0];
 | 
			
		||||
  }, [capabilities, extendedProfileSupported]);
 | 
			
		||||
 | 
			
		||||
  const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
 | 
			
		||||
    displayname: legacyProfile.displayName,
 | 
			
		||||
    avatar_url: legacyProfile.avatarUrl,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // this updates the field defaults when the extended profile data is (re)loaded.
 | 
			
		||||
  // it has to be a layout effect to prevent flickering on saves.
 | 
			
		||||
  // if MSC4133 isn't supported by the HS this does nothing
 | 
			
		||||
  useLayoutEffect(() => {
 | 
			
		||||
    // `extendedProfile` includes the old dn/av fields, so
 | 
			
		||||
    // we don't have to add those here
 | 
			
		||||
    if (extendedProfile) {
 | 
			
		||||
      setFieldDefaults(extendedProfile);
 | 
			
		||||
    }
 | 
			
		||||
  }, [setFieldDefaults, extendedProfile]);
 | 
			
		||||
 | 
			
		||||
  const [saveState, handleSave] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (fields: ExtendedProfile) => {
 | 
			
		||||
        if (extendedProfileSupported) {
 | 
			
		||||
          await Promise.all(
 | 
			
		||||
            Object.entries(fields).map(async ([key, value]) => {
 | 
			
		||||
              if (value === undefined) {
 | 
			
		||||
                await mx.deleteExtendedProfileProperty(key);
 | 
			
		||||
              } else {
 | 
			
		||||
                await mx.setExtendedProfileProperty(key, value);
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          );
 | 
			
		||||
          
 | 
			
		||||
          // calling this will trigger the layout effect to update the defaults
 | 
			
		||||
          // once the profile request completes
 | 
			
		||||
          await refreshExtendedProfile();
 | 
			
		||||
 | 
			
		||||
          // synthesize a profile update for ourselves to update our name and avatar in the rest
 | 
			
		||||
          // of the UI. code copied from matrix-js-sdk
 | 
			
		||||
          const user = mx.getUser(userId);
 | 
			
		||||
          if (user) {
 | 
			
		||||
            user.displayName = fields.displayname;
 | 
			
		||||
            user.avatarUrl = fields.avatar_url;
 | 
			
		||||
            user.emit(UserEvent.DisplayName, user.events.presence, user);
 | 
			
		||||
            user.emit(UserEvent.AvatarUrl, user.events.presence, user);
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          await mx.setDisplayName(fields.displayname ?? '');
 | 
			
		||||
          await mx.setAvatarUrl(fields.avatar_url ?? '');
 | 
			
		||||
          // layout effect does nothing because `extendedProfile` is undefined
 | 
			
		||||
          // so we have to update the defaults explicitly here
 | 
			
		||||
          setFieldDefaults(fields);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [mx, userId, refreshExtendedProfile, extendedProfileSupported, setFieldDefaults]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const saving = saveState.status === AsyncStatus.Loading;
 | 
			
		||||
  const loadingExtendedProfile = extendedProfile === undefined;
 | 
			
		||||
  const busy = saving || loadingExtendedProfile;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Profile</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        variant="Surface"
 | 
			
		||||
        outlined
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
        style={{
 | 
			
		||||
          overflow: 'hidden',
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <ProfileAvatar userId={userId} profile={profile} />
 | 
			
		||||
        <ProfileDisplayName userId={userId} profile={profile} />
 | 
			
		||||
        <ProfileFieldContext
 | 
			
		||||
          fieldDefaults={fieldDefaults}
 | 
			
		||||
          fieldElements={fieldElementConstructors}
 | 
			
		||||
          context={{ busy }}
 | 
			
		||||
        >
 | 
			
		||||
          {(reset, hasChanges, fields, fieldElements) => {
 | 
			
		||||
            const heroAvatarUrl =
 | 
			
		||||
              (fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
 | 
			
		||||
              undefined;
 | 
			
		||||
            return (
 | 
			
		||||
              <>
 | 
			
		||||
                <UserHero userId={userId} avatarUrl={heroAvatarUrl} />
 | 
			
		||||
                <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
 | 
			
		||||
                  <Box gap="400" alignItems="Start">
 | 
			
		||||
                    <UserHeroName
 | 
			
		||||
                      userId={userId}
 | 
			
		||||
                      displayName={fields.displayname as string}
 | 
			
		||||
                      extendedProfile={fields}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                  <Box alignItems="Center" gap="200" wrap="Wrap">
 | 
			
		||||
                    {server && <ServerChip server={server} />}
 | 
			
		||||
                    <ShareChip userId={userId} />
 | 
			
		||||
                    {fields['us.cloke.msc4175.tz'] && (
 | 
			
		||||
                      <TimezoneChip timezone={fields['us.cloke.msc4175.tz']} />
 | 
			
		||||
                    )}
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Line />
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                  radii="0"
 | 
			
		||||
                >
 | 
			
		||||
                  {profileEditableThroughIDP && (
 | 
			
		||||
                    <IdentityProviderSettings authMetadata={authMetadata} />
 | 
			
		||||
                  )}
 | 
			
		||||
                  {profileEditableThroughClient && (
 | 
			
		||||
                    <>
 | 
			
		||||
                      <Box gap="300" direction="Column">
 | 
			
		||||
                        {fieldElements}
 | 
			
		||||
                      </Box>
 | 
			
		||||
                      <Box gap="300" alignItems="Center">
 | 
			
		||||
                        <Button
 | 
			
		||||
                          type="submit"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          variant={!busy && hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
                          fill={!busy && hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
                          outlined
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          disabled={!hasChanges || busy}
 | 
			
		||||
                          onClick={() => handleSave(fields)}
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text size="B300">Save</Text>
 | 
			
		||||
                        </Button>
 | 
			
		||||
                        <Button
 | 
			
		||||
                          type="reset"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          variant="Secondary"
 | 
			
		||||
                          fill="Soft"
 | 
			
		||||
                          outlined
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          onClick={reset}
 | 
			
		||||
                          disabled={!hasChanges || busy}
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text size="B300">Cancel</Text>
 | 
			
		||||
                        </Button>
 | 
			
		||||
                        {saving && <Spinner size="300" />}
 | 
			
		||||
                      </Box>
 | 
			
		||||
                    </>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {!(profileEditableThroughClient || profileEditableThroughIDP) && (
 | 
			
		||||
                    <CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
 | 
			
		||||
                      <SettingTile>
 | 
			
		||||
                        <Box direction="Column" gap="200">
 | 
			
		||||
                          <Box gap="200" justifyContent="SpaceBetween">
 | 
			
		||||
                            <Text size="L400">Profile Editing Disabled</Text>
 | 
			
		||||
                          </Box>
 | 
			
		||||
                          <Box direction="Column">
 | 
			
		||||
                            <Text size="T200">
 | 
			
		||||
                              Your homeserver does not allow you to edit your profile.
 | 
			
		||||
                            </Text>
 | 
			
		||||
                          </Box>
 | 
			
		||||
                        </Box>
 | 
			
		||||
                      </SettingTile>
 | 
			
		||||
                    </CutoutCard>
 | 
			
		||||
                  )}
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
              </>
 | 
			
		||||
            );
 | 
			
		||||
          }}
 | 
			
		||||
        </ProfileFieldContext>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										118
									
								
								src/app/features/settings/account/fields/ProfileAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/app/features/settings/account/fields/ProfileAvatar.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { Text, Box, Button, Overlay, OverlayBackdrop, OverlayCenter, Modal } from 'folds';
 | 
			
		||||
import React, { useState, useMemo, useCallback } from 'react';
 | 
			
		||||
import { ImageEditor } from '../../../../components/image-editor';
 | 
			
		||||
import { SettingTile } from '../../../../components/setting-tile';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../../../../components/upload-card';
 | 
			
		||||
import { useFilePicker } from '../../../../hooks/useFilePicker';
 | 
			
		||||
import { useMatrixClient } from '../../../../hooks/useMatrixClient';
 | 
			
		||||
import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { useObjectURL } from '../../../../hooks/useObjectURL';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../../../state/upload';
 | 
			
		||||
import { stopPropagation } from '../../../../utils/keyboard';
 | 
			
		||||
import { mxcUrlToHttp } from '../../../../utils/matrix';
 | 
			
		||||
import { FieldContext } from '../Profile';
 | 
			
		||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
 | 
			
		||||
import { ModalWide } from '../../../../styles/Modal.css';
 | 
			
		||||
 | 
			
		||||
export function ProfileAvatar({
 | 
			
		||||
  busy, value, setValue,
 | 
			
		||||
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const avatarUrl = value
 | 
			
		||||
    ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
  const disabled = busy;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  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 handleUploaded = useCallback(
 | 
			
		||||
    (upload: UploadSuccess) => {
 | 
			
		||||
      const { mxc } = upload;
 | 
			
		||||
      setValue(mxc);
 | 
			
		||||
      handleRemoveUpload();
 | 
			
		||||
    },
 | 
			
		||||
    [setValue, handleRemoveUpload]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemoveAvatar = () => {
 | 
			
		||||
    setValue('');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={<Text as="span" size="L400">
 | 
			
		||||
        Avatar
 | 
			
		||||
      </Text>}
 | 
			
		||||
    >
 | 
			
		||||
      {uploadAtom ? (
 | 
			
		||||
        <Box gap="200" direction="Column">
 | 
			
		||||
          <CompactUploadCardRenderer
 | 
			
		||||
            uploadAtom={uploadAtom}
 | 
			
		||||
            onRemove={handleRemoveUpload}
 | 
			
		||||
            onComplete={handleUploaded} />
 | 
			
		||||
        </Box>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Box gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => pickFile('image/*')}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Upload Avatar</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          {avatarUrl && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
              onClick={handleRemoveAvatar}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Remove Avatar</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {imageFileURL && (
 | 
			
		||||
        <Overlay open={false} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleRemoveUpload,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal className={ModalWide} variant="Surface" size="500">
 | 
			
		||||
                <ImageEditor
 | 
			
		||||
                  name={imageFile?.name ?? 'Unnamed'}
 | 
			
		||||
                  url={imageFileURL}
 | 
			
		||||
                  requestClose={handleRemoveUpload} />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										127
									
								
								src/app/features/settings/account/fields/ProfileFieldContext.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/app/features/settings/account/fields/ProfileFieldContext.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,127 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  FunctionComponent,
 | 
			
		||||
  ReactNode,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import { deepCompare } from 'matrix-js-sdk/lib/utils';
 | 
			
		||||
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
 | 
			
		||||
 | 
			
		||||
/// These types ensure the element functions are actually able to manipulate
 | 
			
		||||
/// the profile fields they're mapped to. The <C> generic parameter represents
 | 
			
		||||
/// extra "context" props which are passed to every element.
 | 
			
		||||
 | 
			
		||||
// strip the index signature from ExtendedProfile using mapped type magic.
 | 
			
		||||
// keeping the index signature causes weird typechecking issues further down the line
 | 
			
		||||
// plus there should never be field elements passed with keys which don't exist in ExtendedProfile.
 | 
			
		||||
type ExtendedProfileKeys = keyof {
 | 
			
		||||
  [Property in keyof ExtendedProfile as string extends Property
 | 
			
		||||
    ? never
 | 
			
		||||
    : Property]: ExtendedProfile[Property];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// these are the props which all field elements must accept.
 | 
			
		||||
// this is split into `RawProps` and `Props` so we can type `V` instead of
 | 
			
		||||
// spraying `ExtendedProfile[K]` all over the place.
 | 
			
		||||
// don't use this directly, use the `ProfileFieldElementProps` type instead
 | 
			
		||||
type ProfileFieldElementRawProps<V, C> = {
 | 
			
		||||
  defaultValue: V;
 | 
			
		||||
  value: V;
 | 
			
		||||
  setValue: (value: V) => void;
 | 
			
		||||
} & C;
 | 
			
		||||
 | 
			
		||||
export type ProfileFieldElementProps<
 | 
			
		||||
  K extends ExtendedProfileKeys,
 | 
			
		||||
  C
 | 
			
		||||
> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
 | 
			
		||||
 | 
			
		||||
// the map of extended profile keys to field element functions
 | 
			
		||||
type ProfileFieldElements<C> = {
 | 
			
		||||
  [Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ProfileFieldContextProps<C> = {
 | 
			
		||||
  fieldDefaults: ExtendedProfile;
 | 
			
		||||
  fieldElements: ProfileFieldElements<C>;
 | 
			
		||||
  children: (
 | 
			
		||||
    reset: () => void,
 | 
			
		||||
    hasChanges: boolean,
 | 
			
		||||
    fields: ExtendedProfile,
 | 
			
		||||
    fieldElements: ReactNode
 | 
			
		||||
  ) => ReactNode;
 | 
			
		||||
  context: C;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/// This element manages the pending state of the profile field widgets.
 | 
			
		||||
/// It takes the default values of each field, as well as a map associating a profile field key
 | 
			
		||||
/// with an element _function_ (not a rendered element!) that will be used to edit that field.
 | 
			
		||||
/// It renders the editor elements internally using React.createElement and passes the rendered
 | 
			
		||||
/// elements into the child UI. This allows it to handle the pending state entirely by itself,
 | 
			
		||||
/// and provides strong typechecking.
 | 
			
		||||
export function ProfileFieldContext<C>({
 | 
			
		||||
  fieldDefaults,
 | 
			
		||||
  fieldElements: fieldElementConstructors,
 | 
			
		||||
  children,
 | 
			
		||||
  context,
 | 
			
		||||
}: ProfileFieldContextProps<C>): ReactNode {
 | 
			
		||||
  const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
 | 
			
		||||
  
 | 
			
		||||
  // this callback also runs when fieldDefaults changes,
 | 
			
		||||
  // which happens when the profile is saved and the pending fields become the new defaults
 | 
			
		||||
  const reset = useCallback(() => {
 | 
			
		||||
    setFields(fieldDefaults);
 | 
			
		||||
  }, [fieldDefaults]);
 | 
			
		||||
 | 
			
		||||
  // set the pending values to the defaults on the first render
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    reset();
 | 
			
		||||
  }, [reset]);
 | 
			
		||||
 | 
			
		||||
  const setField = useCallback(
 | 
			
		||||
    (key: string, value: unknown) => {
 | 
			
		||||
      setFields({
 | 
			
		||||
        ...fields,
 | 
			
		||||
        [key]: value,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [fields]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const hasChanges = useMemo(
 | 
			
		||||
    () =>
 | 
			
		||||
      Object.entries(fields).find(
 | 
			
		||||
        ([key, value]) =>
 | 
			
		||||
          // deep comparison is necessary here because field values can be any JSON type
 | 
			
		||||
          !deepCompare(fieldDefaults[key as keyof ExtendedProfile], value)
 | 
			
		||||
      ) !== undefined,
 | 
			
		||||
    [fields, fieldDefaults]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const createElement = useCallback(
 | 
			
		||||
    <K extends ExtendedProfileKeys>(key: K, element: ProfileFieldElements<C>[K]) => {
 | 
			
		||||
      const props: ProfileFieldElementRawProps<ExtendedProfile[K], C> = {
 | 
			
		||||
        ...context,
 | 
			
		||||
        defaultValue: fieldDefaults[key],
 | 
			
		||||
        value: fields[key],
 | 
			
		||||
        setValue: (value) => setField(key, value),
 | 
			
		||||
        key,
 | 
			
		||||
      };
 | 
			
		||||
      // element can be undefined if the field defaults didn't include its key,
 | 
			
		||||
      // which means the HS doesn't support setting that field
 | 
			
		||||
      if (element !== undefined) {
 | 
			
		||||
        return React.createElement(element, props);
 | 
			
		||||
      }
 | 
			
		||||
      return undefined;
 | 
			
		||||
    },
 | 
			
		||||
    [context, fieldDefaults, fields, setField]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) =>
 | 
			
		||||
    // @ts-expect-error TypeScript doesn't quite understand the magic going on here
 | 
			
		||||
    createElement(key, element)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return children(reset, hasChanges, fields, fieldElements);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										125
									
								
								src/app/features/settings/account/fields/ProfilePronouns.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/app/features/settings/account/fields/ProfilePronouns.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { RectCords, Text, Box, Chip, Icon, Icons, PopOut, Menu, config, Input, Button } from 'folds';
 | 
			
		||||
import { isKeyHotkey } from 'is-hotkey';
 | 
			
		||||
import React, { useState, FormEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react';
 | 
			
		||||
import { SettingTile } from '../../../../components/setting-tile';
 | 
			
		||||
import { stopPropagation } from '../../../../utils/keyboard';
 | 
			
		||||
import { FieldContext } from '../Profile';
 | 
			
		||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
 | 
			
		||||
 | 
			
		||||
export function ProfilePronouns({
 | 
			
		||||
  value, setValue, busy,
 | 
			
		||||
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
 | 
			
		||||
  const disabled = busy;
 | 
			
		||||
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const [pendingPronoun, setPendingPronoun] = useState('');
 | 
			
		||||
 | 
			
		||||
  const handleRemovePronoun = (index: number) => {
 | 
			
		||||
    const newPronouns = [...(value ?? [])];
 | 
			
		||||
    newPronouns.splice(index, 1);
 | 
			
		||||
    if (newPronouns.length > 0) {
 | 
			
		||||
      setValue(newPronouns);
 | 
			
		||||
    } else {
 | 
			
		||||
      setValue(undefined);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
    if (pendingPronoun.length > 0) {
 | 
			
		||||
      setValue([...(value ?? []), { language: 'en', summary: pendingPronoun }]);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    if (isKeyHotkey('escape', evt)) {
 | 
			
		||||
      evt.stopPropagation();
 | 
			
		||||
      setMenuCords(undefined);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleOpenMenu: MouseEventHandler<HTMLSpanElement> = (evt) => {
 | 
			
		||||
    setPendingPronoun('');
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={<Text as="span" size="L400">
 | 
			
		||||
        Pronouns
 | 
			
		||||
      </Text>}
 | 
			
		||||
    >
 | 
			
		||||
      <Box alignItems="Center" gap="200" wrap="Wrap">
 | 
			
		||||
        {value?.map(({ summary }, index) => (
 | 
			
		||||
          <Chip
 | 
			
		||||
            // eslint-disable-next-line react/no-array-index-key
 | 
			
		||||
            key={index}
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            after={<Icon src={Icons.Cross} size="100" />}
 | 
			
		||||
            onClick={() => handleRemovePronoun(index)}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="T200" truncate>
 | 
			
		||||
              {summary}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Chip>
 | 
			
		||||
        ))}
 | 
			
		||||
        <Chip
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          radii="Pill"
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          after={<Icon src={menuCords ? Icons.ChevronRight : Icons.Plus} size="100" />}
 | 
			
		||||
          onClick={handleOpenMenu}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="T200">Add</Text>
 | 
			
		||||
        </Chip>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Right"
 | 
			
		||||
        align="Center"
 | 
			
		||||
        content={<FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
            isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            style={{
 | 
			
		||||
              padding: config.space.S200,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Box as="form" onSubmit={handleSubmit} direction="Row" gap="200">
 | 
			
		||||
              <Input
 | 
			
		||||
                variant="Secondary"
 | 
			
		||||
                placeholder="they/them"
 | 
			
		||||
                inputSize={10}
 | 
			
		||||
                radii="300"
 | 
			
		||||
                size="300"
 | 
			
		||||
                outlined
 | 
			
		||||
                value={pendingPronoun}
 | 
			
		||||
                onChange={(evt) => setPendingPronoun(evt.currentTarget.value)}
 | 
			
		||||
                onKeyDown={handleKeyDown} />
 | 
			
		||||
              <Button
 | 
			
		||||
                type="submit"
 | 
			
		||||
                size="300"
 | 
			
		||||
                variant="Success"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                before={<Icon size="100" src={Icons.Plus} />}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B300">Add</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Menu>
 | 
			
		||||
        </FocusTrap>} />
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,63 @@
 | 
			
		|||
import { Text, Box, Input, IconButton, Icon, Icons } from 'folds';
 | 
			
		||||
import React, { ChangeEventHandler } from 'react';
 | 
			
		||||
import { FilterByValues } from '../../../../../types/utils';
 | 
			
		||||
import { SettingTile } from '../../../../components/setting-tile';
 | 
			
		||||
import { ExtendedProfile } from '../../../../hooks/useExtendedProfile';
 | 
			
		||||
import { FieldContext } from '../Profile';
 | 
			
		||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
 | 
			
		||||
 | 
			
		||||
export function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
 | 
			
		||||
  label, defaultValue, value, setValue, busy,
 | 
			
		||||
}: ProfileFieldElementProps<K, FieldContext> & { label: string; }) {
 | 
			
		||||
  const disabled = busy;
 | 
			
		||||
  const hasChanges = defaultValue !== value;
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const content = evt.currentTarget.value;
 | 
			
		||||
    if (content.length > 0) {
 | 
			
		||||
      setValue(evt.currentTarget.value);
 | 
			
		||||
    } else {
 | 
			
		||||
      setValue(undefined);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setValue(defaultValue);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={<Text as="span" size="L400">
 | 
			
		||||
        {label}
 | 
			
		||||
      </Text>}
 | 
			
		||||
    >
 | 
			
		||||
      <Box direction="Column" grow="Yes" gap="100">
 | 
			
		||||
        <Box gap="200" aria-disabled={disabled}>
 | 
			
		||||
          <Box grow="Yes" direction="Column">
 | 
			
		||||
            <Input
 | 
			
		||||
              required
 | 
			
		||||
              name="displayNameInput"
 | 
			
		||||
              value={value ?? ''}
 | 
			
		||||
              onChange={handleChange}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
              readOnly={disabled}
 | 
			
		||||
              after={hasChanges &&
 | 
			
		||||
                !busy && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    type="reset"
 | 
			
		||||
                    onClick={handleReset}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )} />
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										160
									
								
								src/app/features/settings/account/fields/ProfileTimezone.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/app/features/settings/account/fields/ProfileTimezone.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,160 @@
 | 
			
		|||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { Text, Overlay, OverlayBackdrop, OverlayCenter, Dialog, Header, config, Box, IconButton, Icon, Icons, Input, toRem, MenuItem, Button } from 'folds';
 | 
			
		||||
import React, { useRef, useState, useMemo, useCallback, useEffect } from 'react';
 | 
			
		||||
import { CutoutCard } from '../../../../components/cutout-card';
 | 
			
		||||
import { SettingTile } from '../../../../components/setting-tile';
 | 
			
		||||
import { FieldContext } from '../Profile';
 | 
			
		||||
import { ProfileFieldElementProps } from './ProfileFieldContext';
 | 
			
		||||
 | 
			
		||||
export function ProfileTimezone({
 | 
			
		||||
  value, setValue, busy,
 | 
			
		||||
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
 | 
			
		||||
  const disabled = busy;
 | 
			
		||||
 | 
			
		||||
  const inputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
  const scrollRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [overlayOpen, setOverlayOpen] = useState(false);
 | 
			
		||||
  const [query, setQuery] = useState('');
 | 
			
		||||
 | 
			
		||||
  // @ts-expect-error Intl.supportedValuesOf isn't in the types yet
 | 
			
		||||
  const timezones = useMemo(() => Intl.supportedValuesOf('timeZone') as string[], []);
 | 
			
		||||
  const filteredTimezones = timezones.filter(
 | 
			
		||||
    (timezone) => query.length === 0 || timezone.toLowerCase().replace('_', ' ').includes(query.toLowerCase())
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleSelect = useCallback(
 | 
			
		||||
    (timezone: string) => {
 | 
			
		||||
      setOverlayOpen(false);
 | 
			
		||||
      setValue(timezone);
 | 
			
		||||
    },
 | 
			
		||||
    [setOverlayOpen, setValue]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (overlayOpen) {
 | 
			
		||||
      const scrollView = scrollRef.current;
 | 
			
		||||
      const focusedItem = scrollView?.querySelector(`[data-tz="${value}"]`);
 | 
			
		||||
 | 
			
		||||
      if (value && focusedItem && scrollView) {
 | 
			
		||||
        focusedItem.scrollIntoView({
 | 
			
		||||
          block: 'center',
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, [scrollRef, value, overlayOpen]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={<Text as="span" size="L400">
 | 
			
		||||
        Timezone
 | 
			
		||||
      </Text>}
 | 
			
		||||
    >
 | 
			
		||||
      <Overlay open={overlayOpen} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: () => inputRef.current,
 | 
			
		||||
              allowOutsideClick: true,
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              onDeactivate: () => setOverlayOpen(false),
 | 
			
		||||
              escapeDeactivates: (evt) => {
 | 
			
		||||
                evt.stopPropagation();
 | 
			
		||||
                return true;
 | 
			
		||||
              },
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Header
 | 
			
		||||
                style={{
 | 
			
		||||
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                }}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                size="500"
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="H4">Choose a Timezone</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <IconButton size="300" onClick={() => setOverlayOpen(false)} radii="300">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Header>
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Input
 | 
			
		||||
                  ref={inputRef}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant="Background"
 | 
			
		||||
                  radii="400"
 | 
			
		||||
                  outlined
 | 
			
		||||
                  placeholder="Search"
 | 
			
		||||
                  before={<Icon size="200" src={Icons.Search} />}
 | 
			
		||||
                  value={query}
 | 
			
		||||
                  onChange={(evt) => setQuery(evt.currentTarget.value)} />
 | 
			
		||||
                <CutoutCard ref={scrollRef} style={{ overflowY: 'scroll', height: toRem(300) }}>
 | 
			
		||||
                  {filteredTimezones.length === 0 && (
 | 
			
		||||
                    <Box
 | 
			
		||||
                      style={{ paddingTop: config.space.S700 }}
 | 
			
		||||
                      grow="Yes"
 | 
			
		||||
                      alignItems="Center"
 | 
			
		||||
                      justifyContent="Center"
 | 
			
		||||
                      direction="Column"
 | 
			
		||||
                      gap="100"
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="H6" align="Center">
 | 
			
		||||
                        No Results
 | 
			
		||||
                      </Text>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {filteredTimezones.map((timezone) => (
 | 
			
		||||
                    <MenuItem
 | 
			
		||||
                      key={timezone}
 | 
			
		||||
                      data-tz={timezone}
 | 
			
		||||
                      variant={timezone === value ? 'Success' : 'Surface'}
 | 
			
		||||
                      fill={timezone === value ? 'Soft' : 'None'}
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      radii="0"
 | 
			
		||||
                      after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
                      onClick={() => handleSelect(timezone)}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Box grow="Yes">
 | 
			
		||||
                        <Text size="T200" truncate>
 | 
			
		||||
                          {timezone}
 | 
			
		||||
                        </Text>
 | 
			
		||||
                      </Box>
 | 
			
		||||
                    </MenuItem>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </CutoutCard>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
      <Box gap="200">
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          fill="Soft"
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          outlined
 | 
			
		||||
          disabled={disabled}
 | 
			
		||||
          onClick={() => setOverlayOpen(true)}
 | 
			
		||||
          after={<Icon size="100" src={Icons.ChevronRight} />}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B300">{value ?? 'Set Timezone'}</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
        {value && (
 | 
			
		||||
          <Button
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Critical"
 | 
			
		||||
            fill="None"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
            onClick={() => setValue(undefined)}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Remove Timezone</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,100 +0,0 @@
 | 
			
		|||
import React, { useCallback, useState } from 'react';
 | 
			
		||||
import { Box, Text, Icon, Icons, Button, MenuItem } from 'folds';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
 | 
			
		||||
import { CutoutCard } from '../../../components/cutout-card';
 | 
			
		||||
 | 
			
		||||
type AccountDataProps = {
 | 
			
		||||
  expand: boolean;
 | 
			
		||||
  onExpandToggle: (expand: boolean) => void;
 | 
			
		||||
  onSelect: (type: string | null) => void;
 | 
			
		||||
};
 | 
			
		||||
export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [accountDataTypes, setAccountDataKeys] = useState(() =>
 | 
			
		||||
    Array.from(mx.store.accountData.keys())
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useAccountDataCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      setAccountDataKeys(Array.from(mx.store.accountData.keys()));
 | 
			
		||||
    }, [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Account Data</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Global"
 | 
			
		||||
          description="Data stored in your global account data."
 | 
			
		||||
          after={
 | 
			
		||||
            <Button
 | 
			
		||||
              onClick={() => onExpandToggle(!expand)}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              outlined
 | 
			
		||||
              before={
 | 
			
		||||
                <Icon src={expand ? Icons.ChevronTop : Icons.ChevronBottom} size="100" filled />
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">{expand ? 'Collapse' : 'Expand'}</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
        {expand && (
 | 
			
		||||
          <Box direction="Column" gap="100">
 | 
			
		||||
            <Box justifyContent="SpaceBetween">
 | 
			
		||||
              <Text size="L400">Events</Text>
 | 
			
		||||
              <Text size="L400">Total: {accountDataTypes.length}</Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
            <CutoutCard>
 | 
			
		||||
              <MenuItem
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                fill="None"
 | 
			
		||||
                size="300"
 | 
			
		||||
                radii="0"
 | 
			
		||||
                before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
                onClick={() => onSelect(null)}
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="T200" truncate>
 | 
			
		||||
                    Add New
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </MenuItem>
 | 
			
		||||
              {accountDataTypes.sort().map((type) => (
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  key={type}
 | 
			
		||||
                  variant="Surface"
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  radii="0"
 | 
			
		||||
                  after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
                  onClick={() => onSelect(type)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box grow="Yes">
 | 
			
		||||
                    <Text size="T200" truncate>
 | 
			
		||||
                      {type}
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </MenuItem>
 | 
			
		||||
              ))}
 | 
			
		||||
            </CutoutCard>
 | 
			
		||||
          </Box>
 | 
			
		||||
        )}
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,54 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Text, Icon, Icons, MenuItem } from 'folds';
 | 
			
		||||
import { CutoutCard } from '../../../components/cutout-card';
 | 
			
		||||
 | 
			
		||||
type AccountDataListProps = {
 | 
			
		||||
  types: string[];
 | 
			
		||||
  onSelect: (type: string | null) => void;
 | 
			
		||||
};
 | 
			
		||||
export function AccountDataList({
 | 
			
		||||
  types,
 | 
			
		||||
  onSelect,
 | 
			
		||||
}: AccountDataListProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Box justifyContent="SpaceBetween">
 | 
			
		||||
        <Text size="L400">Fields</Text>
 | 
			
		||||
        <Text size="L400">Total: {types.length}</Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <CutoutCard>
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          variant="Surface"
 | 
			
		||||
          fill="None"
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="0"
 | 
			
		||||
          before={<Icon size="50" src={Icons.Plus} />}
 | 
			
		||||
          onClick={() => onSelect(null)}
 | 
			
		||||
        >
 | 
			
		||||
          <Box grow="Yes">
 | 
			
		||||
            <Text size="T200" truncate>
 | 
			
		||||
              Add New
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
        {types.sort().map((type) => (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            key={type}
 | 
			
		||||
            variant="Surface"
 | 
			
		||||
            fill="None"
 | 
			
		||||
            size="300"
 | 
			
		||||
            radii="0"
 | 
			
		||||
            after={<Icon size="50" src={Icons.ChevronRight} />}
 | 
			
		||||
            onClick={() => onSelect(type)}
 | 
			
		||||
          >
 | 
			
		||||
            <Box grow="Yes">
 | 
			
		||||
              <Text size="T200" truncate>
 | 
			
		||||
                {type}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </MenuItem>
 | 
			
		||||
        ))}
 | 
			
		||||
      </CutoutCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,7 @@
 | 
			
		|||
import React, { useCallback, useState } from 'react';
 | 
			
		||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
 | 
			
		||||
import { AccountDataEvents } from 'matrix-js-sdk';
 | 
			
		||||
import { Feature, ServerSupport } from 'matrix-js-sdk/lib/feature';
 | 
			
		||||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
| 
						 | 
				
			
			@ -8,117 +10,209 @@ import { useSetting } from '../../../state/hooks/settings';
 | 
			
		|||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import {
 | 
			
		||||
  AccountDataDeleteCallback,
 | 
			
		||||
  AccountDataEditor,
 | 
			
		||||
  AccountDataSubmitCallback,
 | 
			
		||||
} from '../../../components/AccountDataEditor';
 | 
			
		||||
import { copyToClipboard } from '../../../utils/dom';
 | 
			
		||||
import { AccountData } from './AccountData';
 | 
			
		||||
import { AccountDataList } from './AccountDataList';
 | 
			
		||||
import { useExtendedProfile } from '../../../hooks/useExtendedProfile';
 | 
			
		||||
import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback';
 | 
			
		||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
 | 
			
		||||
 | 
			
		||||
type DeveloperToolsPage =
 | 
			
		||||
  | { name: 'index' }
 | 
			
		||||
  | { name: 'account-data'; type: string | null }
 | 
			
		||||
  | { name: 'profile-field'; type: string | null };
 | 
			
		||||
 | 
			
		||||
type DeveloperToolsProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
};
 | 
			
		||||
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId() as string;
 | 
			
		||||
 | 
			
		||||
  const [accountDataTypes, setAccountDataKeys] = useState(() =>
 | 
			
		||||
    Array.from(mx.store.accountData.keys())
 | 
			
		||||
  );
 | 
			
		||||
  const accountDataDeletionSupported =
 | 
			
		||||
    (mx.canSupport.get(Feature.AccountDataDeletion) ?? ServerSupport.Unsupported) !==
 | 
			
		||||
    ServerSupport.Unsupported;
 | 
			
		||||
  useAccountDataCallback(
 | 
			
		||||
    mx,
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      setAccountDataKeys(Array.from(mx.store.accountData.keys()));
 | 
			
		||||
    }, [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
 | 
			
		||||
 | 
			
		||||
  const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
 | 
			
		||||
  const [expand, setExpend] = useState(false);
 | 
			
		||||
  const [accountDataType, setAccountDataType] = useState<string | null>();
 | 
			
		||||
  const [page, setPage] = useState<DeveloperToolsPage>({ name: 'index' });
 | 
			
		||||
  const [globalExpand, setGlobalExpand] = useState(false);
 | 
			
		||||
  const [profileExpand, setProfileExpand] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const submitAccountData: AccountDataSubmitCallback = useCallback(
 | 
			
		||||
    async (type, content) => {
 | 
			
		||||
      await mx.setAccountData(type, content);
 | 
			
		||||
      await mx.setAccountData(type as keyof AccountDataEvents, content);
 | 
			
		||||
    },
 | 
			
		||||
    [mx]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (accountDataType !== undefined) {
 | 
			
		||||
    return (
 | 
			
		||||
      <AccountDataEditor
 | 
			
		||||
        type={accountDataType ?? undefined}
 | 
			
		||||
        content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
 | 
			
		||||
        submitChange={submitAccountData}
 | 
			
		||||
        requestClose={() => setAccountDataType(undefined)}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Page>
 | 
			
		||||
      <PageHeader outlined={false}>
 | 
			
		||||
        <Box grow="Yes" gap="200">
 | 
			
		||||
          <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
            <Text size="H3" truncate>
 | 
			
		||||
              Developer Tools
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box shrink="No">
 | 
			
		||||
            <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
              <Icon src={Icons.Cross} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </PageHeader>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll hideTrack visibility="Hover">
 | 
			
		||||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Options</Text>
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                >
 | 
			
		||||
                  <SettingTile
 | 
			
		||||
                    title="Enable Developer Tools"
 | 
			
		||||
                    after={
 | 
			
		||||
                      <Switch
 | 
			
		||||
                        variant="Primary"
 | 
			
		||||
                        value={developerTools}
 | 
			
		||||
                        onChange={setDeveloperTools}
 | 
			
		||||
                      />
 | 
			
		||||
                    }
 | 
			
		||||
                  />
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
                {developerTools && (
 | 
			
		||||
                  <SequenceCard
 | 
			
		||||
                    className={SequenceCardStyle}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    direction="Column"
 | 
			
		||||
                    gap="400"
 | 
			
		||||
                  >
 | 
			
		||||
                    <SettingTile
 | 
			
		||||
                      title="Access Token"
 | 
			
		||||
                      description="Copy access token to clipboard."
 | 
			
		||||
                      after={
 | 
			
		||||
                        <Button
 | 
			
		||||
                          onClick={() =>
 | 
			
		||||
                            copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
 | 
			
		||||
                          }
 | 
			
		||||
                          variant="Secondary"
 | 
			
		||||
                          fill="Soft"
 | 
			
		||||
                          size="300"
 | 
			
		||||
                          radii="300"
 | 
			
		||||
                          outlined
 | 
			
		||||
                        >
 | 
			
		||||
                          <Text size="B300">Copy</Text>
 | 
			
		||||
                        </Button>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </SequenceCard>
 | 
			
		||||
                )}
 | 
			
		||||
              </Box>
 | 
			
		||||
              {developerTools && (
 | 
			
		||||
                <AccountData
 | 
			
		||||
                  expand={expand}
 | 
			
		||||
                  onExpandToggle={setExpend}
 | 
			
		||||
                  onSelect={setAccountDataType}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Page>
 | 
			
		||||
  const deleteAccountData: AccountDataDeleteCallback = useCallback(
 | 
			
		||||
    async (type) => {
 | 
			
		||||
      await mx.deleteAccountData(type as keyof AccountDataEvents);
 | 
			
		||||
    },
 | 
			
		||||
    [mx]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const submitProfileField: AccountDataSubmitCallback = useCallback(
 | 
			
		||||
    async (type, content) => {
 | 
			
		||||
      await mx.setExtendedProfileProperty(type, content);
 | 
			
		||||
      await refreshExtendedProfile();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, refreshExtendedProfile]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const deleteProfileField: AccountDataDeleteCallback = useCallback(
 | 
			
		||||
    async (type) => {
 | 
			
		||||
      await mx.deleteExtendedProfileProperty(type);
 | 
			
		||||
      await refreshExtendedProfile();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, refreshExtendedProfile]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleClose = useCallback(() => setPage({ name: 'index' }), [setPage]);
 | 
			
		||||
 | 
			
		||||
  switch (page.name) {
 | 
			
		||||
    case 'account-data':
 | 
			
		||||
      return (
 | 
			
		||||
        <AccountDataEditor
 | 
			
		||||
          type={page.type ?? undefined}
 | 
			
		||||
          content={
 | 
			
		||||
            page.type
 | 
			
		||||
              ? mx.getAccountData(page.type as keyof AccountDataEvents)?.getContent()
 | 
			
		||||
              : undefined
 | 
			
		||||
          }
 | 
			
		||||
          submitChange={submitAccountData}
 | 
			
		||||
          submitDelete={accountDataDeletionSupported ? deleteAccountData : undefined}
 | 
			
		||||
          requestClose={handleClose}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    case 'profile-field':
 | 
			
		||||
      return (
 | 
			
		||||
        <AccountDataEditor
 | 
			
		||||
          type={page.type ?? undefined}
 | 
			
		||||
          content={page.type ? extendedProfile?.[page.type] : undefined}
 | 
			
		||||
          submitChange={submitProfileField}
 | 
			
		||||
          submitDelete={deleteProfileField}
 | 
			
		||||
          requestClose={handleClose}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
    default:
 | 
			
		||||
      return (
 | 
			
		||||
        <Page>
 | 
			
		||||
          <PageHeader outlined={false}>
 | 
			
		||||
            <Box grow="Yes" gap="200">
 | 
			
		||||
              <Box grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
                <Text size="H3" truncate>
 | 
			
		||||
                  Developer Tools
 | 
			
		||||
                </Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box shrink="No">
 | 
			
		||||
                <IconButton onClick={requestClose} variant="Surface">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageHeader>
 | 
			
		||||
          <Box grow="Yes">
 | 
			
		||||
            <Scroll hideTrack visibility="Hover">
 | 
			
		||||
              <PageContent>
 | 
			
		||||
                <Box direction="Column" gap="700">
 | 
			
		||||
                  <Box direction="Column" gap="100">
 | 
			
		||||
                    <Text size="L400">Options</Text>
 | 
			
		||||
                    <SequenceCard
 | 
			
		||||
                      className={SequenceCardStyle}
 | 
			
		||||
                      variant="SurfaceVariant"
 | 
			
		||||
                      direction="Column"
 | 
			
		||||
                      gap="400"
 | 
			
		||||
                    >
 | 
			
		||||
                      <SettingTile
 | 
			
		||||
                        title="Enable Developer Tools"
 | 
			
		||||
                        after={
 | 
			
		||||
                          <Switch
 | 
			
		||||
                            variant="Primary"
 | 
			
		||||
                            value={developerTools}
 | 
			
		||||
                            onChange={setDeveloperTools}
 | 
			
		||||
                          />
 | 
			
		||||
                        }
 | 
			
		||||
                      />
 | 
			
		||||
                    </SequenceCard>
 | 
			
		||||
                    {developerTools && (
 | 
			
		||||
                      <SequenceCard
 | 
			
		||||
                        className={SequenceCardStyle}
 | 
			
		||||
                        variant="SurfaceVariant"
 | 
			
		||||
                        direction="Column"
 | 
			
		||||
                        gap="400"
 | 
			
		||||
                      >
 | 
			
		||||
                        <SettingTile
 | 
			
		||||
                          title="Access Token"
 | 
			
		||||
                          description="Copy access token to clipboard."
 | 
			
		||||
                          after={
 | 
			
		||||
                            <Button
 | 
			
		||||
                              onClick={() =>
 | 
			
		||||
                                copyToClipboard(mx.getAccessToken() ?? '<NO_ACCESS_TOKEN_FOUND>')
 | 
			
		||||
                              }
 | 
			
		||||
                              variant="Secondary"
 | 
			
		||||
                              fill="Soft"
 | 
			
		||||
                              size="300"
 | 
			
		||||
                              radii="300"
 | 
			
		||||
                              outlined
 | 
			
		||||
                            >
 | 
			
		||||
                              <Text size="B300">Copy</Text>
 | 
			
		||||
                            </Button>
 | 
			
		||||
                          }
 | 
			
		||||
                        />
 | 
			
		||||
                      </SequenceCard>
 | 
			
		||||
                    )}
 | 
			
		||||
                  </Box>
 | 
			
		||||
                  {developerTools && (
 | 
			
		||||
                    <Box direction="Column" gap="100">
 | 
			
		||||
                      <Text size="L400">Account Data</Text>
 | 
			
		||||
                      <CollapsibleCard
 | 
			
		||||
                        expand={globalExpand}
 | 
			
		||||
                        setExpand={setGlobalExpand}
 | 
			
		||||
                        title="Account"
 | 
			
		||||
                        description="Private data stored in your account."
 | 
			
		||||
                      >
 | 
			
		||||
                        <AccountDataList
 | 
			
		||||
                          types={accountDataTypes}
 | 
			
		||||
                          onSelect={(type) => setPage({ name: 'account-data', type })}
 | 
			
		||||
                        />
 | 
			
		||||
                      </CollapsibleCard>
 | 
			
		||||
                      {extendedProfile && (
 | 
			
		||||
                        <CollapsibleCard
 | 
			
		||||
                          expand={profileExpand}
 | 
			
		||||
                          setExpand={setProfileExpand}
 | 
			
		||||
                          title="Profile"
 | 
			
		||||
                          description="Public data attached to your Matrix profile."
 | 
			
		||||
                        >
 | 
			
		||||
                          <AccountDataList
 | 
			
		||||
                            types={Object.keys(extendedProfile)}
 | 
			
		||||
                            onSelect={(type) => setPage({ name: 'profile-field', type })}
 | 
			
		||||
                          />
 | 
			
		||||
                        </CollapsibleCard>
 | 
			
		||||
                      )}
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  )}
 | 
			
		||||
                </Box>
 | 
			
		||||
              </PageContent>
 | 
			
		||||
            </Scroll>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Page>
 | 
			
		||||
      );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		|||
import { decryptMegolmKeyFile, encryptMegolmKeyFile } from '../../../../util/cryptE2ERoomKeys';
 | 
			
		||||
import { useAlive } from '../../../hooks/useAlive';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
import { CollapsibleCard } from '../../../components/CollapsibleCard';
 | 
			
		||||
 | 
			
		||||
function ExportKeys() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
| 
						 | 
				
			
			@ -121,37 +122,18 @@ function ExportKeys() {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ExportKeysTile() {
 | 
			
		||||
function ExportKeysCard() {
 | 
			
		||||
  const [expand, setExpand] = useState(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Export Messages Data"
 | 
			
		||||
        description="Save password protected copy of encryption data on your device to decrypt messages later."
 | 
			
		||||
        after={
 | 
			
		||||
          <Box>
 | 
			
		||||
            <Button
 | 
			
		||||
              type="button"
 | 
			
		||||
              onClick={() => setExpand(!expand)}
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              outlined
 | 
			
		||||
              radii="300"
 | 
			
		||||
              before={
 | 
			
		||||
                <Icon size="100" src={expand ? Icons.ChevronTop : Icons.ChevronBottom} filled />
 | 
			
		||||
              }
 | 
			
		||||
            >
 | 
			
		||||
              <Text as="span" size="B300" truncate>
 | 
			
		||||
                {expand ? 'Collapse' : 'Expand'}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          </Box>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      {expand && <ExportKeys />}
 | 
			
		||||
    </>
 | 
			
		||||
    <CollapsibleCard
 | 
			
		||||
      expand={expand}
 | 
			
		||||
      setExpand={setExpand}
 | 
			
		||||
      title="Export Messages Data"
 | 
			
		||||
      description="Save password protected copy of encryption data on your device to decrypt messages later."
 | 
			
		||||
    >
 | 
			
		||||
      <ExportKeys />
 | 
			
		||||
    </CollapsibleCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -304,14 +286,7 @@ export function LocalBackup() {
 | 
			
		|||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Local Backup</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <ExportKeysTile />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
      <ExportKeysCard />
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										112
									
								
								src/app/hooks/useExtendedProfile.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								src/app/hooks/useExtendedProfile.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,112 @@
 | 
			
		|||
import { useCallback } from 'react';
 | 
			
		||||
import z from 'zod';
 | 
			
		||||
import { useQuery } from '@tanstack/react-query';
 | 
			
		||||
import { Capabilities } from 'matrix-js-sdk';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { useSpecVersions } from './useSpecVersions';
 | 
			
		||||
import { IProfileFieldsCapability } from '../../types/matrix/common';
 | 
			
		||||
 | 
			
		||||
const extendedProfile = z.looseObject({
 | 
			
		||||
  displayname: z.string().optional(),
 | 
			
		||||
  avatar_url: z.string().optional(),
 | 
			
		||||
  'io.fsky.nyx.pronouns': z
 | 
			
		||||
    .object({
 | 
			
		||||
      language: z.string(),
 | 
			
		||||
      summary: z.string(),
 | 
			
		||||
    })
 | 
			
		||||
    .array()
 | 
			
		||||
    .optional()
 | 
			
		||||
    .catch(undefined),
 | 
			
		||||
  'us.cloke.msc4175.tz': z.string().optional().catch(undefined),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type ExtendedProfile = z.infer<typeof extendedProfile>;
 | 
			
		||||
 | 
			
		||||
export function useExtendedProfileSupported(): boolean {
 | 
			
		||||
  const { versions, unstable_features: unstableFeatures } = useSpecVersions();
 | 
			
		||||
 | 
			
		||||
  return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Returns the user's MSC4133 extended profile, if our homeserver supports it.
 | 
			
		||||
/// This will return `undefined` while the request is in flight and `null` if the HS lacks support.
 | 
			
		||||
export function useExtendedProfile(
 | 
			
		||||
  userId: string
 | 
			
		||||
): [ExtendedProfile | undefined | null, () => Promise<void>] {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const extendedProfileSupported = useExtendedProfileSupported();
 | 
			
		||||
  const { data, refetch } = useQuery({
 | 
			
		||||
    queryKey: ['extended-profile', userId],
 | 
			
		||||
    queryFn: useCallback(async () => {
 | 
			
		||||
      if (extendedProfileSupported) {
 | 
			
		||||
        return extendedProfile.parse(await mx.getExtendedProfile(userId));
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    }, [mx, userId, extendedProfileSupported]),
 | 
			
		||||
    refetchOnMount: false,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return [
 | 
			
		||||
    data,
 | 
			
		||||
    async () => {
 | 
			
		||||
      await refetch();
 | 
			
		||||
    },
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
 | 
			
		||||
 | 
			
		||||
/// Returns whether the given profile field may be edited by the user.
 | 
			
		||||
export function profileEditsAllowed(
 | 
			
		||||
  field: string,
 | 
			
		||||
  capabilities: Capabilities,
 | 
			
		||||
  extendedProfileSupported: boolean
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (LEGACY_FIELDS.includes(field)) {
 | 
			
		||||
    // this field might have a pre-msc4133 capability. check that first
 | 
			
		||||
    if (capabilities[`m.set_${field}`]?.enabled === false) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!extendedProfileSupported) {
 | 
			
		||||
      // the homeserver only supports legacy fields
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (extendedProfileSupported) {
 | 
			
		||||
    // the homeserver has msc4133 support
 | 
			
		||||
    const extendedProfileCapability = capabilities[
 | 
			
		||||
      'uk.tcpip.msc4133.profile_fields'
 | 
			
		||||
    ] as IProfileFieldsCapability;
 | 
			
		||||
 | 
			
		||||
    if (extendedProfileCapability === undefined) {
 | 
			
		||||
      // the capability is missing, assume modification is allowed
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!extendedProfileCapability.enabled) {
 | 
			
		||||
      // the capability is set to disable profile modifications
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      extendedProfileCapability.allowed !== undefined &&
 | 
			
		||||
      !extendedProfileCapability.allowed.includes(field)
 | 
			
		||||
    ) {
 | 
			
		||||
      // the capability includes an allowlist and `field` isn't in it
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (extendedProfileCapability.disallowed?.includes(field)) {
 | 
			
		||||
      // the capability includes an blocklist and `field` is in it
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // the capability is enabled and `field` isn't blocked
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // `field` is an extended profile key and the homeserver lacks msc4133 support
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,9 @@
 | 
			
		|||
import React, { useState } from 'react';
 | 
			
		||||
import { Text } from 'folds';
 | 
			
		||||
import { Icon, Icons } from 'folds';
 | 
			
		||||
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { nameInitials } from '../../../utils/common';
 | 
			
		||||
import { mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { Settings } from '../../../features/settings';
 | 
			
		||||
import { useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
| 
						 | 
				
			
			@ -13,12 +12,11 @@ import { Modal500 } from '../../../components/Modal500';
 | 
			
		|||
export function SettingsTab() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const userId = mx.getUserId() as string;
 | 
			
		||||
  const profile = useUserProfile(userId);
 | 
			
		||||
 | 
			
		||||
  const [settings, setSettings] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -34,7 +32,7 @@ export function SettingsTab() {
 | 
			
		|||
            <UserAvatar
 | 
			
		||||
              userId={userId}
 | 
			
		||||
              src={avatarUrl}
 | 
			
		||||
              renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
 | 
			
		||||
              renderFallback={() => <Icon size="400" src={Icons.User} filled />}
 | 
			
		||||
            />
 | 
			
		||||
          </SidebarAvatar>
 | 
			
		||||
        )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk';
 | 
			
		|||
 | 
			
		||||
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
 | 
			
		||||
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
 | 
			
		||||
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
 | 
			
		||||
export const MATRIX_SPOILER_REASON_PROPERTY_NAME =
 | 
			
		||||
  'page.codeberg.everypizza.msc4193.spoiler.reason';
 | 
			
		||||
 | 
			
		||||
export type IImageInfo = {
 | 
			
		||||
  w?: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -88,3 +89,9 @@ export type ILocationContent = {
 | 
			
		|||
  geo_uri?: string;
 | 
			
		||||
  info?: IThumbnailContent;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type IProfileFieldsCapability = {
 | 
			
		||||
  enabled?: boolean;
 | 
			
		||||
  allowed?: string[];
 | 
			
		||||
  disallowed?: string[];
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,8 @@
 | 
			
		|||
export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
 | 
			
		||||
  [Property in Key]-?: Type[Property];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Represents a subset of T containing only the keys whose values extend V
 | 
			
		||||
export type FilterByValues<T extends object, V> = {
 | 
			
		||||
  [Property in keyof T as T[Property] extends V ? Property : never]: T[Property];
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue