diff --git a/netlify.toml b/netlify.toml index a8710303..ff4a38e1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -29,6 +29,11 @@ to = "/assets/:splat" status = 200 +[[redirects]] + from = "/widgets/*" + to = "/widgets/:splat" + status = 200 + [[redirects]] from = "/*" to = "/index.html" diff --git a/package-lock.json b/package-lock.json index 158a6112..1d45ddde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 7316dcce..1c6d6ea6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.10.2", + "version": "4.10.2-minty", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -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", diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..ef8d01a1 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -27,6 +27,7 @@ import { useTextAreaCodeEditor } from '../hooks/useTextAreaCodeEditor'; const EDITOR_INTENT_SPACE_COUNT = 2; export type AccountDataSubmitCallback = (type: string, content: object) => Promise; +export type AccountDataDeleteCallback = (type: string) => Promise; 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} > - Account Data + Field Name 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(useCallback( + async () => { + if (submitDelete !== undefined) { + await submitDelete(type); + requestClose(); + } + }, + [type, submitDelete, requestClose], + )); + const deleting = deleteState.status === AsyncStatus.Loading; + return ( - Account Data + Field Name - + {onEdit && ( + + )} + {submitDelete && ( + + )} JSON Content @@ -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({ @@ -301,7 +330,7 @@ export function AccountDataEditor({ - {edit ? ( + {(edit && submitChange) ? ( setEdit(true)} + requestClose={requestClose} + onEdit={submitChange ? () => setEdit(true) : undefined} + submitDelete={submitDelete} /> )} diff --git a/src/app/components/CollapsibleCard.tsx b/src/app/components/CollapsibleCard.tsx new file mode 100644 index 00000000..95c02964 --- /dev/null +++ b/src/app/components/CollapsibleCard.tsx @@ -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 ( + + setExpand(!expand)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expand ? 'Collapse' : 'Expand'} + + } + /> + {expand && children} + + ); +} diff --git a/src/app/components/element-call/CallView.tsx b/src/app/components/element-call/CallView.tsx new file mode 100644 index 00000000..7aff6f49 --- /dev/null +++ b/src/app/components/element-call/CallView.tsx @@ -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(null); + + // Model state + const [elementCall, setElementCall] = useState(); + const [widgetApi, setWidgetApi] = useState(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 ( +
+ {/* Exit button for lobby state */} + {state === State.Lobby && ( + + )} +