Compare commits

...

36 commits

Author SHA1 Message Date
482e93e3fb Merge commit 'refs/pull/2512/head' of https://github.com/cinnyapp/cinny into dev
Some checks are pending
Deploy to Netlify (dev) / Deploy to Netlify (push) Waiting to run
Merged the element call pr from upstream
2026-01-30 19:54:33 +02:00
f027696676 Merge commit 'refs/pull/2487/head' of https://github.com/cinnyapp/cinny into dev
Merging the pronouns pr from upstream
2026-01-30 19:52:37 +02:00
Timo K
c1b2902bc5 add netlify redirect to widgets/
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-21 13:51:59 +02:00
Timo K
0f70e6f706 fix widget loading vite config
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-21 11:58:47 +02:00
Timo K
94ff2d1d7d Add call button to header
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:33:45 +02:00
Timo K
c9effa0860 use call view in room view
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:32:57 +02:00
Timo K
4985de841c call view impl
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:32:11 +02:00
Timo K
65085e84f7 Call View template
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:31:32 +02:00
Timo K
e18769f9c6 copy over StopGapWidget (ElementCallMessaging class)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:29:30 +02:00
Timo K
8df27ac688 copy over widgetDriver from EW
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:24:02 +02:00
Timo K
bbe53d6d6f copy over utils form EW
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:23:48 +02:00
Timo K
98c90b23af dependencies
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-12 19:22:58 +02:00
Ginger
13dd8fcc06
Allow account data to be deleted if the homeserver supports it 2025-10-06 14:18:41 -04:00
Ginger
205ea1655a
Add a context menu option to view a user's raw extended profile fields 2025-10-06 14:02:50 -04:00
Ginger
d42bcc6e3d
Use a common CollapsibleCard element for collapsible settings cards 2025-10-06 12:21:01 -04:00
Ginger
af9460ef8b
Fix incorrect logic when checking for profile field changes 2025-10-06 11:45:32 -04:00
Ginger
5bc9654d32
Add a panel in Developer Tools for editing profile fields 2025-10-06 11:44:41 -04:00
Ginger
4e7b64eb5f
Merge branch 'dev' into msc4133 2025-10-01 13:56:37 -04:00
Ginger
f9b0d8c86f
Add some explanatory comments 2025-09-24 10:44:17 -04:00
Ginger
458b1c0172
Merge branch 'dev' into msc4133 2025-09-22 11:46:32 -04:00
Ginger
4c5acc1940
Use proper deep comparison for hasChanged 2025-09-20 16:40:18 -04:00
Ginger
cfee62ffe6
Fix profile field comparison 2025-09-20 14:40:13 -04:00
Ginger
79b37e177b
Improve text contrast in IDP profile settings element 2025-09-19 10:50:57 -04:00
Ginger
4c515bb72e
Move timezone chip to a better position 2025-09-18 12:36:08 -04:00
Ginger
8a8443bda4
Move profile field elements into their own files 2025-09-18 12:34:34 -04:00
Ginger
317cd366c3
Hide profile fields which are blocked by a capability 2025-09-18 12:29:51 -04:00
Ginger
aafd028af4
Fix support for MSC4133-less homeservers, add OIDC profile link 2025-09-18 10:20:52 -04:00
Ginger
984803c52c
Add slightly more padding above the profile save and cancel buttons 2025-09-16 09:36:07 -04:00
Ginger
07df0c2c79
Use Tanstack Query when fetching extended profiles to improve caching 2025-09-16 09:23:06 -04:00
Ginger
c389365ea2
Merge branch 'dev' into msc4133 2025-09-15 18:27:50 -04:00
Ginger
d4deba6074
Use a consistent fallback icon in settings for users with no avatar 2025-09-15 14:50:29 -04:00
Ginger
c5b59ea122
Add a setting for user pronouns 2025-09-15 14:46:08 -04:00
Ginger
c7f6e33a2b
Propery delete blank profile fields 2025-09-15 14:06:41 -04:00
Ginger
5c2c8984aa
Fix flickering issues when updating profile fields 2025-09-15 14:03:08 -04:00
Ginger
c3901804c0
Add a chip and setting for user timezones 2025-09-15 13:46:27 -04:00
Ginger
3c1aa0e699
Rework profile settings to show a preview and support more fields 2025-09-15 10:47:21 -04:00
33 changed files with 2973 additions and 913 deletions

View file

@ -29,6 +29,11 @@
to = "/assets/:splat"
status = 200
[[redirects]]
from = "/widgets/*"
to = "/widgets/:splat"
status = 200
[[redirects]]
from = "/*"
to = "/index.html"

19
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@element-hq/element-call-embedded": "0.16.0",
"@fontsource/inter": "4.5.14",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
@ -44,6 +45,7 @@
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.13.1",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@ -62,7 +64,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",
@ -1649,6 +1652,11 @@
"node": ">=6.9.0"
}
},
"node_modules/@element-hq/element-call-embedded": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.0.tgz",
"integrity": "sha512-cTJwW9cmQHrTUvcJm0xv9uSTMYiToDEaw5AuxXQS9jVjHQT8B3W3DWtKYAzq1PRRH13JZkcwXbHjdFpkxzhzCQ=="
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@ -12112,6 +12120,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"
}
}
}
}

View file

@ -23,6 +23,7 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@element-hq/element-call-embedded": "0.16.0",
"@fontsource/inter": "4.5.14",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
@ -55,6 +56,7 @@
"linkify-react": "4.1.3",
"linkifyjs": "4.1.3",
"matrix-js-sdk": "38.2.0",
"matrix-widget-api": "1.13.1",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@ -73,7 +75,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",

View file

@ -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>

View 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>
);
}

View file

@ -0,0 +1,151 @@
import React, { useEffect, useRef, useState } from 'react';
import { ClientWidgetApi } from 'matrix-widget-api';
import { Button, Text } from 'folds';
import ElementCall from './ElementCall';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useEventEmitter } from './utils';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallOngoing } from '../../hooks/useCallOngoing';
export enum CallWidgetActions {
// All of these actions are currently specific to Jitsi and Element Call
JoinCall = 'io.element.join',
HangupCall = 'im.vector.hangup',
Close = 'io.element.close',
}
export function action(type: CallWidgetActions): string {
return `action:${type}`;
}
const iframeFeatures =
'microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; ' +
'clipboard-read;';
const sandboxFlags =
'allow-forms allow-popups allow-popups-to-escape-sandbox ' +
'allow-same-origin allow-scripts allow-presentation allow-downloads';
const iframeStyles = { flex: '1 1', border: 'none' };
const containerStyle = (hidden: boolean): React.CSSProperties => ({
display: 'flex',
flexDirection: 'column',
position: 'relative',
height: '100%',
width: '100%',
...(hidden
? {
overflow: 'hidden',
width: 0,
height: 0,
}
: {}),
});
const closeButtonStyle: React.CSSProperties = {
position: 'absolute',
top: 8,
right: 8,
zIndex: 100,
maxWidth: 'fit-content',
};
enum State {
Preparing = 'preparing',
Lobby = 'lobby',
Joined = 'joined',
HungUp = 'hung_up',
CanClose = 'can_close',
}
/**
* Shows a call for this room. Rendering this component will
* automatically create a call widget and join the call in the room.
* @returns
*/
export interface IRoomCallViewProps {
onClose?: () => void;
onJoin?: () => void;
onHangup?: (errorMessage?: string) => void;
}
export function CallView({
onJoin = undefined,
onClose = undefined,
onHangup = undefined,
}: IRoomCallViewProps): JSX.Element {
// Construct required variables
const room = useRoom();
const client = useMatrixClient();
const iframe = useRef<HTMLIFrameElement>(null);
// Model state
const [elementCall, setElementCall] = useState<ElementCall | null>();
const [widgetApi, setWidgetApi] = useState<ClientWidgetApi | null>(null);
const [state, setState] = useState(State.Preparing);
// Initialization parameters
const isDirect = useIsDirectRoom();
const callOngoing = useCallOngoing(room);
const initialCallOngoing = React.useRef(callOngoing);
const initialIsDirect = React.useRef(isDirect);
useEffect(() => {
if (client && room && !elementCall) {
const e = new ElementCall(client, room, initialIsDirect.current, initialCallOngoing.current);
setElementCall(e);
}
}, [client, room, setElementCall, elementCall]);
// Start the messaging over the widget api.
useEffect(() => {
if (iframe.current && elementCall) {
elementCall.startMessaging(iframe.current);
}
return () => {
elementCall?.stopMessaging();
};
}, [iframe, elementCall]);
// Widget api ready
useEventEmitter(elementCall, 'ready', () => {
setWidgetApi(elementCall?.widgetApi ?? null);
setState(State.Lobby);
});
// Use widget api to listen for hangup/join/close actions
useEventEmitter(widgetApi, action(CallWidgetActions.HangupCall), () => {
setState(State.HungUp);
onHangup?.();
});
useEventEmitter(widgetApi, action(CallWidgetActions.JoinCall), () => {
setState(State.Joined);
onJoin?.();
});
useEventEmitter(widgetApi, action(CallWidgetActions.Close), () => {
setState(State.CanClose);
onClose?.();
});
// render component
return (
<div style={containerStyle(state === State.HungUp)}>
{/* Exit button for lobby state */}
{state === State.Lobby && (
<Button
variant="Secondary"
onClick={() => {
setState(State.CanClose);
onClose?.();
}}
style={closeButtonStyle}
>
<Text size="B400">Close</Text>
</Button>
)}
<iframe
ref={iframe}
allow={iframeFeatures}
sandbox={sandboxFlags}
style={iframeStyles}
src={elementCall?.embedUrl}
title="room call"
/>
</div>
);
}

View file

@ -0,0 +1,497 @@
/* eslint-disable no-plusplus */
/* eslint-disable no-dupe-class-members */
/* eslint-disable lines-between-class-members */
/* eslint-disable class-methods-use-this */
import {
type Capability,
type IOpenIDCredentials,
type IOpenIDUpdate,
type ISendDelayedEventDetails,
type ISendEventDetails,
type ITurnServer,
type IReadEventRelationsResult,
type IRoomEvent,
OpenIDRequestState,
type SimpleObservable,
WidgetDriver,
type IWidgetApiErrorResponseDataDetails,
type ISearchUserDirectoryResult,
type IGetMediaConfigResult,
type UpdateDelayedEventAction,
} from 'matrix-widget-api';
import {
ClientEvent,
type ITurnServer as IClientTurnServer,
EventType,
type IContent,
MatrixError,
type MatrixEvent,
Direction,
type SendDelayedEventResponse,
type StateEvents,
type TimelineEvents,
MatrixClient,
getHttpUriForMxc,
} from 'matrix-js-sdk';
import { logger } from 'matrix-js-sdk/lib/logger';
import iterableDiff, { downloadFromUrlToFile } from './utils';
// TODO: Purge this from the universe
const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): ITurnServer => ({
uris: urls,
username,
password: credential,
});
export default class CallWidgetDriver extends WidgetDriver {
// TODO: Refactor widgetKind into the Widget class
public constructor(
private client: MatrixClient,
private allowedCapabilities = new Set<Capability>(),
private inRoomId: string
) {
super();
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
// Check to see if any capabilities aren't automatically accepted (such as sticker pickers
// allowing stickers to be sent). If there are excess capabilities to be approved, the user
// will be prompted to accept them.
const missing = new Set(iterableDiff(requested, this.allowedCapabilities).removed); // "removed" is "in A (requested) but not in B (allowed)"
if (missing.size > 0) logger.error('missing widget capabilities', missing);
return this.allowedCapabilities;
}
public async sendEvent<K extends keyof StateEvents>(
eventType: K,
content: StateEvents[K],
stateKey: string | null,
targetRoomId: string | null
): Promise<ISendEventDetails>;
public async sendEvent<K extends keyof TimelineEvents>(
eventType: K,
content: TimelineEvents[K],
stateKey: null,
targetRoomId: string | null
): Promise<ISendEventDetails>;
public async sendEvent(
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null
): Promise<ISendEventDetails> {
const { client } = this;
const roomId = targetRoomId || this.inRoomId;
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let r: { event_id: string } | null;
if (stateKey !== null) {
// state event
r = await client.sendStateEvent(
roomId,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey
);
} else if (eventType === EventType.RoomRedaction) {
// special case: extract the `redacts` property and call redact
r = await client.redactEvent(roomId, content.redacts);
} else {
// message event
r = await client.sendEvent(
roomId,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents]
);
}
return { roomId, eventId: r.event_id };
}
/**
* @experimental Part of MSC4140 & MSC4157
* @see {@link WidgetDriver#sendDelayedEvent}
*/
public async sendDelayedEvent<K extends keyof StateEvents>(
delay: number | null,
parentDelayId: string | null,
eventType: K,
content: StateEvents[K],
stateKey: string | null,
targetRoomId: string | null
): Promise<ISendDelayedEventDetails>;
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async sendDelayedEvent<K extends keyof TimelineEvents>(
delay: number | null,
parentDelayId: string | null,
eventType: K,
content: TimelineEvents[K],
stateKey: null,
targetRoomId: string | null
): Promise<ISendDelayedEventDetails>;
public async sendDelayedEvent(
delay: number | null,
parentDelayId: string | null,
eventType: string,
content: IContent,
stateKey: string | null = null,
targetRoomId: string | null = null
): Promise<ISendDelayedEventDetails> {
const { client } = this;
const roomId = targetRoomId || this.inRoomId;
if (!client || !roomId) throw new Error('Not in a room or not attached to a client');
let delayOpts;
if (delay !== null) {
delayOpts = {
delay,
...(parentDelayId !== null && { parent_delay_id: parentDelayId }),
};
} else if (parentDelayId !== null) {
delayOpts = {
parent_delay_id: parentDelayId,
};
} else {
throw new Error('Must provide at least one of delay or parentDelayId');
}
let r: SendDelayedEventResponse | null;
if (stateKey !== null) {
// state event
r = await client._unstable_sendDelayedStateEvent(
roomId,
delayOpts,
eventType as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey
);
} else {
// message event
r = await client._unstable_sendDelayedEvent(
roomId,
delayOpts,
null,
eventType as keyof TimelineEvents,
content as TimelineEvents[keyof TimelineEvents]
);
}
return {
roomId,
delayId: r.delay_id,
};
}
/**
* @experimental Part of MSC4140 & MSC4157
*/
public async updateDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction
): Promise<void> {
const { client } = this;
if (!client) throw new Error('Not in a room or not attached to a client');
await client._unstable_updateDelayedEvent(delayId, action);
}
/**
* Implements {@link WidgetDriver#sendToDevice}
*/
public async sendToDevice(
eventType: string,
encrypted: boolean,
contentMap: { [userId: string]: { [deviceId: string]: object } }
): Promise<void> {
const { client } = this;
if (encrypted) {
const crypto = client.getCrypto();
if (!crypto) throw new Error('E2EE not enabled');
// attempt to re-batch these up into a single request
const invertedContentMap: { [content: string]: { userId: string; deviceId: string }[] } = {};
Object.keys(contentMap).forEach((userId) => {
const userContentMap = contentMap[userId];
Object.keys(userContentMap).forEach((deviceId) => {
const content = userContentMap[deviceId];
const stringifiedContent = JSON.stringify(content);
invertedContentMap[stringifiedContent] = invertedContentMap[stringifiedContent] || [];
invertedContentMap[stringifiedContent].push({ userId, deviceId });
});
});
await Promise.all(
Object.entries(invertedContentMap).map(async ([stringifiedContent, recipients]) => {
const batch = await crypto.encryptToDeviceMessages(
eventType,
recipients,
JSON.parse(stringifiedContent)
);
await client.queueToDevice(batch);
})
);
} else {
await client.queueToDevice({
eventType,
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(([deviceId, content]) => ({
userId,
deviceId,
payload: content,
}))
),
});
}
}
/**
* Reads all events of the given type, and optionally `msgtype` (if applicable/defined),
* the user has access to. The widget API will have already verified that the widget is
* capable of receiving the events. Less events than the limit are allowed to be returned,
* but not more.
* @param roomId The ID of the room to look within.
* @param eventType The event type to be read.
* @param msgtype The msgtype of the events to be read, if applicable/defined.
* @param stateKey The state key of the events to be read, if applicable/defined.
* @param limit The maximum number of events to retrieve. Will be zero to denote "as many as
* possible".
* @param since When null, retrieves the number of events specified by the "limit" parameter.
* Otherwise, the event ID at which only subsequent events will be returned, as many as specified
* in "limit".
* @returns {Promise<IRoomEvent[]>} Resolves to the room events, or an empty array.
*/
public async readRoomTimeline(
roomId: string,
eventType: string,
msgtype: string | undefined,
stateKey: string | undefined,
limit: number,
since: string | undefined
): Promise<IRoomEvent[]> {
// relatively arbitrary
const timelineLimit =
limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
const room = this.client.getRoom(roomId);
if (room === null) return [];
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i];
if (results.length >= timelineLimit) break;
if (since !== undefined && ev.getId() === since) break;
if (
ev.getType() === eventType &&
!ev.isState() &&
(eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) &&
(ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey)
) {
results.push(ev);
}
}
return results.map((e) => e.getEffectiveEvent() as IRoomEvent);
}
/**
* Reads the current values of all matching room state entries.
* @param roomId The ID of the room.
* @param eventType The event type of the entries to be read.
* @param stateKey The state key of the entry to be read. If undefined,
* all room state entries with a matching event type should be returned.
* @returns {Promise<IRoomEvent[]>} Resolves to the events representing the
* current values of the room state entries.
*/
public async readRoomState(
roomId: string,
eventType: string,
stateKey: string | undefined
): Promise<IRoomEvent[]> {
const room = this.client.getRoom(roomId);
if (room === null) return [];
const state = room.getLiveTimeline().getState(Direction.Forward);
if (state === undefined) return [];
if (stateKey === undefined) {
return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent);
}
const event = state.getStateEvents(eventType, stateKey);
return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent];
}
public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>): Promise<void> {
// TODO: Fully functional widget driver a user prompt is required here, see element web
const getToken = (): Promise<IOpenIDCredentials> => this.client.getOpenIdToken();
return observer.update({ state: OpenIDRequestState.Allowed, token: await getToken() });
}
public async navigate(uri: string): Promise<void> {
// navigateToPermalink(uri);
// TODO: Dummy code until we figured out navigateToPermalink implementation
if (uri) return Promise.resolve();
return Promise.reject();
}
public async *getTurnServers(): AsyncGenerator<ITurnServer> {
const { client } = this;
if (!client.pollingTurnServers || !client.getTurnServers().length) return;
let setTurnServer: (server: ITurnServer) => void;
let setError: (error: Error) => void;
const onTurnServers = ([server]: IClientTurnServer[]): void =>
setTurnServer(normalizeTurnServer(server));
const onTurnServersError = (error: Error, fatal: boolean): void => {
if (fatal) setError(error);
};
client.on(ClientEvent.TurnServers, onTurnServers);
client.on(ClientEvent.TurnServersError, onTurnServersError);
try {
const initialTurnServer = client.getTurnServers()[0];
yield normalizeTurnServer(initialTurnServer);
const waitForTurnServer = (): Promise<ITurnServer> =>
new Promise<ITurnServer>((resolve, reject) => {
setTurnServer = resolve;
setError = reject;
});
// Repeatedly listen for new TURN servers until an error occurs or
// the caller stops this generator
while (true) {
// eslint-disable-next-line no-await-in-loop
yield await waitForTurnServer();
}
} finally {
// The loop was broken - clean up
client.off(ClientEvent.TurnServers, onTurnServers);
client.off(ClientEvent.TurnServersError, onTurnServersError);
}
}
public async readEventRelations(
eventId: string,
roomId?: string,
relationType?: string,
eventType?: string,
from?: string,
to?: string,
limit?: number,
direction?: 'f' | 'b'
): Promise<IReadEventRelationsResult> {
const { client } = this;
const dir = direction as Direction;
const rId = roomId || this.inRoomId;
if (typeof rId !== 'string') {
throw new Error('Error while reading the current room');
}
const { events, nextBatch, prevBatch } = await client.relations(
rId,
eventId,
relationType ?? null,
eventType ?? null,
{
from,
to,
limit,
dir,
}
);
return {
chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent),
nextBatch: nextBatch ?? undefined,
prevBatch: prevBatch ?? undefined,
};
}
public async searchUserDirectory(
searchTerm: string,
limit?: number
): Promise<ISearchUserDirectoryResult> {
const { client } = this;
const { limited, results } = await client.searchUserDirectory({ term: searchTerm, limit });
return {
limited,
results: results.map((r) => ({
userId: r.user_id,
displayName: r.display_name,
avatarUrl: r.avatar_url,
})),
};
}
public async getMediaConfig(): Promise<IGetMediaConfigResult> {
const { client } = this;
return client.getMediaConfig();
}
public async uploadFile(file: XMLHttpRequestBodyInit): Promise<{ contentUri: string }> {
const { client } = this;
const uploadResult = await client.uploadContent(file);
return { contentUri: uploadResult.content_uri };
}
/**
* Download a file from the media repository on the homeserver.
*
* @param contentUri - the MXC URI of the file to download
* @returns an object with: file - response contents as Blob
*/
public async downloadFile(contentUri: string): Promise<{ file: XMLHttpRequestBodyInit }> {
const httpUrl = getHttpUriForMxc(
this.client.baseUrl,
contentUri,
undefined,
undefined,
undefined,
false,
undefined,
true
);
const file = await downloadFromUrlToFile(httpUrl);
return { file };
}
/**
* Gets the IDs of all joined or invited rooms currently known to the
* client.
* @returns The room IDs.
*/
public getKnownRooms(): string[] {
return this.client.getVisibleRooms(false).map((r) => r.roomId);
}
/**
* Expresses a {@link MatrixError} as a JSON payload
* for use by Widget API error responses.
* @param error The error to handle.
* @returns The error expressed as a JSON payload,
* or undefined if it is not a {@link MatrixError}.
*/
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
return error instanceof MatrixError
? {
matrix_api_error: error.asWidgetApiErrorData(),
}
: undefined;
}
}

View file

@ -0,0 +1,290 @@
import {
MatrixEvent,
MatrixClient,
MatrixEventEvent,
ClientEvent,
RoomStateEvent,
KnownMembership,
Room,
} from 'matrix-js-sdk';
import { ClientWidgetApi, type IRoomEvent, Widget } from 'matrix-widget-api';
import { EventEmitter } from 'events';
import { logger } from 'matrix-js-sdk/src/logger';
import { arrayFastClone, elementCallCapabilities } from './utils';
import CallWidgetDriver from './CallWidgetDriver';
export enum ElementCallIntent {
StartCall = 'start_call',
JoinExisting = 'join_existing',
StartCallDM = 'start_call_dm',
JoinExistingDM = 'join_existing_dm',
}
function createCallWidget(room: Room, client: MatrixClient, intent: string): Widget {
const perParticipantE2EE = room?.hasEncryptionStateEvent() ?? false;
const baseUrl = new URL(window.location.href).origin;
const url = new URL('./widgets/element-call/index.html#', baseUrl); // this strips hash fragment from baseUrl
const widgetId = 'io-element-call-widget-id';
// Splice together the Element Call URL for this call
const paramsHash = new URLSearchParams({
perParticipantE2EE: perParticipantE2EE ? 'true' : 'false',
intent,
userId: client.getSafeUserId(),
deviceId: client.getDeviceId() ?? '',
roomId: room.roomId,
baseUrl: client.baseUrl,
lang: 'en-EN',
theme: 'light',
});
const paramsSearch = new URLSearchParams({
widgetId,
parentUrl: window.location.href.split('#', 2)[0],
});
url.search = paramsSearch.toString();
const replacedUrl = paramsHash.toString().replace(/%24/g, '$');
url.hash = `#?${replacedUrl}`;
return new Widget({
id: widgetId,
creatorUserId: client.getSafeUserId(),
name: 'Element Call',
type: 'm.call',
url: url.toString(),
waitForIframeLoad: false,
data: {},
});
}
export default class ElementCall extends EventEmitter {
private messaging: ClientWidgetApi | null = null;
public widget: Widget;
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
// Holds events that should be fed to the widget once they finish decrypting
private eventsToFeed = new WeakSet<MatrixEvent>();
public constructor(
private client: MatrixClient,
private room: Room,
isDirect: boolean,
callOngoing: boolean
) {
super();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const intent = new Map([
['start_call_dm', ElementCallIntent.StartCallDM],
['join_existing_dm', ElementCallIntent.JoinExistingDM],
['start_call', ElementCallIntent.StartCall],
['join_existing', ElementCallIntent.JoinExisting],
]).get((callOngoing ? 'join_existing' : 'start_call') + (isDirect ? '_dm' : ''))!;
this.widget = createCallWidget(this.room, this.client, intent);
}
public get widgetApi(): ClientWidgetApi | null {
return this.messaging;
}
/**
* The URL to use in the iframe
*/
public get embedUrl(): string {
return this.widget.templateUrl;
}
/**
* This starts the messaging for the widget if it is not in the state `started` yet.
* @param iframe the iframe the widget should use
*/
public startMessaging(iframe: HTMLIFrameElement) {
if (this.messaging) return;
const userId = this.client.getSafeUserId();
const deviceId = this.client.getDeviceId() ?? undefined;
const driver = new CallWidgetDriver(
this.client,
elementCallCapabilities(this.room.roomId, userId, deviceId),
this.room.roomId
);
this.messaging = new ClientWidgetApi(this.widget, iframe, driver);
this.messaging.on('preparing', () => this.emit('preparing'));
this.messaging.on('error:preparing', (err: unknown) => this.emit('error:preparing', err));
this.messaging.once('ready', () => {
this.emit('ready');
});
this.messaging.on('capabilitiesNotified', () => this.emit('capabilitiesNotified'));
// Room widgets get locked to the room they were added in
this.messaging.setViewedRoomId(this.room.roomId);
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
this.client.getRooms().forEach((room) => {
// Timelines are most recent last
const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
if (!roomEvent) return; // force later code to think the room is fresh
this.readUpToMap[room.roomId] = roomEvent.getId()!;
});
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(RoomStateEvent.Events, this.onStateUpdate);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
/**
* Stops the widget messaging for if it is started. Skips stopping if it is an active
* widget.
* @param opts
*/
public stopMessaging(): void {
if (this.messaging) {
this.messaging.stop();
}
this.messaging = null;
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.off(RoomStateEvent.Events, this.onStateUpdate);
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
// Clear internal state
this.readUpToMap = {};
this.eventsToFeed = new WeakSet<MatrixEvent>();
}
private onEvent = (ev: MatrixEvent): void => {
this.client.decryptEventIfNeeded(ev);
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent): void => {
this.feedEvent(ev);
};
private onStateUpdate = (ev: MatrixEvent): void => {
if (this.messaging === null) return;
const raw = ev.getEffectiveEvent();
this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => {
logger.error('Error sending state update to widget: ', e);
});
};
private onToDeviceEvent = async (ev: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
};
/**
* Determines whether the event has a relation to an unknown parent.
*/
private relatesToUnknown(ev: MatrixEvent): boolean {
// Replies to unknown events don't count
if (!ev.relationEventId || ev.replyEventId) return false;
const room = this.client.getRoom(ev.getRoomId());
return room === null || !room.findEventById(ev.relationEventId);
}
/**
* Advances the "read up to" marker for a room to a certain event. No-ops if
* the event is before the marker.
* @returns Whether the "read up to" marker was advanced.
*/
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
const evId = ev.getId();
if (evId === undefined) return false;
const roomId = ev.getRoomId();
if (roomId === undefined) return false;
const room = this.client.getRoom(roomId);
if (room === null) return false;
const upToEventId = this.readUpToMap[ev.getRoomId()!];
if (!upToEventId) {
// There's no marker yet; start it at this event
this.readUpToMap[roomId] = evId;
return true;
}
// Small optimization for exact match (skip the search)
if (upToEventId === evId) return false;
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = room.getLiveTimeline();
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean {
return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId();
}
const possibleMarkerEv = events.find(isRelevantTimelineEvent);
if (possibleMarkerEv?.getId() === upToEventId) {
// The event must be somewhere before the "read up to" marker
return false;
}
if (possibleMarkerEv?.getId() === ev.getId()) {
// The event is after the marker; advance it
this.readUpToMap[roomId] = evId;
return true;
}
// We can't say for sure whether the widget has seen the event; let's
// just assume that it has
return false;
}
/**
* Determines whether the event comes from a room that we've been invited to
* (in which case we likely don't have the full timeline).
*/
private isFromInvite(ev: MatrixEvent): boolean {
const room = this.client.getRoom(ev.getRoomId());
return room?.getMyMembership() === KnownMembership.Invite;
}
private feedEvent(ev: MatrixEvent): void {
if (this.messaging === null) return;
if (
// If we had decided earlier to feed this event to the widget, but
// it just wasn't ready, give it another try
this.eventsToFeed.delete(ev) ||
// Skip marker timeline check for events with relations to unknown parent because these
// events are not added to the timeline here and will be ignored otherwise:
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
this.relatesToUnknown(ev) ||
// Skip marker timeline check for rooms where membership is
// 'invite', otherwise the membership event from the invitation room
// will advance the marker and new state events will not be
// forwarded to the widget.
this.isFromInvite(ev) ||
// Check whether this event would be before or after our "read up to" marker. If it's
// before, or we can't decide, then we assume the widget will have already seen the event.
// If the event is after, or we don't have a marker for the room,
// then the marker will advance and we'll send it through.
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
// receiving ancient events from backfill and such.
this.advanceReadUpToMarker(ev)
) {
// If the event is still being decrypted, remember that we want to
// feed it to the widget (even if not strictly in the order given by
// the timeline) and get back to it later
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
this.eventsToFeed.add(ev);
} else {
const raw = ev.getEffectiveEvent();
this.messaging.feedEvent(raw as IRoomEvent).catch((e) => {
logger.error('Error sending event to widget: ', e);
});
}
}
}
}

View file

@ -0,0 +1,157 @@
import { logger } from 'matrix-js-sdk/lib/logger';
import { EventType } from 'matrix-js-sdk';
import { EventDirection, MatrixCapabilities, WidgetEventCapability } from 'matrix-widget-api';
import EventEmitter from 'events';
import { useEffect } from 'react';
function getCredentials() {
const accessToken = localStorage.getItem('mx_access_token');
const userId = localStorage.getItem('mx_user_id');
const deviceId = localStorage.getItem('mx_device_id');
return [accessToken, userId, deviceId];
}
export async function downloadFromUrlToFile(url: string, filename?: string): Promise<File> {
const [accessToken] = getCredentials();
try {
const response = await fetch(url, {
body: 'blob',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const file = new File([await response.blob()], filename || 'file', {
type: response.type,
});
return file;
} catch (error) {
logger.error('Error downloading file', error);
throw error;
}
}
export type Diff<T> = { added: T[]; removed: T[] };
/**
* Performs a diff on two arrays. The result is what is different with the
* first array (`added` in the returned object means objects in B that aren't
* in A). Shallow comparisons are used to perform the diff.
* @param a The first array. Must be defined.
* @param b The second array. Must be defined.
* @returns The diff between the arrays.
*/
export function arrayDiff<T>(a: T[], b: T[]): Diff<T> {
return {
added: b.filter((i) => !a.includes(i)),
removed: a.filter((i) => !b.includes(i)),
};
}
export default function iterableDiff<T>(
a: Iterable<T>,
b: Iterable<T>
): { added: Iterable<T>; removed: Iterable<T> } {
return arrayDiff(Array.from(a), Array.from(b));
}
/**
* Clones an array as fast as possible, retaining references of the array's values.
* @param a The array to clone. Must be defined.
* @returns A copy of the array.
*/
export function arrayFastClone<T>(a: T[]): T[] {
return a.slice(0, a.length);
}
export function elementCallCapabilities(
inRoomId: string,
clientUserId: string,
clientDeviceId?: string
): Set<string> {
const allowedCapabilities = new Set<string>();
// This is a trusted Element Call widget that we control
const addCapability = (type: string, state: boolean, dir: EventDirection, stateKey?: string) =>
allowedCapabilities.add(
state
? WidgetEventCapability.forStateEvent(dir, type, stateKey).raw
: WidgetEventCapability.forRoomEvent(dir, type).raw
);
const addToDeviceCapability = (eventType: string, dir: EventDirection) =>
allowedCapabilities.add(WidgetEventCapability.forToDeviceEvent(dir, eventType).raw);
const recvState = (eventType: string, stateKey?: string) =>
addCapability(eventType, true, EventDirection.Receive, stateKey);
const sendState = (eventType: string, stateKey?: string) =>
addCapability(eventType, true, EventDirection.Send, stateKey);
const sendRecvToDevice = (eventType: string) => {
addToDeviceCapability(eventType, EventDirection.Receive);
addToDeviceCapability(eventType, EventDirection.Send);
};
const recvRoom = (eventType: string) => addCapability(eventType, false, EventDirection.Receive);
const sendRoom = (eventType: string) => addCapability(eventType, false, EventDirection.Send);
const sendRecvRoom = (eventType: string) => {
recvRoom(eventType);
sendRoom(eventType);
};
allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
recvState(EventType.RoomMember);
recvState('org.matrix.msc3401.call');
recvState(EventType.RoomEncryption);
recvState(EventType.RoomName);
// For the legacy membership type
sendState('org.matrix.msc3401.call.member', clientUserId);
// For the session membership type compliant with MSC4143
sendState('org.matrix.msc3401.call.member', `_${clientUserId}_${clientDeviceId}_m.call`);
sendState('org.matrix.msc3401.call.member', `${clientUserId}_${clientDeviceId}_m.call`);
sendState('org.matrix.msc3401.call.member', `_${clientUserId}_${clientDeviceId}`);
sendState('org.matrix.msc3401.call.member', `${clientUserId}_${clientDeviceId}`);
recvState('org.matrix.msc3401.call.member');
// for determining auth rules specific to the room version
recvState(EventType.RoomCreate);
sendRoom('org.matrix.msc4075.rtc.notification');
sendRecvRoom('io.element.call.encryption_keys');
sendRecvRoom('org.matrix.rageshake_request');
sendRecvRoom(EventType.Reaction);
sendRecvRoom(EventType.RoomRedaction);
sendRecvRoom('io.element.call.reaction');
sendRecvRoom('org.matrix.msc4310.rtc.decline');
sendRecvToDevice(EventType.CallInvite);
sendRecvToDevice(EventType.CallCandidates);
sendRecvToDevice(EventType.CallAnswer);
sendRecvToDevice(EventType.CallHangup);
sendRecvToDevice(EventType.CallReject);
sendRecvToDevice(EventType.CallSelectAnswer);
sendRecvToDevice(EventType.CallNegotiate);
sendRecvToDevice(EventType.CallSDPStreamMetadataChanged);
sendRecvToDevice(EventType.CallSDPStreamMetadataChangedPrefix);
sendRecvToDevice(EventType.CallReplaces);
sendRecvToDevice(EventType.CallEncryptionKeysPrefix);
return allowedCapabilities;
}
// Shortcut for registering a listener on an EventTarget
// Copied from element-web
export function useEventEmitter<T>(
emitter: EventEmitter | null | undefined,
eventType: string,
listener: (event: T) => void
): void {
useEffect((): (() => void) => {
if (emitter) {
emitter.on(eventType, listener);
return (): void => {
emitter.off(eventType, listener);
};
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}, [emitter, eventType, listener]);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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 />}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Box, Text, config } from 'folds';
import { EventType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@ -22,6 +22,7 @@ import { settingsAtom } from '../../state/settings';
import { useSetting } from '../../state/hooks/settings';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { CallView } from '../../components/element-call/CallView';
const FN_KEYS_REGEX = /^F\d+$/;
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
@ -71,6 +72,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const [showCall, setShowCall] = useState(false);
const [callJoined, setCallJoined] = useState(false);
const permissions = useRoomPermissions(creators, powerLevels);
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
@ -93,49 +96,60 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
return (
<Page ref={roomViewRef}>
<RoomViewHeader />
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
<RoomViewHeader onCallClick={() => setShowCall(true)} callJoined={callJoined} />
<Box grow="Yes" direction="Row">
{showCall && (
<CallView
onClose={() => setShowCall(false)}
onJoin={() => setCallJoined(true)}
onHangup={() => setCallJoined(false)}
/>
)}
<Box grow="Yes" direction="Column" style={{ width: 350 }}>
<Box grow="Yes" direction="Column">
<RoomTimeline
key={roomId}
room={room}
eventId={eventId}
roomInputRef={roomInputRef}
editor={editor}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
<RoomViewTyping room={room} />
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
) : (
<>
{canMessage && (
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
{!canMessage && (
<RoomInputPlaceholder
style={{ padding: config.space.S200 }}
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
</RoomInputPlaceholder>
)}
</>
)}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</div>
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
</Box>
</Box>
</Box>
</Page>
);

View file

@ -69,6 +69,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { useCallOngoing } from '../../hooks/useCallOngoing';
type RoomMenuProps = {
room: Room;
@ -253,8 +254,11 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</Menu>
);
});
export function RoomViewHeader() {
interface RoomViewHeaderProps {
onCallClick?: () => void;
callJoined?: boolean;
}
export function RoomViewHeader({ onCallClick, callJoined }: RoomViewHeaderProps) {
const navigate = useNavigate();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@ -274,6 +278,7 @@ export function RoomViewHeader() {
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const callOngoing = useCallOngoing(room);
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
@ -295,6 +300,9 @@ export function RoomViewHeader() {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const notParticipatingState = callOngoing ? 'join' : 'start';
const buttonState = callJoined ? 'participating' : notParticipatingState;
return (
<PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300">
@ -387,6 +395,33 @@ export function RoomViewHeader() {
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{buttonState === 'start' && <Text>Start Call</Text>}
{buttonState === 'join' && <Text>Join Call</Text>}
{buttonState === 'participating' && <Text>Call Ongoing</Text>}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant={buttonState === 'join' ? 'Primary' : undefined}
style={{ position: 'relative' }}
onClick={onCallClick}
ref={triggerRef}
disabled={buttonState === 'participating'}
aria-pressed={!!pinMenuAnchor}
>
{buttonState === 'join' && <Text size="B400">Join</Text>}
<Icon size="400" src={Icons.Phone} filled={buttonState === 'participating'} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}

View file

@ -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>

View file

@ -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&apos;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>
);

View 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>
);
}

View 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);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}
}

View file

@ -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"

View file

@ -0,0 +1,29 @@
import { Room } from 'matrix-js-sdk';
import { useEffect, useState } from 'react';
import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager';
export const useCallOngoing = (room: Room) => {
const [callOngoing, setCallOngoing] = useState(
room.client.matrixRTC.getRoomSession(room).memberships.length > 0
);
useEffect(() => {
const start = (roomId: string) => {
if (roomId !== room.roomId) return;
setCallOngoing(true);
};
const end = (roomId: string) => {
if (roomId !== room.roomId) return;
setCallOngoing(false);
};
room.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, start);
room.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, end);
return () => {
room.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, start);
room.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, end);
};
}, [room]);
return callOngoing;
};

View 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;
}

View file

@ -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>
)}

View file

@ -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[];
};

View file

@ -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];
};

View file

@ -38,6 +38,10 @@ const copyFiles = {
src: 'public/locales',
dest: 'public/',
},
{
src: 'node_modules/@element-hq/element-call-embedded/dist/*',
dest: 'widgets/element-call/',
},
],
};
@ -47,7 +51,10 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) {
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === wasmFilePath) {
const resolvedPath = path.join(path.resolve(), "/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm");
const resolvedPath = path.join(
path.resolve(),
'/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm'
);
if (fs.existsSync(resolvedPath)) {
res.setHeader('Content-Type', 'application/wasm');
@ -102,8 +109,8 @@ export default defineConfig({
},
devOptions: {
enabled: true,
type: 'module'
}
type: 'module',
},
}),
],
optimizeDeps: {