cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

commit 24066930d7e0ef2980607482a3799330a15c95c1
parent 4765cb4f2017b24360080848b0ad65aa7d3f4665
Author: MTRNord <mtrnord1@gmail.com>
Date:   Sun,  7 May 2023 21:18:29 +0200

Add audio rendering, make codeblock work with matrix, improve codeblock rendering in timeline, add crypto dependency patch to CI

Diffstat:
A.github/crypto-dep.patch | 11+++++++++++
M.github/workflows/deploy.yaml | 1+
M.github/workflows/percy.yaml | 2+-
Mpackage-lock.json | 33++++++++++++++++++++++++++++++++-
Mpackage.json | 5++++-
Msrc/App.tsx | 6+++---
Msrc/app/sdk/api/apiTypes.ts | 16++++++++++++++++
Msrc/app/sdk/client.ts | 111+++++++++++++++++--------------------------------------------------------------
Msrc/components/avatar/avatar.tsx | 10++++++----
Asrc/components/events/helpers/Waveform.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/events/messageEvent.tsx | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Asrc/components/input/chat/customNodes/CustomParagraphNode.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/input/chat/input.tsx | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Asrc/components/input/chat/plugins/DebugPlugin.tsx | 25+++++++++++++++++++++++++
Msrc/components/input/chat/plugins/ToolbarPlugin.tsx | 22+++++++++++-----------
Msrc/index.scss | 8++------
Msrc/main.tsx | 2+-
17 files changed, 437 insertions(+), 126 deletions(-)

diff --git a/.github/crypto-dep.patch b/.github/crypto-dep.patch @@ -0,0 +1,11 @@ +--- matrix_sdk_crypto_js.js 2023-05-07 19:00:04.360260024 +0200 ++++ matrix_sdk_crypto_js_fixed.js 2023-05-07 18:59:48.775003117 +0200 +@@ -6444,7 +6444,7 @@ + ); + + let inited = false; +-__initSync = function () { ++const __initSync = function () { + if (inited) { + return; + } diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml @@ -18,6 +18,7 @@ jobs: run: | # Install npm packages and build the Storybook files npm install npx browserslist@latest --update-db + patch node_modules/@matrix-org/matrix-sdk-crypto-js/pkg/matrix_sdk_crypto_js.js < .github/workflows/patch/matrix_sdk_crypto_js.patch npm run build -- --base /cetirizine/ npm run build-storybook -- --disable-telemetry -o dist/storybook diff --git a/.github/workflows/percy.yaml b/.github/workflows/percy.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version: '18' - - run: npm install && npx browserslist@latest --update-db + - run: npm install && npx browserslist@latest --update-db && patch node_modules/@matrix-org/matrix-sdk-crypto-js/pkg/matrix_sdk_crypto_js.js < .github/workflows/patch/matrix_sdk_crypto_js.patch - run: npm run percy env: PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} diff --git a/package-lock.json b/package-lock.json @@ -12,6 +12,7 @@ "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.8", "dompurify": "^3.0.3", "events": "^3.3.0", + "highlight.js": "^11.8.0", "idb": "^7.1.1", "lexical": "^0.10.0", "linkify-html": "^4.1.1", @@ -23,7 +24,8 @@ "react-dom": "^18.2.0", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.11.1", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "wavesurfer.js": "^6.6.3" }, "devDependencies": { "@percy/cli": "^1.24.0", @@ -45,6 +47,7 @@ "@types/node": "^20.1.0", "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", + "@types/wavesurfer.js": "^6.0.5", "@vitejs/plugin-react-swc": "^3.3.0", "autoprefixer": "^10.4.14", "postcss": "^8.4.23", @@ -5432,6 +5435,12 @@ "@types/node": "*" } }, + "node_modules/@types/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==", + "dev": true + }, "node_modules/@types/detect-port": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/detect-port/-/detect-port-1.3.2.tgz", @@ -5753,6 +5762,15 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "node_modules/@types/wavesurfer.js": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-6.0.5.tgz", + "integrity": "sha512-ZAP/2pSyJ9jylK6bqejQTFZNJ8h5Ds1i7uaT0/22xsHSAiji83rxZcT92cNwomZ+mDpS++G9yXEU8CN2MYidSA==", + "dev": true, + "dependencies": { + "@types/debounce": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -8598,6 +8616,14 @@ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "dev": true }, + "node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -13873,6 +13899,11 @@ "node": ">=10.13.0" } }, + "node_modules/wavesurfer.js": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-6.6.3.tgz", + "integrity": "sha512-XqUOXe8+SOTe8uKCHaqW0vJ5etCCQvq/NgaPycn9HAX/nUi+2zoWD+w9i7H5vBT9UCDNawOia+vS5Ct3kZGQzA==" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json @@ -8,6 +8,7 @@ "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.8", "dompurify": "^3.0.3", "events": "^3.3.0", + "highlight.js": "^11.8.0", "idb": "^7.1.1", "lexical": "^0.10.0", "linkify-html": "^4.1.1", @@ -19,7 +20,8 @@ "react-dom": "^18.2.0", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.11.1", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "wavesurfer.js": "^6.6.3" }, "scripts": { "start": "vite", @@ -77,6 +79,7 @@ "@types/node": "^20.1.0", "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", + "@types/wavesurfer.js": "^6.0.5", "@vitejs/plugin-react-swc": "^3.3.0", "autoprefixer": "^10.4.14", "postcss": "^8.4.23", diff --git a/src/App.tsx b/src/App.tsx @@ -1,5 +1,5 @@ import { - BrowserRouter, + HashRouter, Route, Routes, } from "react-router-dom"; @@ -9,12 +9,12 @@ import { ProtectedRoute } from './app/protectedRoute'; import { memo } from "react"; function App() { - return <BrowserRouter basename={import.meta.env.BASE_URL}> + return <HashRouter basename={import.meta.env.BASE_URL}> <Routes> <Route path="/:roomIdOrAlias?" element={<ProtectedRoute><MainPage /></ProtectedRoute>} /> <Route path="/login" element={<LoginPage />} /> </Routes> - </BrowserRouter>; + </HashRouter>; } export default memo(App); diff --git a/src/app/sdk/api/apiTypes.ts b/src/app/sdk/api/apiTypes.ts @@ -317,6 +317,12 @@ export interface IRoomMessageImageContent extends IRoomMessageContent<"m.image"> file?: IEncryptedFile; } +export interface IRoomMessageAudioContent extends IRoomMessageContent<"m.audio"> { + info?: IAudioInfo; + url?: string; + file?: IEncryptedFile; +} + export interface IRoomMessageEvent<Content = any> extends IRoomEvent<Content> { } export interface IRoomMessageTextEvent extends IRoomMessageEvent<IRoomMessageTextContent> { } @@ -329,6 +335,10 @@ export function isRoomMessageImageEvent(event: IRoomEvent): event is IRoomMessag return event.type === "m.room.message" && event.content.msgtype === "m.image"; } +export function isRoomMessageAudioEvent(event: IRoomEvent): event is IRoomMessageEvent<IRoomMessageAudioContent> { + return event.type === "m.room.message" && event.content.msgtype === "m.audio"; +} + export function isRoomMessageEvent(event: IRoomEvent): event is IRoomMessageEvent { return event.type === "m.room.message"; } @@ -405,6 +415,12 @@ export interface IImageInfo { w: number; } +export interface IAudioInfo { + duration?: number; + mimetype?: string; + size?: number; +} + export interface IRoomAvatarContent { info: IImageInfo; url?: string; diff --git a/src/app/sdk/client.ts b/src/app/sdk/client.ts @@ -338,13 +338,14 @@ export class MatrixClient extends EventEmitter { max = Number(n); } } - const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === index); - if (roomObj) { - const tx = this.database?.transaction('rooms', 'readwrite'); - await tx?.store.delete(roomObj.roomID); - await tx?.done; - this.rooms.delete(roomObj) - } + // TODO: Unclear if this is needed or working. Probably wrong? + // const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === index); + // if (roomObj) { + // const tx = this.database?.transaction('rooms', 'readwrite'); + // await tx?.store.delete(roomObj.roomID); + // await tx?.done; + // this.rooms.delete(roomObj) + // } if (max < 0 || index > max) { return; } @@ -550,7 +551,6 @@ export class MatrixClient extends EventEmitter { } console.error("Failed to send to device", response); } - console.log(request_typed); this.olmMachine.markRequestAsSent(request_typed.id ?? request_typed.txn_id, request_typed.type, await response.text()); } else if (request.type === RequestType.SignatureUpload) { const request_typed = request as SignatureUploadRequest; @@ -639,7 +639,7 @@ export class MatrixClient extends EventEmitter { }; for (const space of this.spaceOpen) { if (space === "other") { continue } - lists_ranges[space] = [[0, 50]]; + lists_ranges[space] = [[0, 20]]; } let timeline_limit = 1; @@ -655,79 +655,14 @@ export class MatrixClient extends EventEmitter { .filter(room => this.roomsInView.includes(room.roomID)) .map(room => room.windowPos[list]).sort().filter(x => x !== undefined && x !== null)) - if (list === "spaces") { + if (this.getSpaces().find(r => r.roomID === list)) { // If we are syncing the spaces list, we need to use the spaceInView list instead rawRangeInView = new Set([...this.rooms] .filter(room => this.spacesInView.includes(room.roomID)) .map(room => room.windowPos[list]).sort().filter(x => x !== undefined && x !== null)) } - - // if (rawRangeInView.size === 0) { - // // If there are no rooms in view, we can skip this list - // continue; - // } - - // // Increment range by 1 to make sure we always get a little more than we need - // // [1,2,3,4,7,8,9,10,11] -> [2,3,4,5,8,9,10,11,16] - // for (const [i, v] of rawRangeInView.entries()) { - // if (i % 2 === 0) { - // rawRangeInView.add(v + 5); - // } - // } - - // // Turn an input like [1,2,3,4,7,8,9,10,11] to [[1,4], [7,11]] - // const rangesInView = [...rawRangeInView].reduce((acc, cur, i, arr) => { - // if (i === 0) { - // // [1,2,3,4] -> [[1,1]] - // acc.push([cur, cur]); - // return acc; - // } - // // Cur = 2, arr = [1,2,3,4], arr[i - 1] + 1 = 2 then - // if (cur === arr[i - 1] + 1) { - // // [1,2,3,4,7] -> [[1,2]] - // acc[acc.length - 1][1] = cur; - // return acc; - // } - // // Else [1,2,3,4,7] -> [[1,2], [7,7]] - // acc.push([cur, cur]); - // return acc; - // }, [] as [number, number][]); - - // // Sort by the first element of each range and add to the object - // const sorted = rangesInView.sort((a, b) => a[0] - b[0]); - - // const deduped = []; - // deduped.push(sorted[0]); - // for (let i = 1; i < sorted.length; i++) { - // let ok; - // for (let j = 0; j < i; j++) { - // if (deduped[j].length != sorted[i].length) { - // continue - // } - // ok = false; - // for (let k = 0; k < sorted[i].length; k++) { - // if (sorted[i][k] != sorted[j][k]) { - // ok = true - // } - // }; - // if (ok == false) { - // break - // }; - // } - // if (ok) { - // deduped.push(sorted[i]) - // }; - // } - - - // // Deduplicate ranges - // const deduped_final = deduped - // .filter(subarray => subarray.length === 2) - // .filter(subarray => subarray[0] !== undefined && subarray[1] !== undefined && subarray[0] !== null && subarray[1] !== null) - if (rawRangeInView.size !== 0) { - console.log("changed:", rawRangeInView) const minimum = Math.min(...rawRangeInView); const maximum = Math.max(...rawRangeInView); @@ -744,7 +679,6 @@ export class MatrixClient extends EventEmitter { } if (!this.lastRanges) { - console.log(lists_ranges) this.lastRanges = lists_ranges; this.lastTxnID = Date.now().toString(); } @@ -834,7 +768,7 @@ export class MatrixClient extends EventEmitter { }, to_device: { enabled: true, - since: this.to_device_since || null + since: this.to_device_since } }, }; @@ -845,7 +779,7 @@ export class MatrixClient extends EventEmitter { body.lists = {}; } body.lists[space] = { - //slow_get_all_rooms: true, + slow_get_all_rooms: true, ranges: this.lastRanges[space], sort: ["by_notification_level", "by_recency", "by_name"], required_state: [ @@ -1097,16 +1031,17 @@ export class MatrixClient extends EventEmitter { } gapIndex = op.index; } else if (isInvalidateOp(op)) { - const tx = this.database?.transaction('rooms', 'readwrite'); - for (let i = op.range[0]; i <= op.range[1]; i++) { - // We shall first forget about these and "startover" - const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i); - if (roomObj) { - await tx?.store.delete(roomObj.roomID); - this.rooms.delete(roomObj) - } - } - await tx?.done; + // TODO: Figure out if this is needed in reality + // const tx = this.database?.transaction('rooms', 'readwrite'); + // for (let i = op.range[0]; i <= op.range[1]; i++) { + // // We shall first forget about these and "startover" + // const roomObj = [...this.rooms].find(room => room.windowPos[listKey] === i); + // if (roomObj) { + // await tx?.store.delete(roomObj.roomID); + // this.rooms.delete(roomObj) + // } + // } + // await tx?.done; } } if (gapIndex !== -1) { diff --git a/src/components/avatar/avatar.tsx b/src/components/avatar/avatar.tsx @@ -1,4 +1,4 @@ -import { FC, memo } from "react"; +import { FC, memo, useState } from "react"; type AvatarProps = { /** @@ -20,10 +20,12 @@ type AvatarProps = { }; const Avatar: FC<AvatarProps> = memo(({ avatarUrl, displayname, dm = false, online = false }: AvatarProps) => { - if (avatarUrl) { + const [error, setError] = useState(false); + + if (avatarUrl && !error) { return ( <div className="flex relative min-w-[2rem] min-h-[2rem] w-[2rem] h-[2rem] justify-center items-center m-0 mr-3 text-xl rounded-full text-white"> - <img className="rounded-full w-8 h-8 object-cover" alt={displayname} src={avatarUrl} /> + <img className="rounded-full w-8 h-8 object-cover" alt={displayname} src={avatarUrl} onError={() => { setError(true) }} /> { dm ? ( @@ -38,7 +40,7 @@ const Avatar: FC<AvatarProps> = memo(({ avatarUrl, displayname, dm = false, onli } return ( <div className="flex relative min-w-[2rem] min-h-[2rem] w-[2rem] h-[2rem] bg-orange-500 justify-center items-center m-0 mr-3 text-xl rounded-full text-white"> - {displayname.replace('@', '')[0].toUpperCase()} + {displayname.replace('@', '').replace('!', '').replace('#', '')[0].toUpperCase()} { dm ? ( diff --git a/src/components/events/helpers/Waveform.tsx b/src/components/events/helpers/Waveform.tsx @@ -0,0 +1,100 @@ +import { FC, memo, useEffect, useRef, useState } from "react"; +import { Pause, Play } from 'lucide-react'; +import WaveSurfer from 'wavesurfer.js'; + +type WaveformProps = { + /** + * The media url to render + */ + src_url: string; +}; + +const Waveform: FC<WaveformProps> = memo(({ src_url }) => { + const [playing, setPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const [waveform, setWaveform] = useState<WaveSurfer | undefined>(undefined); + const waveformRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (waveformRef.current) { + if (waveformRef.current.children.length > 0) { + if (waveform) { + setWaveform(prev => { + prev?.destroy(); + if (waveformRef.current) { + for (const child of waveformRef.current.children) { + child.remove(); + } + } + return undefined; + }); + } + } + if (waveformRef.current.children.length === 0) { + setWaveform(WaveSurfer.create({ + barWidth: 5, + cursorWidth: 1, + container: waveformRef.current, + backend: 'WebAudio', + height: 45, + progressColor: '#f59e0b', + responsive: true, + waveColor: '#cbd5e1', + cursorColor: 'transparent', + barRadius: 5, + hideScrollbar: true, + normalize: true, + })); + } + } + }, []); + + + useEffect(() => { + if (waveform) { + waveform.load(src_url); + waveform.on('ready', () => { + setDuration(waveform.getDuration()); + }); + } + }, [waveform]) + + const handlePlay = () => { + if (waveform) { + setPlaying((prev) => !prev); + waveform.playPause(); + } + }; + + return ( + <div className="flex w-[min-content] flex-row h-full items-start justify-start gap-3 bg-slate-200 rounded border-2 border-slate-500 p-1"> + <button className="text-center flex justify-center items-center w-12 h-12 min-w-12 min-h-12 rounded-full outline-none cursor-pointer pb-1 bg-orange-500 border-orange-600 border hover:bg-orange-600 group" onClick={handlePlay}> + {!playing ? <Play viewBox="0 0 21 24" className="stroke-slate-50 group-hover:stroke-slate-100" /> : <Pause size={24} className="stroke-slate-700 group-hover:stroke-slate-600" />} + </button> + <div className="flex flex-col w-96 gap-2 justify-between"> + <div className="w-full h-12" id="waveform" ref={waveformRef}></div> + <span className="w-full text-sm font-medium text-slate-600 text-right">{secondsToHms(duration)}</span> + </div> + </div> + ); +}) + +function secondsToHms(d?: number) { + if (!d) { + return "00:00"; + } + const h = Math.floor(d / 3600); + const m = Math.floor(d % 3600 / 60); + const s = Math.floor(d % 3600 % 60); + + if (h === 0 && m === 0 && s === 0) { + return "00:00"; + } else if (h === 0 && m === 0) { + return `00:${s.toString().padStart(2, "0")}`; + } else if (h === 0) { + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } + return `${h}:${m}:${s}`; +} + +export default Waveform; +\ No newline at end of file diff --git a/src/components/events/messageEvent.tsx b/src/components/events/messageEvent.tsx @@ -1,5 +1,5 @@ import { memo, useContext, useEffect, useState } from "react"; -import { IRoomEvent, isRoomMessageImageEvent, isRoomMessageTextEvent } from "../../app/sdk/api/apiTypes"; +import { IRoomEvent, isRoomMessageAudioEvent, isRoomMessageImageEvent, isRoomMessageTextEvent } from "../../app/sdk/api/apiTypes"; import { FC } from "react"; import Avatar from "../avatar/avatar"; import { MatrixContext, useRoom } from "../../app/sdk/client"; @@ -8,6 +8,9 @@ import linkifyHtml from 'linkify-html'; import DOMPurify from "dompurify"; import { UndecryptableEvent } from "./unknownEvent"; import { decryptAttachment } from "matrix-encrypt-attachment"; +import Waveform from './helpers/Waveform'; +import hljs from 'highlight.js'; +import 'highlight.js/styles/base16/solarized-dark.css'; type MessageEventProps = { /** @@ -36,8 +39,10 @@ const linkifyOptions = { } const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEvent }) => { + const client = useContext(MatrixContext); const room = useRoom(roomID); + const renderCorrectMessage = (event: IRoomEvent) => { if (isRoomMessageTextEvent(event)) { if (event.content.format === "org.matrix.custom.html") { @@ -83,7 +88,22 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv "summary" ] }) - const linkified = linkifyHtml(sanitized, linkifyOptions); + let linkified = linkifyHtml(sanitized, linkifyOptions); + // Extract code and language from the html + const codeRegex = /<pre><code (?:class="language-(?<language>.*?)")?.*?>(?<code>[\s\S]*?)<\/code><\/pre>/; + const code = codeRegex.exec(linkified); + + if (code?.groups?.["code"]) { + if (code.groups?.["language"]) { + // Highlight the code + const highlighted = hljs.highlight(code.groups?.["code"], { language: code.groups?.["language"] }).value; + linkified = linkified.replace(code.groups?.["code"], `${highlighted}`); + } else { + // Highlight the code + const highlighted = hljs.highlightAuto(code.groups?.["code"]).value; + linkified = linkified.replace(code[0], `${highlighted}`); + } + } // TODO: sanitize the attributes allowed by matrix spec return ( @@ -118,7 +138,6 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv ) } } else if (isRoomMessageImageEvent(event)) { - const client = useContext(MatrixContext); const [url, setUrl] = useState<string | undefined>(undefined); const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); @@ -196,6 +215,78 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv </div> </div> ) + } else if (isRoomMessageAudioEvent(event)) { + const [url, setUrl] = useState<string | undefined>(undefined); + const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); + + const decryptAudio = (event: IRoomEvent) => { + console.log("Downloading audio file:", event.event_id); + fetch(client.convertMXC(event.content.file.url), { + headers: { + Authorization: `Bearer ${client.accessToken}` + } + }).then((response) => { + if (!response.ok) { + // TODO: display error? + console.log("Unable to decrypt audio file:", response.text()); + return; + } + console.log("Downloaded audio file:", event.event_id); + response.arrayBuffer().then((responseData) => { + // Decrypt the array buffer using the information taken from the event content. + decryptAttachment(responseData, event.content.file).then((dataArray) => { + // Turn the array into a Blob and give it the correct MIME-type. + + // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise + // they introduce XSS attacks if the Blob URI is viewed directly in the + // browser (e.g. by copying the URI into a new tab or window.) + // See warning at top of file. + let mimetype = event.content.info?.mimetype ? event.content.info.mimetype.split(";")[0].trim() : ""; + mimetype = getBlobSafeMimeType(mimetype); + + const blob = new Blob([dataArray], { type: mimetype }); + setUrl(URL.createObjectURL(blob)); + setUnableToDecrypt(false); + console.log("Decrypted audio file:", event.event_id); + }).catch((e: any) => { + console.log("Unable to decrypt audio file due to decryption error:", e); + setUnableToDecrypt(true); + }); + }); + }); + } + + useEffect(() => { + if (isRoomMessageAudioEvent(event)) { + if (event.content.url) { + setUrl(client.convertMXC(event.content.url)); + } else { + // Audio is encrypted and we need to download and decrypt it + if (event.content.file) { + decryptAudio(event); + } + } + } + }, [event]) + + if (unableToDecrypt) { + return (<UndecryptableEvent event={event} roomID={roomID} hasPreviousEvent={hasPreviousEvent} />) + } + + return ( + <div className={!hasPreviousEvent ? "flex flex-row gap-4 p-2 pb-1 hover:bg-gray-200 rounded-md duration-200 ease-in-out items-start" : "flex flex-row p-2 pb-1 pt-1 hover:bg-gray-200 rounded-md duration-200 ease-in-out"}> + {!hasPreviousEvent && <Avatar + displayname={room?.getMemberName(event.sender) || event.sender} + avatarUrl={room?.getMemberAvatar(event.sender)} + online={room?.isOnline() || false} + dm={room?.isDM() || false} + />} + <div className={!hasPreviousEvent ? "flex flex-col gap-1" : "flex-1 ml-[3.7rem]"}> + {!hasPreviousEvent && <h2 className="text-sm font-medium text-red-500 whitespace-pre-wrap">{room?.getMemberName(event.sender) || event.sender}</h2>} + {url && <Waveform src_url={url} />} + </div> + </div> + ) } else { return ( <div className={!hasPreviousEvent ? "flex flex-row gap-4 p-2 pb-1 hover:bg-gray-200 rounded-md duration-200 ease-in-out items-start" : "flex flex-row p-2 pb-1 pt-0 hover:bg-gray-200 rounded-md duration-200 ease-in-out"}> diff --git a/src/components/input/chat/customNodes/CustomParagraphNode.ts b/src/components/input/chat/customNodes/CustomParagraphNode.ts @@ -0,0 +1,45 @@ +import { $applyNodeReplacement, $getRoot, DOMExportOutput, LexicalEditor, ParagraphNode, SerializedElementNode } from "lexical"; + +export class CustomParagraphNode extends ParagraphNode { + static getType(): string { + return "custom-paragraph"; + } + + static clone(node: CustomParagraphNode): CustomParagraphNode { + return new CustomParagraphNode(node.__key); + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const { element } = super.exportDOM(editor); + + if (this.isEmpty()) { + const root = $getRoot(); + let foundFurtherText = false; + for (const child of root.getChildren()) { + if (child === this) { + continue; + } + if (child instanceof CustomParagraphNode || child instanceof ParagraphNode) { + if (!child.isEmpty()) { + foundFurtherText = true; + } + } + } + if (!foundFurtherText) { + return { element: null }; + } + } + + return { element }; + } + + exportJSON(): SerializedElementNode { + const node = super.exportJSON(); + node.type = "custom-paragraph"; + return node; + } +} + +export function $createCustomParagraphNode(): CustomParagraphNode { + return $applyNodeReplacement(new CustomParagraphNode()); +} +\ No newline at end of file diff --git a/src/components/input/chat/input.tsx b/src/components/input/chat/input.tsx @@ -18,6 +18,8 @@ import { TRANSFORMERS, $convertToMarkdownString } from "@lexical/markdown"; import AutoLinkPlugin from "./plugins/AutoLinkPlugin"; import ToolbarPlugin from "./plugins/ToolbarPlugin"; +//import TreeViewPlugin from "./plugins/DebugPlugin"; +import { CustomParagraphNode } from './customNodes/CustomParagraphNode'; import CodeHighlightPlugin from './plugins/CodeHighlightPlugin'; import EditorTheme from './theme'; @@ -26,7 +28,7 @@ import { FC, memo, useEffect, useState } from 'react'; import { Send } from 'lucide-react'; import { useRoom } from '../../../app/sdk/client'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { CLEAR_EDITOR_COMMAND } from 'lexical'; +import { CLEAR_EDITOR_COMMAND, ParagraphNode } from 'lexical'; import { useLocation } from 'react-router-dom'; type ChatInputProps = { @@ -57,9 +59,17 @@ type SendButtonProps = { * The plain text message */ plainMessage: string + /** + * Callback when sending starts + */ + onStartSending: () => void + /** + * Callback when sending stops + */ + onStopSending: () => void }; -const SendButton: FC<SendButtonProps> = memo(({ roomID, htmlMessage, plainMessage }: SendButtonProps) => { +const SendButton: FC<SendButtonProps> = memo(({ roomID, htmlMessage, plainMessage, onStartSending, onStopSending }: SendButtonProps) => { const room = useRoom(roomID || ""); const [editor] = useLexicalComposerContext(); @@ -68,12 +78,14 @@ const SendButton: FC<SendButtonProps> = memo(({ roomID, htmlMessage, plainMessag if (!room) { return; } - console.log("Sending message to: ", roomID) // TODO: local echo - if (htmlMessage === "" && plainMessage === "") { + if ((htmlMessage === "" && plainMessage === "") || htmlMessage === '<p class="editor-paragraph"><br></p>') { return; } + onStartSending(); + console.log("Sending message to: ", roomID) + if (htmlMessage !== '<p class="editor-paragraph"><br></p>') { try { await room.sendHtmlMessage(htmlMessage, plainMessage); @@ -91,6 +103,7 @@ const SendButton: FC<SendButtonProps> = memo(({ roomID, htmlMessage, plainMessag console.log(e); } } + onStopSending(); }} /> }); @@ -132,11 +145,13 @@ const RoomChangePlugin: FC<RoomChangeProps> = ({ roomID }) => { const ChatInput: FC<ChatInputProps> = memo(({ namespace, roomID }: ChatInputProps) => { const [htmlMessage, setHtmlMessage] = useState<string>(""); const [plainMessage, setPlainMessage] = useState<string>(""); + const [sending, setSending] = useState(false); const initialConfig: InitialConfigType = { namespace: namespace, theme: EditorTheme, onError: (e) => console.error(e), + editable: !sending, nodes: [ HeadingNode, ListNode, @@ -148,7 +163,14 @@ const ChatInput: FC<ChatInputProps> = memo(({ namespace, roomID }: ChatInputProp TableCellNode, TableRowNode, AutoLinkNode, - LinkNode + LinkNode, + CustomParagraphNode, + { + replace: ParagraphNode, + with: (_node: ParagraphNode) => { + return new CustomParagraphNode(); + } + } ] } return ( @@ -166,8 +188,38 @@ const ChatInput: FC<ChatInputProps> = memo(({ namespace, roomID }: ChatInputProp // Convert editor state to both html and markdown. // If there is no formatting then just use the plain text. editorState.read(() => { - const html = $generateHtmlFromNodes(editor); + let html = $generateHtmlFromNodes(editor); // TODO: Make sure that we strip any non matrix stuff + const codeRegex = /(?<all><code .* (?:data-highlight-language="(?<language>.*?)")(?: .*?)?>(?<code>[\s\S]*?)<\/code>)/; + let matched = codeRegex.exec(html); + while (matched !== null) { + if (matched) { + const { groups } = matched; + if (groups) { + let { all, language, code } = groups; + + const spanRegex = /<span(?: class=".*?")?>(?<content>.*?)<\/span>/; + let matchedspans = spanRegex.exec(code); + while (matchedspans !== null) { + if (matchedspans) { + const { groups } = matchedspans; + if (groups) { + const { content } = groups; + + code = code.replaceAll(matchedspans[0], content) + } + } + matchedspans = spanRegex.exec(code) + } + + code = code.replaceAll("<br>", "\n") + html = html.replace(all, `<pre><code class="language-${language}">${code}</code></pre>`) + + + } + } + matched = codeRegex.exec(html) + } setHtmlMessage(html); const markdown = $convertToMarkdownString(TRANSFORMERS); setPlainMessage(markdown); @@ -181,9 +233,10 @@ const ChatInput: FC<ChatInputProps> = memo(({ namespace, roomID }: ChatInputProp <MarkdownShortcutPlugin transformers={TRANSFORMERS} /> <ClearEditorPlugin /> <RoomChangePlugin roomID={roomID} /> + {/*<TreeViewPlugin />*/} </div> </div> - <SendButton roomID={roomID} htmlMessage={htmlMessage} plainMessage={plainMessage} /> + <SendButton roomID={roomID} htmlMessage={htmlMessage} plainMessage={plainMessage} onStartSending={() => { console.log("Sending"); setSending(true) }} onStopSending={() => { setSending(false) }} /> </LexicalComposer> </div> diff --git a/src/components/input/chat/plugins/DebugPlugin.tsx b/src/components/input/chat/plugins/DebugPlugin.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license + * + */ + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { TreeView } from '@lexical/react/LexicalTreeView'; + +export default function TreeViewPlugin(): JSX.Element { + const [editor] = useLexicalComposerContext(); + return ( + <TreeView + viewClassName="tree-view-output" + treeTypeButtonClassName="debug-treetype-button" + timeTravelPanelClassName="debug-timetravel-panel" + timeTravelButtonClassName="debug-timetravel-button" + timeTravelPanelSliderClassName="debug-timetravel-panel-slider" + timeTravelPanelButtonClassName="debug-timetravel-panel-button" + editor={editor} + /> + ); +} +\ No newline at end of file diff --git a/src/components/input/chat/plugins/ToolbarPlugin.tsx b/src/components/input/chat/plugins/ToolbarPlugin.tsx @@ -22,7 +22,6 @@ import { FORMAT_TEXT_COMMAND, $getSelection, $isRangeSelection, - $createParagraphNode, $getNodeByKey, LexicalEditor, RangeSelection, @@ -57,11 +56,12 @@ import { } from "@lexical/code"; import { Bold, ChevronDown, Code, Heading1, Heading2, Heading4, Heading5, Italic, Link, List, ListOrdered, Quote, Redo, Strikethrough, Text, Underline, Undo } from "lucide-react"; import { Heading3 } from "lucide-react"; +import { $createCustomParagraphNode } from "../customNodes/CustomParagraphNode"; const LowPriority = 1; const supportedBlockTypes = new Set([ - "paragraph", + "custom-paragraph", "quote", "code", "h1", @@ -71,27 +71,27 @@ const supportedBlockTypes = new Set([ ]); interface BlockTypes { - code: string, + "code": string, h1: string, h2: string, h3: string, h4: string, h5: string, ol: string, - paragraph: string, + "custom-paragraph": string, quote: string, ul: string } const blockTypeToBlockName: BlockTypes = { - code: "Code Block", + "code": "Code Block", h1: "Large Heading", h2: "Small Heading", h3: "Heading", h4: "Heading", h5: "Heading", ol: "Numbered List", - paragraph: "Normal", + "custom-paragraph": "Normal", quote: "Quote", ul: "Bulleted List" } as const; @@ -313,12 +313,12 @@ const BlockOptionsDropdownList = memo(({ }, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]); const formatParagraph = () => { - if (blockType !== "paragraph") { + if (blockType !== "custom-paragraph") { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { - $wrapNodes(selection, () => $createParagraphNode()); + $wrapNodes(selection, () => $createCustomParagraphNode()); } }); } @@ -400,7 +400,7 @@ const BlockOptionsDropdownList = memo(({ <button className="item" onClick={formatParagraph}> <Text className="icon" size={20} /> <span className="text">Normal</span> - {blockType === "paragraph" && <span className="active" />} + {blockType === "custom-paragraph" && <span className="active" />} </button> <button className="item" onClick={formatLargeHeading}> <Heading1 className="icon" size={20} /> @@ -441,7 +441,7 @@ const ToolbarPlugin = memo(() => { const toolbarRef = useRef(null); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); - const [blockType, setBlockType] = useState<BlockType>("paragraph"); + const [blockType, setBlockType] = useState<BlockType>("custom-paragraph"); const [selectedElementKey, setSelectedElementKey] = useState(null); const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState( false @@ -613,7 +613,7 @@ const ToolbarPlugin = memo(() => { <Heading5 className="icon" size={20} /> : blockType === "code" ? <Code className="icon" size={20} /> : - blockType === "paragraph" ? + blockType === "custom-paragraph" ? <Text className="icon" size={20} /> : blockType === "ol" ? <ListOrdered className="icon" size={20} /> : diff --git a/src/index.scss b/src/index.scss @@ -13,14 +13,10 @@ code { } pre { - color: #F5F7FA; - background-color: #1F2933; - - @apply rounded p-2; + @apply rounded p-2 bg-slate-200 border-2 border-slate-500 min-w-[28.5rem] whitespace-pre-wrap; code { - color: #F5F7FA; - @apply p-0 bg-transparent; + @apply p-0 bg-transparent text-black; } } diff --git a/src/main.tsx b/src/main.tsx @@ -6,7 +6,7 @@ import './index.scss'; initAsync().then(() => { if (Tracing.isAvailable()) { console.log("Tracing is available, turning on"); - let tracing = new Tracing(LoggerLevel.Trace); + let tracing = new Tracing(LoggerLevel.Info); tracing.turnOn(); } start();