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:
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();