Compare commits
9 Commits
579449acc3
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| f589182709 | |||
| ef573376ac | |||
| 34d9272790 | |||
| 96f7187031 | |||
| 664dcd4cd8 | |||
| 7f960b026b | |||
| 992d2b83b3 | |||
| a9505ca5b2 | |||
| dca51a41ef |
+5
-1
@@ -37,6 +37,10 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
||||
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
||||
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
||||
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
||||
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||
|
||||
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||
|
||||
@@ -146,7 +150,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||
|
||||
### Dependencies & Build
|
||||
|
||||
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
|
||||
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~ — **done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
|
||||
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
||||
|
||||
### Code Hygiene / DevEx
|
||||
|
||||
Generated
+20
-40
@@ -24,7 +24,6 @@
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
@@ -36,7 +35,6 @@
|
||||
"dayjs": "1.11.20",
|
||||
"deepfilternet3-noise-filter": "1.2.1",
|
||||
"domhandler": "6.0.1",
|
||||
"dompurify": "3.4.5",
|
||||
"emojibase": "17.0.0",
|
||||
"emojibase-data": "17.0.0",
|
||||
"file-saver": "2.0.5",
|
||||
@@ -54,7 +52,7 @@
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
@@ -75,7 +73,8 @@
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.124.2",
|
||||
"styled-components": "6.4.2",
|
||||
"ua-parser-js": "2.0.10"
|
||||
"ua-parser-js": "2.0.10",
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
@@ -2697,9 +2696,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "18.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
||||
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
||||
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -3920,16 +3919,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4051,7 +4040,7 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ua-parser-js": {
|
||||
"version": "0.7.39",
|
||||
@@ -5550,12 +5539,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-commit-types": {
|
||||
@@ -6196,15 +6189,6 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -9971,16 +9955,16 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "41.6.0-rc.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz",
|
||||
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==",
|
||||
"version": "41.7.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
||||
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"content-type": "^2.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
@@ -13228,7 +13212,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
||||
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-expiration": {
|
||||
@@ -13269,7 +13252,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
||||
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.1",
|
||||
@@ -13306,7 +13288,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
||||
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.1"
|
||||
@@ -13316,7 +13297,6 @@
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
||||
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"workbox-core": "7.4.1"
|
||||
|
||||
+3
-4
@@ -49,7 +49,6 @@
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@types/dompurify": "3.2.0",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
@@ -61,7 +60,6 @@
|
||||
"dayjs": "1.11.20",
|
||||
"deepfilternet3-noise-filter": "1.2.1",
|
||||
"domhandler": "6.0.1",
|
||||
"dompurify": "3.4.5",
|
||||
"emojibase": "17.0.0",
|
||||
"emojibase-data": "17.0.0",
|
||||
"file-saver": "2.0.5",
|
||||
@@ -79,7 +77,7 @@
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.6.0-rc.0",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
@@ -100,7 +98,8 @@
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.124.2",
|
||||
"styled-components": "6.4.2",
|
||||
"ua-parser-js": "2.0.10"
|
||||
"ua-parser-js": "2.0.10",
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
|
||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
||||
import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
|
||||
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||
const recentEmoji = useRecentEmoji(mx, 20);
|
||||
|
||||
// Lazily load emojibase data (see plugins/emoji `loadEmojiData`). Until it
|
||||
// resolves, `emojis` is empty and autocomplete matches only custom-emoji
|
||||
// packs; the unicode emoji list fills in once loaded.
|
||||
const [loadedEmojis, setLoadedEmojis] = useState<IEmoji[]>(() => emojis);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEmojiData()
|
||||
// Fresh array reference: loadEmojiData populates the module-level array
|
||||
// IN PLACE, so state set to the same ref would bail out of re-rendering
|
||||
// and the search list would never gain the unicode emojis.
|
||||
.then((loaded) => {
|
||||
if (alive) setLoadedEmojis(loaded.emojis.slice());
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
const list: Array<EmoticonSearchItem> = [];
|
||||
return list.concat(
|
||||
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
||||
emojis,
|
||||
loadedEmojis,
|
||||
);
|
||||
}, [imagePacks]);
|
||||
}, [imagePacks, loadedEmojis]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
searchList,
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Box, config, Icons, Scroll } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
|
||||
/**
|
||||
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||
* `emojis`/`emojiGroups` arrays are populated in place once the promise
|
||||
* resolves; we wrap them in a fresh object on load so React re-renders and the
|
||||
* board fills in. Before that, both are empty and the board shows only custom
|
||||
* image packs / recents (which is fleeting — the load starts on mount).
|
||||
*/
|
||||
const useEmojiData = (): EmojiData => {
|
||||
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEmojiData()
|
||||
// Fresh array references (not just a fresh wrapper): downstream memos
|
||||
// depend on the arrays themselves, which are populated IN PLACE — same
|
||||
// refs would skip recompute and leave emoji search empty until remount.
|
||||
.then((loaded) => {
|
||||
if (alive)
|
||||
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return data;
|
||||
};
|
||||
|
||||
type EmojiGroupItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -75,6 +103,7 @@ const useGroups = (
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const labels = useEmojiGroupLabels();
|
||||
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||
|
||||
const emojiGroupItems = useMemo(() => {
|
||||
const g: EmojiGroupItem[] = [];
|
||||
@@ -99,7 +128,7 @@ const useGroups = (
|
||||
});
|
||||
});
|
||||
|
||||
emojiGroups.forEach((group) => {
|
||||
loadedEmojiGroups.forEach((group) => {
|
||||
g.push({
|
||||
id: group.id,
|
||||
name: labels[group.id],
|
||||
@@ -108,7 +137,7 @@ const useGroups = (
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
||||
|
||||
const stickerGroupItems = useMemo(() => {
|
||||
const g: StickerGroupItem[] = [];
|
||||
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
||||
const usage = ImageUsage.Emoticon;
|
||||
const labels = useEmojiGroupLabels();
|
||||
const icons = useEmojiGroupIcons();
|
||||
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||
|
||||
const packLabels = useMemo(() => {
|
||||
const map = new Map<string, string | undefined>();
|
||||
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
||||
}}
|
||||
>
|
||||
<SidebarDivider />
|
||||
{emojiGroups.map((group) => (
|
||||
{loadedEmojiGroups.map((group) => (
|
||||
<GroupIcon
|
||||
key={group.id}
|
||||
active={activeGroupId === group.id}
|
||||
@@ -409,13 +439,14 @@ export function EmojiBoard({
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||
const renderItem = useItemRenderer(tab);
|
||||
const { emojis: loadedEmojis } = useEmojiData();
|
||||
|
||||
const searchList = useMemo(() => {
|
||||
let list: Array<PackImageReader | IEmoji> = [];
|
||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||
if (emojiTab) list = list.concat(emojis);
|
||||
if (emojiTab) list = list.concat(loadedEmojis);
|
||||
return list;
|
||||
}, [emojiTab, usage, imagePacks]);
|
||||
}, [emojiTab, usage, imagePacks, loadedEmojis]);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
searchList,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
|
||||
import { randomStr } from '../../utils/common';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
|
||||
type RoomSoundboardPackProps = {
|
||||
room: Room;
|
||||
stateKey: string;
|
||||
};
|
||||
|
||||
export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canEdit = permissions.stateEvent(
|
||||
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||
userId,
|
||||
);
|
||||
|
||||
const fallbackPack = useMemo(
|
||||
() => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)),
|
||||
[room.roomId, stateKey],
|
||||
);
|
||||
const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack;
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (content: SoundboardContent) => {
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||
content as never,
|
||||
stateKey,
|
||||
);
|
||||
},
|
||||
[mx, room.roomId, stateKey],
|
||||
);
|
||||
|
||||
return <SoundboardPackEditor pack={pack} canEdit={canEdit} onUpdate={handleUpdate} />;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
PopOut,
|
||||
Spinner,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { EmojiBoard } from '../emoji-board';
|
||||
import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||
import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import {
|
||||
playClipLocally,
|
||||
resolveClipObjectUrl,
|
||||
SOUNDBOARD_ACCEPT,
|
||||
SOUNDBOARD_MAX_CLIP_BYTES,
|
||||
SOUNDBOARD_MAX_CLIPS,
|
||||
} from '../../utils/soundboardClips';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
type ClipDraft = {
|
||||
url: string;
|
||||
body: string;
|
||||
emoji: string;
|
||||
volume: number;
|
||||
info?: SoundboardClip['info'];
|
||||
};
|
||||
|
||||
type SoundboardPackEditorProps = {
|
||||
pack: SoundboardPack;
|
||||
canEdit?: boolean;
|
||||
onUpdate: (content: SoundboardContent) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable single-pack soundboard manager (used by the settings page and the
|
||||
* in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
|
||||
* edit + batched-save pattern, but per-clip fields are name + emoji + volume.
|
||||
*/
|
||||
export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// Staged, unsaved state:
|
||||
const [drafts, setDrafts] = useState<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
|
||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||
const [busyPreview, setBusyPreview] = useState<string>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||
|
||||
const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0;
|
||||
|
||||
const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) =>
|
||||
drafts.get(shortcode) ?? { url: '', ...base };
|
||||
|
||||
const setDraft = (shortcode: string, patch: Partial<ClipDraft>, base: ClipDraft) => {
|
||||
setDrafts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const preview = useCallback(
|
||||
async (id: string, mxc: string, volume: number) => {
|
||||
setBusyPreview(id);
|
||||
try {
|
||||
const url = await resolveClipObjectUrl(mx, mxc);
|
||||
playClipLocally(url, volume / 100);
|
||||
} catch {
|
||||
/* ignore preview errors */
|
||||
} finally {
|
||||
setBusyPreview(undefined);
|
||||
}
|
||||
},
|
||||
[mx],
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const taken = new Set<string>([
|
||||
...existing.map((c) => c.shortcode),
|
||||
...uploads.map((u) => u.shortcode),
|
||||
]);
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
const file = files[i];
|
||||
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
|
||||
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||
}
|
||||
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||
const mxc = res.content_uri;
|
||||
if (!mxc) throw new Error('Upload failed.');
|
||||
const name = file.name.replace(/\.[^/.]+$/, '');
|
||||
const shortcode = uniqueShortcode(name, taken);
|
||||
taken.add(shortcode);
|
||||
setUploads((prev) => [
|
||||
...prev,
|
||||
{
|
||||
shortcode,
|
||||
url: mxc,
|
||||
body: name,
|
||||
emoji: '',
|
||||
volume: 100,
|
||||
info: { mimetype: file.type || undefined, size: file.size },
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[mx, existing, uploads, clipCount],
|
||||
);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const clips: Record<string, SoundboardClip> = {};
|
||||
existing.forEach((c) => {
|
||||
if (deleted.has(c.shortcode)) return;
|
||||
const d = drafts.get(c.shortcode);
|
||||
clips[c.shortcode] = {
|
||||
url: c.url,
|
||||
body: d ? d.body : c.body,
|
||||
emoji: d ? d.emoji || undefined : c.emoji,
|
||||
volume: d ? d.volume : c.volume,
|
||||
info: c.info,
|
||||
};
|
||||
});
|
||||
uploads.forEach((u) => {
|
||||
clips[u.shortcode] = {
|
||||
url: u.url,
|
||||
body: u.body,
|
||||
emoji: u.emoji || undefined,
|
||||
volume: u.volume,
|
||||
info: u.info,
|
||||
};
|
||||
});
|
||||
await onUpdate({ pack: pack.meta.content, clips });
|
||||
setDrafts(new Map());
|
||||
setDeleted(new Set());
|
||||
setUploads([]);
|
||||
}, [existing, deleted, drafts, uploads, onUpdate, pack]),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => {
|
||||
const d = isUpload ? base : draftFor(key, base);
|
||||
const rowVolume = isUpload ? base.volume : d.volume;
|
||||
const rowBody = isUpload ? base.body : d.body;
|
||||
const rowEmoji = isUpload ? base.emoji : d.emoji;
|
||||
const commit = (patch: Partial<ClipDraft>) => {
|
||||
if (isUpload) {
|
||||
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
|
||||
} else {
|
||||
setDraft(key, patch, base);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
background: color.SurfaceVariant.Container,
|
||||
opacity: markedDeleted ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
disabled={busyPreview === key}
|
||||
onClick={() => preview(key, base.url, rowVolume)}
|
||||
aria-label={`Preview ${rowBody}`}
|
||||
>
|
||||
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
disabled={!canEdit || markedDeleted}
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
emojiAnchorRef.current = evt.currentTarget;
|
||||
setEmojiFor(key);
|
||||
}}
|
||||
aria-label="Pick emoji"
|
||||
>
|
||||
<Text size="T400">{rowEmoji || '🔊'}</Text>
|
||||
</IconButton>
|
||||
<Box grow="Yes">
|
||||
<Input
|
||||
variant="Surface"
|
||||
size="300"
|
||||
defaultValue={rowBody}
|
||||
readOnly={!canEdit || markedDeleted}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||
aria-label="Clip name"
|
||||
/>
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
||||
<Icon size="50" src={Icons.VolumeHigh} />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
defaultValue={rowVolume}
|
||||
disabled={!canEdit || markedDeleted}
|
||||
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Clip volume"
|
||||
/>
|
||||
</Box>
|
||||
{canEdit && !isUpload && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant={markedDeleted ? 'Success' : 'Critical'}
|
||||
onClick={() =>
|
||||
setDeleted((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'}
|
||||
>
|
||||
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
|
||||
</IconButton>
|
||||
)}
|
||||
{canEdit && isUpload && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Critical"
|
||||
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
|
||||
aria-label="Remove upload"
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SOUNDBOARD_ACCEPT}
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
|
||||
{canEdit && (
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
<Text size="B300">Upload</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box direction="Column" gap="100">
|
||||
{existing.map((c) =>
|
||||
renderRow(
|
||||
c.shortcode,
|
||||
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
||||
false,
|
||||
deleted.has(c.shortcode),
|
||||
),
|
||||
)}
|
||||
{uploads.map((u) => renderRow(u.shortcode, u, true, false))}
|
||||
{existing.length === 0 && uploads.length === 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{canEdit && dirty && (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
disabled={saving}
|
||||
onClick={() => save()}
|
||||
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
|
||||
>
|
||||
<Text size="B300">Save changes</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setDrafts(new Map());
|
||||
setDeleted(new Set());
|
||||
setUploads([]);
|
||||
setError(undefined);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">Reset</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<PopOut
|
||||
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setEmojiFor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<EmojiBoard
|
||||
imagePackRooms={[]}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={(unicode: string) => {
|
||||
const key = emojiFor;
|
||||
setEmojiFor(undefined);
|
||||
if (!key) return;
|
||||
const up = uploads.find((u) => u.shortcode === key);
|
||||
if (up) {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)),
|
||||
);
|
||||
} else {
|
||||
const c = existing.find((x) => x.shortcode === key);
|
||||
if (c)
|
||||
setDraft(
|
||||
key,
|
||||
{ emoji: unicode },
|
||||
{
|
||||
url: c.url,
|
||||
body: c.body ?? c.shortcode,
|
||||
emoji: c.emoji ?? '',
|
||||
volume: c.volume,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
requestClose={() => setEmojiFor(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</PopOut>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||
|
||||
export function UserSoundboardPack() {
|
||||
const mx = useMatrixClient();
|
||||
const defaultPack = useMemo(
|
||||
() =>
|
||||
new SoundboardPack(
|
||||
mx.getUserId() ?? '',
|
||||
{ pack: { display_name: 'My Soundboard' } },
|
||||
undefined,
|
||||
),
|
||||
[mx],
|
||||
);
|
||||
const pack = useUserSoundboardPack() ?? defaultPack;
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (content: SoundboardContent) => {
|
||||
await mx.setAccountData(
|
||||
AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents,
|
||||
content as never,
|
||||
);
|
||||
},
|
||||
[mx],
|
||||
);
|
||||
|
||||
return <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './SoundboardPackEditor';
|
||||
export * from './RoomSoundboardPack';
|
||||
export * from './UserSoundboardPack';
|
||||
@@ -351,10 +351,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
<ScreenshareAudioButton
|
||||
muted={screenshareAudioMuted}
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
</Box>
|
||||
{!compact && showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
@@ -363,12 +359,20 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
user can stop it; once stopped it hides and can't be restarted. */}
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
control since they're the same concern. */}
|
||||
<ScreenshareAudioButton
|
||||
muted={screenshareAudioMuted}
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
|
||||
@@ -1,220 +1,254 @@
|
||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSoundboard } from '../../hooks/useSoundboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks';
|
||||
import { SoundboardClipReader } from '../../plugins/soundboard';
|
||||
import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import {
|
||||
SOUNDBOARD_ACCEPT,
|
||||
SOUNDBOARD_MAX_CLIPS,
|
||||
playClipLocally,
|
||||
resolveClipObjectUrl,
|
||||
} from '../../utils/soundboardClips';
|
||||
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
||||
|
||||
type CallSoundboardProps = {
|
||||
callEmbed: CallEmbed;
|
||||
};
|
||||
|
||||
type FlatClip = {
|
||||
key: string; // packId|shortcode
|
||||
packId: string;
|
||||
packName: string;
|
||||
clip: SoundboardClipReader;
|
||||
};
|
||||
|
||||
/**
|
||||
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each
|
||||
* clip is published to peers as a separate track by the EC fork
|
||||
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
|
||||
* Clips are uploadable/managed here and synced across devices via the
|
||||
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
|
||||
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
|
||||
* relevant to the call room (the room + parent spaces ∪ the user's personal
|
||||
* pack), just like custom emoji. Playing a clip publishes it into the call via
|
||||
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
|
||||
* A management toggle reveals the pack editors (personal + this room, if
|
||||
* permitted). Space-wide packs are managed from Space settings.
|
||||
*/
|
||||
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { clips, addClip, removeClip } = useSoundboard();
|
||||
const { room } = callEmbed;
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const packRooms = useImagePackRooms(room.roomId, roomToParents);
|
||||
const packs = useRelevantSoundboardPacks(packRooms);
|
||||
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [busyId, setBusyId] = useState<string>();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||
const [error, setError] = useState<string>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const volume = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
packs
|
||||
.map((pack) => ({
|
||||
id: pack.id,
|
||||
name: pack.meta.name ?? 'Soundboard',
|
||||
clips: pack.getClips(),
|
||||
}))
|
||||
.filter((g) => g.clips.length > 0),
|
||||
[packs],
|
||||
);
|
||||
|
||||
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setError(undefined);
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handlePlay = useCallback(
|
||||
async (id: string, mxc: string) => {
|
||||
setBusyId(id);
|
||||
const play = useCallback(
|
||||
async (flat: FlatClip) => {
|
||||
if (playingKey) return; // one at a time (fork also enforces this)
|
||||
setPlayingKey(flat.key);
|
||||
setError(undefined);
|
||||
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||
try {
|
||||
const objectUrl = await resolveClipObjectUrl(mx, mxc);
|
||||
callEmbed.control.injectAudio(objectUrl, volume);
|
||||
playClipLocally(objectUrl, volume);
|
||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||
const vol = (flat.clip.volume / 100) * master;
|
||||
callEmbed.control.injectAudio(url, vol);
|
||||
const audio = playClipLocally(url, vol);
|
||||
if (audio) {
|
||||
audio.addEventListener('ended', done, { once: true });
|
||||
audio.addEventListener('error', done, { once: true });
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
// Safety: clear the guard even if the audio never signals end.
|
||||
window.setTimeout(done, 30_000);
|
||||
} catch {
|
||||
setError('Could not play that clip.');
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
done();
|
||||
}
|
||||
},
|
||||
[mx, callEmbed, volume],
|
||||
);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await addClip(file);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[addClip],
|
||||
[mx, callEmbed, master, playingKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SOUNDBOARD_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
handleFile(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: '320px' }}>
|
||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Soundboard</Text>
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
before={
|
||||
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />
|
||||
}
|
||||
>
|
||||
<Text size="B300">Upload</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
|
||||
{clips.length === 0 ? (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text size="L400">Soundboard</Text>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Text size="T200" priority="300">
|
||||
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
|
||||
Clips sync across your devices.
|
||||
Manage
|
||||
</Text>
|
||||
) : (
|
||||
<Box wrap="Wrap" gap="200">
|
||||
{clips.map((clip) => (
|
||||
<Box
|
||||
key={clip.id}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
disabled={busyId === clip.id}
|
||||
onClick={() => handlePlay(clip.id, clip.url)}
|
||||
before={
|
||||
busyId === clip.id ? (
|
||||
<Spinner size="100" />
|
||||
) : (
|
||||
<Icon size="100" src={Icons.Play} />
|
||||
)
|
||||
}
|
||||
after={
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
removeClip(clip.id);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
|
||||
{clip.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||||
{manage ? (
|
||||
<>
|
||||
<RoomSoundboardPack room={room} stateKey="" />
|
||||
<UserSoundboardPack />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{groups.length === 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||||
a pack in Space settings.
|
||||
</Text>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<Box key={g.id} direction="Column" gap="100">
|
||||
<Text size="L400">{g.name}</Text>
|
||||
<Box wrap="Wrap" gap="200">
|
||||
{g.clips.map((clip) => {
|
||||
const key = `${g.id}|${clip.shortcode}`;
|
||||
const flat: FlatClip = {
|
||||
key,
|
||||
packId: g.id,
|
||||
packName: g.name,
|
||||
clip,
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
as="button"
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="100"
|
||||
disabled={!!playingKey}
|
||||
onClick={() => play(flat)}
|
||||
aria-label={`Play ${clip.name}`}
|
||||
style={{
|
||||
width: toRem(76),
|
||||
height: toRem(76),
|
||||
padding: config.space.S100,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
background:
|
||||
playingKey === key
|
||||
? color.Primary.Container
|
||||
: color.SurfaceVariant.Container,
|
||||
cursor: playingKey ? 'default' : 'pointer',
|
||||
opacity: playingKey && playingKey !== key ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Text size="H4">
|
||||
{playingKey === key ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
clip.emoji || '🔊'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||||
{clip.name}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Soundboard</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Soundboard</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={handleOpen}
|
||||
outlined
|
||||
aria-label="Soundboard"
|
||||
aria-expanded={!!cords}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.BellRing} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</PopOut>
|
||||
</>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={handleOpen}
|
||||
outlined
|
||||
aria-label="Soundboard"
|
||||
aria-expanded={!!cords}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.BellRing} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { RoomSoundboardPack, UserSoundboardPack } from '../../../components/soundboard-pack-view';
|
||||
|
||||
type SoundboardProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Soundboard management page (Room/Space settings). Mirrors the Emojis &
|
||||
* Stickers page: a shared room/space pack (admin-editable, inherited by child
|
||||
* rooms like emoji packs) plus the user's personal pack. A single default room
|
||||
* pack (state key "") is used per room/space.
|
||||
*/
|
||||
export function Soundboard({ requestClose }: SoundboardProps) {
|
||||
const room = useRoom();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text as="h2" size="H3" truncate>
|
||||
Soundboard
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<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="200">
|
||||
<Text size="L400">This room / space (shared)</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Clips here are shared with everyone, and inherited by every room under this space
|
||||
— just like emoji/sticker packs. Only members with permission can edit.
|
||||
</Text>
|
||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Personal</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Your own clips, available in every call and synced across your devices.
|
||||
</Text>
|
||||
<UserSoundboardPack />
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Soundboard';
|
||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { General } from './general';
|
||||
import { Members } from '../common-settings/members';
|
||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||
import { Soundboard } from '../common-settings/soundboard';
|
||||
import { Permissions } from './permissions';
|
||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
@@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.SoundboardPage,
|
||||
name: 'Soundboard',
|
||||
icon: Icons.Bell,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
@@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.SoundboardPage && (
|
||||
<Soundboard requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -49,15 +49,46 @@ export function Room() {
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
// Skip when a composer already consumed Escape (it preventDefaults).
|
||||
if (evt.defaultPrevented) return;
|
||||
// Skip while a thread panel is open: listener registration order
|
||||
// means this can run BEFORE the panel's own Escape handler, and the
|
||||
// user's intent there is "close the panel", not "mark room read".
|
||||
if (activeThreadId) return;
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId, hideActivity],
|
||||
[mx, room.roomId, hideActivity, activeThreadId],
|
||||
),
|
||||
);
|
||||
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
|
||||
// Thread panel and media gallery are mutually exclusive on every screen size:
|
||||
// opening one closes the other. Detect the just-opened transition so whichever
|
||||
// was opened most recently wins.
|
||||
const prevThreadRef = useRef(activeThreadId);
|
||||
const prevGalleryRef = useRef(galleryOpen);
|
||||
useEffect(() => {
|
||||
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
||||
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
||||
if (threadJustOpened && galleryOpen) {
|
||||
setGalleryOpen(false);
|
||||
} else if (galleryJustOpened && activeThreadId) {
|
||||
setActiveThreadId(null);
|
||||
}
|
||||
prevThreadRef.current = activeThreadId;
|
||||
prevGalleryRef.current = galleryOpen;
|
||||
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]);
|
||||
|
||||
// On non-desktop screens at most one right-side panel may show, priority
|
||||
// thread > gallery > members. On desktop thread + members may coexist while
|
||||
// thread + gallery stay mutually exclusive (via the effect above).
|
||||
const isDesktop = screenSize === ScreenSize.Desktop;
|
||||
const showThreadPanel = !callView && Boolean(activeThreadId);
|
||||
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
||||
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen));
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
<Box grow="Yes">
|
||||
@@ -86,7 +117,7 @@ export function Room() {
|
||||
<CallChatView />
|
||||
</>
|
||||
)}
|
||||
{!callView && galleryOpen && (
|
||||
{showGallery && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
@@ -94,7 +125,7 @@ export function Room() {
|
||||
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
{!callView && activeThreadId && (
|
||||
{showThreadPanel && activeThreadId && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
@@ -107,7 +138,7 @@ export function Room() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!callView && isDrawer && (
|
||||
{showMembers && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
|
||||
@@ -679,15 +679,23 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
submit();
|
||||
}
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
// Only consume Escape (and stop it bubbling to the thread panel / room
|
||||
// window handlers) when the composer actually has something to dismiss.
|
||||
// If we did nothing, let Escape propagate so those handlers can run.
|
||||
if (autocompleteQuery) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
setAutocompleteQuery(undefined);
|
||||
return;
|
||||
}
|
||||
setReplyDraft(undefined);
|
||||
if (replyDraft) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
setReplyDraft(undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||||
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
@@ -68,12 +69,17 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
async (msg: ScheduledMessage) => {
|
||||
if (cancelling.has(msg.delayId)) return;
|
||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||
setCancelErrors((prev) => {
|
||||
if (!prev.has(msg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await cancelScheduledMessage(mx, msg.delayId);
|
||||
} catch {
|
||||
// If cancellation fails on the server, still remove locally
|
||||
// since the user intends to remove it
|
||||
} finally {
|
||||
// Only prune local state once the server confirms cancellation. If we
|
||||
// removed it optimistically the still-live delayed event would fire and
|
||||
// the "cancelled" message would send anyway.
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
@@ -85,6 +91,11 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep the item (still cancellable) and surface an inline error; the
|
||||
// delayed event is still scheduled on the server.
|
||||
setCancelErrors((prev) => new Set(prev).add(msg.delayId));
|
||||
} finally {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
@@ -131,41 +142,52 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.delayId}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
direction="Column"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string'
|
||||
? (msg.content.body as string)
|
||||
: '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{cancelErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not cancel this message. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
||||
import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
@@ -28,6 +29,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
@@ -86,35 +88,83 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const modalStyle = useModalStyle(400);
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allRooms = mx
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
||||
const allRooms = useMemo(
|
||||
() =>
|
||||
mx
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
|
||||
[mx],
|
||||
);
|
||||
|
||||
const filtered = query
|
||||
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
|
||||
: allRooms;
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return allRooms;
|
||||
const q = query.toLowerCase();
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
|
||||
/**
|
||||
* Build the content to forward:
|
||||
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||
* original pre-edit body
|
||||
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||
* message stands alone in the target room
|
||||
*/
|
||||
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
||||
if (mEvent.isDecryptionFailure()) return undefined;
|
||||
|
||||
let content = { ...mEvent.getContent() };
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const room = mx.getRoom(mEvent.getRoomId());
|
||||
if (eventId && room) {
|
||||
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||
if (newContent && typeof newContent === 'object') {
|
||||
content = { ...(newContent as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
|
||||
delete content['m.relates_to'];
|
||||
if (typeof content.body === 'string') {
|
||||
content.body = trimReplyFromBody(content.body);
|
||||
}
|
||||
if (typeof content.formatted_body === 'string') {
|
||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||
}
|
||||
return content;
|
||||
}, [mx, mEvent]);
|
||||
|
||||
const forward = useCallback(
|
||||
async (room: Room) => {
|
||||
if (sending) return;
|
||||
const fwdContent = buildForwardContent();
|
||||
if (!fwdContent) {
|
||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
|
||||
delete fwdContent['m.relates_to'];
|
||||
setError(null);
|
||||
try {
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
|
||||
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
|
||||
setSentTo(room.name);
|
||||
setTimeout(onClose, 1400);
|
||||
} catch {
|
||||
setSending(false);
|
||||
setError(`Failed to forward to ${room.name}. Try again.`);
|
||||
}
|
||||
},
|
||||
[mx, mEvent, onClose, sending],
|
||||
[mx, mEvent, onClose, sending, buildForwardContent],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -122,7 +172,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
initialFocus: () => searchInputRef.current ?? false,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
@@ -153,8 +203,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
</IconButton>
|
||||
</Header>
|
||||
{!sentTo && (
|
||||
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
variant="Background"
|
||||
size="400"
|
||||
radii="400"
|
||||
@@ -163,6 +218,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Line size="300" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
@@ -43,15 +44,25 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePick = async (ms: number) => {
|
||||
await addReminder({
|
||||
roomId,
|
||||
eventId,
|
||||
timestamp: Date.now() + ms,
|
||||
message: previewText || 'Reminder',
|
||||
});
|
||||
onClose();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addReminder({
|
||||
roomId,
|
||||
eventId,
|
||||
timestamp: Date.now() + ms,
|
||||
message: previewText || 'Reminder',
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
setBusy(false);
|
||||
setError('Could not set reminder. Try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -108,6 +119,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => handlePick(p.ms)}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
@@ -115,6 +127,14 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -123,6 +123,10 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
// The composer preventDefaults Escape when it consumes it (dismissing
|
||||
// autocomplete / clearing a reply draft). Don't close the panel in
|
||||
// that case — only when Escape wasn't already handled.
|
||||
if (evt.defaultPrevented) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
requestClose();
|
||||
|
||||
@@ -11,6 +11,15 @@ export const ThreadTimelineContent = style({
|
||||
padding: `${config.space.S400} 0`,
|
||||
});
|
||||
|
||||
export const ThreadTimelineFloat = style({
|
||||
position: 'absolute',
|
||||
bottom: config.space.S400,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1,
|
||||
minWidth: 'max-content',
|
||||
});
|
||||
|
||||
export const ThreadCentered = style({
|
||||
height: '100%',
|
||||
padding: config.space.S700,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { Editor } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import to from 'await-to-js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Badge, Box, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||
@@ -459,6 +459,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
}
|
||||
}, [scrollToBottomCount]);
|
||||
|
||||
const handleJumpToBottom = useCallback(() => {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||
// events resume sticking to the bottom.
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
// Scroll in-place editor into view.
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
@@ -949,6 +957,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
<Box className={css.ThreadTimelineFloat} justifyContent="Center" alignItems="Center">
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToBottom}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
{editHistoryEvent && (
|
||||
<EditHistoryModal
|
||||
room={room}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventStatus, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||
import { getThreadSummary, isPendingThreadReply } from './threadSummary';
|
||||
import { getThreadSummary, isPendingThreadReply } from './threadSummaryData';
|
||||
|
||||
// getThreadSummary reads either the live Thread (preferred) or the
|
||||
// server-aggregated `m.thread` bundle. We stub only the members it touches and
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { getLinkedTimelines } from '../RoomTimeline';
|
||||
import { isPendingThreadReply } from './threadSummary';
|
||||
import { isPendingThreadReply } from './threadSummaryData';
|
||||
|
||||
/**
|
||||
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||
import { Soundboard } from '../common-settings/soundboard';
|
||||
import { Members } from '../common-settings/members';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { General } from './general';
|
||||
@@ -48,6 +49,11 @@ const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.SoundboardPage,
|
||||
name: 'Soundboard',
|
||||
icon: Icons.Bell,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
@@ -190,6 +196,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
||||
{activePage === SpaceSettingsPage.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SpaceSettingsPage.SoundboardPage && (
|
||||
<Soundboard requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
|
||||
@@ -2,11 +2,26 @@ import { useEffect, useState } from 'react';
|
||||
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getRecentEmojis } from '../plugins/recent-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { IEmoji } from '../plugins/emoji';
|
||||
import { IEmoji, loadEmojiData } from '../plugins/emoji';
|
||||
|
||||
export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
||||
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit));
|
||||
|
||||
// Recent emojis are resolved against the (now lazily loaded) emojibase data
|
||||
// via getRecentEmojis. Recompute once loadEmojiData has populated it so the
|
||||
// recent list fills in on first open.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadEmojiData()
|
||||
.then(() => {
|
||||
if (alive) setRecentEmoji(getRecentEmojis(mx, limit));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mx, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import {
|
||||
SoundboardClip,
|
||||
SoundboardContent,
|
||||
SOUNDBOARD_MAX_CLIP_BYTES,
|
||||
SOUNDBOARD_MAX_CLIPS,
|
||||
SOUNDBOARD_NAME_MAX,
|
||||
readSoundboardClips,
|
||||
} from '../utils/soundboardClips';
|
||||
|
||||
const KEY = AccountDataEvent.LotusSoundboard;
|
||||
|
||||
/**
|
||||
* [P5-15] Read/write the user's personal soundboard, stored in the
|
||||
* `io.lotus.soundboard` account data event (synced across devices like custom
|
||||
* emoji/sticker packs). Uploading writes the audio to the media repo and
|
||||
* appends an mxc reference.
|
||||
*/
|
||||
export function useSoundboard(): {
|
||||
clips: SoundboardClip[];
|
||||
addClip: (file: File, name?: string) => Promise<void>;
|
||||
removeClip: (id: string) => Promise<void>;
|
||||
renameClip: (id: string, name: string) => Promise<void>;
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback((evt) => {
|
||||
if (evt.getType() === KEY) {
|
||||
const content = evt.getContent<SoundboardContent>();
|
||||
setClips(Array.isArray(content?.clips) ? content.clips : []);
|
||||
}
|
||||
}, []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setClips(readSoundboardClips(mx));
|
||||
}, [mx]);
|
||||
|
||||
const persist = useCallback(
|
||||
async (next: SoundboardClip[]) => {
|
||||
const content: SoundboardContent = { clips: next };
|
||||
await (
|
||||
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
|
||||
).setAccountData(KEY, content);
|
||||
},
|
||||
[mx],
|
||||
);
|
||||
|
||||
const addClip = useCallback(
|
||||
async (file: File, name?: string) => {
|
||||
const current = readSoundboardClips(mx);
|
||||
if (current.length >= SOUNDBOARD_MAX_CLIPS) {
|
||||
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||
}
|
||||
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||
throw new Error('Clip is too large (max 1 MB).');
|
||||
}
|
||||
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||
const mxc = res.content_uri;
|
||||
if (!mxc) throw new Error('Upload failed.');
|
||||
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
|
||||
.trim()
|
||||
.slice(0, SOUNDBOARD_NAME_MAX);
|
||||
const clip: SoundboardClip = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: label || 'Clip',
|
||||
url: mxc,
|
||||
mimetype: file.type || undefined,
|
||||
size: file.size,
|
||||
};
|
||||
await persist([...current, clip]);
|
||||
},
|
||||
[mx, persist],
|
||||
);
|
||||
|
||||
const removeClip = useCallback(
|
||||
async (id: string) => {
|
||||
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
|
||||
await persist(next);
|
||||
},
|
||||
[mx, persist],
|
||||
);
|
||||
|
||||
const renameClip = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
|
||||
if (!trimmed) return;
|
||||
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
|
||||
await persist(next);
|
||||
},
|
||||
[mx, persist],
|
||||
);
|
||||
|
||||
return { clips, addClip, removeClip, renameClip };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import {
|
||||
getGlobalSoundboardPacks,
|
||||
getRoomSoundboardPack,
|
||||
getRoomSoundboardPacks,
|
||||
getUserSoundboardPack,
|
||||
SoundboardPack,
|
||||
} from '../plugins/soundboard';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||
import { useStateEventCallback } from './useStateEventCallback';
|
||||
|
||||
// Parallels hooks/useImagePacks.ts (custom emoji). Same aggregation shape.
|
||||
|
||||
export const useUserSoundboardPack = (): SoundboardPack | undefined => {
|
||||
const mx = useMatrixClient();
|
||||
const [userPack, setUserPack] = useState(() => getUserSoundboardPack(mx));
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === AccountDataEvent.LotusSoundboard) {
|
||||
setUserPack(getUserSoundboardPack(mx));
|
||||
}
|
||||
},
|
||||
[mx],
|
||||
),
|
||||
);
|
||||
|
||||
return userPack;
|
||||
};
|
||||
|
||||
export const useGlobalSoundboardPacks = (): SoundboardPack[] => {
|
||||
const mx = useMatrixClient();
|
||||
const [globalPacks, setGlobalPacks] = useState(() => getGlobalSoundboardPacks(mx));
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === AccountDataEvent.LotusSoundboardRooms) {
|
||||
setGlobalPacks(getGlobalSoundboardPacks(mx));
|
||||
}
|
||||
},
|
||||
[mx],
|
||||
),
|
||||
);
|
||||
|
||||
useStateEventCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
const roomId = mEvent.getRoomId();
|
||||
const stateKey = mEvent.getStateKey();
|
||||
if (
|
||||
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
|
||||
roomId &&
|
||||
typeof stateKey === 'string'
|
||||
) {
|
||||
const isGlobal = !!globalPacks.find(
|
||||
(pack) =>
|
||||
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey,
|
||||
);
|
||||
if (isGlobal) setGlobalPacks(getGlobalSoundboardPacks(mx));
|
||||
}
|
||||
},
|
||||
[mx, globalPacks],
|
||||
),
|
||||
);
|
||||
|
||||
return globalPacks;
|
||||
};
|
||||
|
||||
export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardPack | undefined => {
|
||||
const mx = useMatrixClient();
|
||||
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
|
||||
|
||||
useStateEventCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (
|
||||
mEvent.getRoomId() === room.roomId &&
|
||||
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
|
||||
mEvent.getStateKey() === stateKey
|
||||
) {
|
||||
setRoomPack(getRoomSoundboardPack(room, stateKey));
|
||||
}
|
||||
},
|
||||
[room, stateKey],
|
||||
),
|
||||
);
|
||||
|
||||
return roomPack;
|
||||
};
|
||||
|
||||
export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
|
||||
const mx = useMatrixClient();
|
||||
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
|
||||
|
||||
useStateEventCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (
|
||||
mEvent.getRoomId() === room.roomId &&
|
||||
mEvent.getType() === StateEvent.LotusSoundboardRoom
|
||||
) {
|
||||
setRoomPacks(getRoomSoundboardPacks(room));
|
||||
}
|
||||
},
|
||||
[room],
|
||||
),
|
||||
);
|
||||
|
||||
return roomPacks;
|
||||
};
|
||||
|
||||
export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
|
||||
const mx = useMatrixClient();
|
||||
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
|
||||
|
||||
useStateEventCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (
|
||||
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
|
||||
mEvent.getType() === StateEvent.LotusSoundboardRoom
|
||||
) {
|
||||
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
|
||||
}
|
||||
},
|
||||
[rooms],
|
||||
),
|
||||
);
|
||||
|
||||
return roomPacks;
|
||||
};
|
||||
|
||||
/** User ∪ global ∪ room packs, deduped by id, keeping only packs with clips. */
|
||||
export const useRelevantSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
|
||||
const userPack = useUserSoundboardPack();
|
||||
const globalPacks = useGlobalSoundboardPacks();
|
||||
const roomsPacks = useRoomsSoundboardPacks(rooms);
|
||||
|
||||
return useMemo(() => {
|
||||
const packs = userPack ? [userPack] : [];
|
||||
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||
const relPacks = packs.concat(
|
||||
globalPacks,
|
||||
roomsPacks.filter((pack) => !globalPackIds.has(pack.id)),
|
||||
);
|
||||
return relPacks.filter((pack) => pack.getClips().length > 0);
|
||||
}, [userPack, globalPacks, roomsPacks]);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RoomEventHandlerMap,
|
||||
ThreadEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
|
||||
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummaryData';
|
||||
import { threadNotificationsAtom } from '../state/threadNotifications';
|
||||
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './types';
|
||||
import { CallControl } from './CallControl';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
@@ -205,6 +206,12 @@ export class CallEmbed {
|
||||
params.append('lotusModel', denoiseModel);
|
||||
params.append('lotusGate', denoiseGate.toString());
|
||||
params.append('lotusGateThreshold', denoiseGateThreshold.toString());
|
||||
|
||||
// [lotus] Fire-and-forget: confirm the fork's ML-denoise assets are
|
||||
// actually served under public/element-call/denoise/ (they're copied by
|
||||
// vite.config.js at build time). Warns once if the copy step regressed;
|
||||
// never blocks call start.
|
||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (CallEmbed.startingCall(intent)) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
|
||||
// Denoise assets copied into public/element-call/denoise/ by vite.config.js's
|
||||
// lotusDenoise() plugin. The filenames here MUST match what that plugin writes
|
||||
// (and what the fork's TrackProcessor fetches at runtime). Grouped per model so
|
||||
// the smoke-check only probes what the active call will actually load.
|
||||
const DENOISE_ASSETS: Record<string, readonly string[]> = {
|
||||
rnnoise: ['rnnoiseWorklet.js', 'rnnoise.wasm', 'rnnoise_simd.wasm'],
|
||||
speex: ['speexWorklet.js', 'speex.wasm'],
|
||||
dtln: ['workadventure/audio-worklet.js'],
|
||||
deepfilternet: [
|
||||
'deepfilternet/index.esm.js',
|
||||
'deepfilternet/v2/pkg/df_bg.wasm',
|
||||
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
|
||||
],
|
||||
};
|
||||
|
||||
// The noise-gate worklet is a shared asset the build ships for every model
|
||||
// (loaded when the gate is enabled), so probe it regardless of the model.
|
||||
const SHARED_ASSETS: readonly string[] = ['noiseGateWorklet.js'];
|
||||
|
||||
/**
|
||||
* Fire-and-forget smoke-check for the ML-denoise asset contract.
|
||||
*
|
||||
* The fork's in-source denoiser (lotusDenoiseSource) loads its worklet/wasm/ESM
|
||||
* from `public/element-call/denoise/` at runtime; if the build's asset copy
|
||||
* step regressed, those fetches 404 and denoise silently degrades to a raw mic.
|
||||
* This HEAD-fetches the critical assets for the selected model and emits a
|
||||
* single console.warn listing any that are missing. No UI, no throw — purely a
|
||||
* developer/operator breadcrumb.
|
||||
*
|
||||
* @param model the selected denoise model (defaults to rnnoise)
|
||||
* @returns true if every probed asset responded OK, false otherwise
|
||||
*/
|
||||
export async function verifyDenoiseAssets(model = 'rnnoise'): Promise<boolean> {
|
||||
const base = new URL(
|
||||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/denoise/`,
|
||||
window.location.origin,
|
||||
);
|
||||
const names = [...(DENOISE_ASSETS[model] ?? DENOISE_ASSETS.rnnoise), ...SHARED_ASSETS];
|
||||
|
||||
const results = await Promise.all(
|
||||
names.map(async (name): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetch(new URL(name, base).href, { method: 'HEAD' });
|
||||
return res.ok ? null : name;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const missing = results.filter((n): n is string => n !== null);
|
||||
if (missing.length > 0) {
|
||||
console.warn(
|
||||
`[lotus-denoise] ML denoise assets missing under ${base.href} (model="${model}"): ${missing.join(
|
||||
', ',
|
||||
)} — the in-source denoiser will fall back to a raw mic. Check vite.config.js lotusDenoise().`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+110
-63
@@ -1,7 +1,4 @@
|
||||
import { CompactEmoji, fromUnicodeToHexcode } from 'emojibase';
|
||||
import emojisData from 'emojibase-data/en/compact.json';
|
||||
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||
import type { CompactEmoji } from 'emojibase';
|
||||
|
||||
export type IEmoji = CompactEmoji & {
|
||||
shortcode: string;
|
||||
@@ -24,57 +21,76 @@ export type IEmojiGroup = {
|
||||
emojis: IEmoji[];
|
||||
};
|
||||
|
||||
export const getShortcodesFor = (hexcode: string): string[] | string | undefined =>
|
||||
joypixels[hexcode] || emojibase[hexcode];
|
||||
export type EmojiData = {
|
||||
emojis: IEmoji[];
|
||||
emojiGroups: IEmojiGroup[];
|
||||
};
|
||||
|
||||
type ShortcodeMap = Record<string, string | string[]>;
|
||||
|
||||
/**
|
||||
* PERF (lazy emojibase split): the heavy `emojibase-data` JSON (compact emoji
|
||||
* data + the joypixels/emojibase shortcode maps, ~965 KB combined) used to be
|
||||
* imported statically at module top-level. Because reaction/message rendering
|
||||
* (`Reaction`, `scaleSystemEmoji`) import this module eagerly, that dragged the
|
||||
* whole `emojibase` chunk into the initial (eager) bundle graph.
|
||||
*
|
||||
* It is now loaded on demand via `loadEmojiData()` (a memoized dynamic import).
|
||||
* Only lazy emoji surfaces (EmojiBoard, EmoticonAutocomplete, recent-emoji)
|
||||
* trigger the load. Anything that renders eagerly (reaction/emoji tooltips and
|
||||
* aria-labels via `getShortcodeFor`) gracefully degrades to `undefined` until
|
||||
* the data has been loaded — the visible emoji glyph itself never depended on
|
||||
* this data, so on-screen UX is unchanged; the shortcode label simply resolves
|
||||
* once emoji data is loaded. `getHexcodeForEmoji` is inlined below so it stays
|
||||
* synchronous WITHOUT pulling the `emojibase` runtime into the eager graph.
|
||||
*/
|
||||
|
||||
// Inlined from emojibase's `fromUnicodeToHexcode` so this synchronous helper
|
||||
// does not import the `emojibase` package (and thus the emojibase chunk) into
|
||||
// the eager graph. Kept byte-for-byte behaviourally identical.
|
||||
const SEQUENCE_REMOVAL_PATTERN = /200D|FE0E|FE0F/g;
|
||||
|
||||
export const getHexcodeForEmoji = (unicode: string, strip = true): string => {
|
||||
const hexcode: string[] = [];
|
||||
[...unicode].forEach((codepoint) => {
|
||||
let hex = codepoint.codePointAt(0)?.toString(16).toUpperCase() ?? '';
|
||||
while (hex.length < 4) {
|
||||
hex = `0${hex}`;
|
||||
}
|
||||
if (!strip || !hex.match(SEQUENCE_REMOVAL_PATTERN)) {
|
||||
hexcode.push(hex);
|
||||
}
|
||||
});
|
||||
return hexcode.join('-');
|
||||
};
|
||||
|
||||
// Populated by loadEmojiData(); `undefined` until the data has been loaded.
|
||||
let joypixelsShortcodes: ShortcodeMap | undefined;
|
||||
let emojibaseShortcodes: ShortcodeMap | undefined;
|
||||
|
||||
export const getShortcodesFor = (hexcode: string): string[] | string | undefined => {
|
||||
if (!joypixelsShortcodes || !emojibaseShortcodes) return undefined;
|
||||
return joypixelsShortcodes[hexcode] || emojibaseShortcodes[hexcode];
|
||||
};
|
||||
|
||||
export const getShortcodeFor = (hexcode: string): string | undefined => {
|
||||
const shortcode = joypixels[hexcode] || emojibase[hexcode];
|
||||
const shortcode = getShortcodesFor(hexcode);
|
||||
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
|
||||
};
|
||||
|
||||
export const getHexcodeForEmoji = fromUnicodeToHexcode;
|
||||
|
||||
// Shared, stable array references. They start empty and are populated in place
|
||||
// the first time loadEmojiData() resolves (mirroring the previous eager module
|
||||
// side-effect). React consumers await loadEmojiData() and re-render to observe
|
||||
// the populated data; non-React consumers (recent-emoji) read them after load.
|
||||
export const emojiGroups: IEmojiGroup[] = [
|
||||
{
|
||||
id: EmojiGroupId.People,
|
||||
order: 0,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Nature,
|
||||
order: 1,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Food,
|
||||
order: 2,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Activity,
|
||||
order: 3,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Travel,
|
||||
order: 4,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Object,
|
||||
order: 5,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Symbol,
|
||||
order: 6,
|
||||
emojis: [],
|
||||
},
|
||||
{
|
||||
id: EmojiGroupId.Flag,
|
||||
order: 7,
|
||||
emojis: [],
|
||||
},
|
||||
{ id: EmojiGroupId.People, order: 0, emojis: [] },
|
||||
{ id: EmojiGroupId.Nature, order: 1, emojis: [] },
|
||||
{ id: EmojiGroupId.Food, order: 2, emojis: [] },
|
||||
{ id: EmojiGroupId.Activity, order: 3, emojis: [] },
|
||||
{ id: EmojiGroupId.Travel, order: 4, emojis: [] },
|
||||
{ id: EmojiGroupId.Object, order: 5, emojis: [] },
|
||||
{ id: EmojiGroupId.Symbol, order: 6, emojis: [] },
|
||||
{ id: EmojiGroupId.Flag, order: 7, emojis: [] },
|
||||
];
|
||||
|
||||
export const emojis: IEmoji[] = [];
|
||||
@@ -95,20 +111,51 @@ function getGroupIndex(emoji: IEmoji): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
emojisData.forEach((emoji) => {
|
||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||
if (!myShortCodes) return;
|
||||
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
|
||||
let emojiDataPromise: Promise<EmojiData> | undefined;
|
||||
|
||||
const em: IEmoji = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
|
||||
};
|
||||
/**
|
||||
* Lazily load emojibase data (dynamic import → the `emojibase` chunk). Memoized:
|
||||
* the JSON is fetched/parsed and `emojis`/`emojiGroups` are built exactly once.
|
||||
*/
|
||||
export const loadEmojiData = (): Promise<EmojiData> => {
|
||||
if (!emojiDataPromise) {
|
||||
emojiDataPromise = (async (): Promise<EmojiData> => {
|
||||
const [emojisModule, joypixelsModule, emojibaseModule] = await Promise.all([
|
||||
import('emojibase-data/en/compact.json'),
|
||||
import('emojibase-data/en/shortcodes/joypixels.json'),
|
||||
import('emojibase-data/en/shortcodes/emojibase.json'),
|
||||
]);
|
||||
|
||||
const groupIndex = getGroupIndex(em);
|
||||
if (groupIndex !== undefined) {
|
||||
addEmojiToGroup(groupIndex, em);
|
||||
emojis.push(em);
|
||||
joypixelsShortcodes = joypixelsModule.default as ShortcodeMap;
|
||||
emojibaseShortcodes = emojibaseModule.default as ShortcodeMap;
|
||||
|
||||
const emojisData = emojisModule.default as unknown as CompactEmoji[];
|
||||
emojisData.forEach((emoji) => {
|
||||
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||
if (!myShortCodes) return;
|
||||
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
|
||||
|
||||
const em: IEmoji = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||
shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes,
|
||||
};
|
||||
|
||||
const groupIndex = getGroupIndex(em);
|
||||
if (groupIndex !== undefined) {
|
||||
addEmojiToGroup(groupIndex, em);
|
||||
emojis.push(em);
|
||||
}
|
||||
});
|
||||
|
||||
return { emojis, emojiGroups };
|
||||
})();
|
||||
// Don't cache a rejection: a transient chunk-load failure (e.g. mid-deploy
|
||||
// 404) would otherwise permanently disable emoji data until a full reload.
|
||||
emojiDataPromise = emojiDataPromise.catch((err) => {
|
||||
emojiDataPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
});
|
||||
return emojiDataPromise;
|
||||
};
|
||||
|
||||
@@ -577,12 +577,12 @@ export const getReactCustomHtmlParser = (
|
||||
return (
|
||||
<span className={css.EmoticonBase}>
|
||||
<span className={css.Emoticon()}>
|
||||
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
|
||||
<img {...props} className={css.EmoticonImg} src={htmlSrc} loading="lazy" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
|
||||
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} loading="lazy" />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,307 +2,33 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import 'prismjs/components/prism-abap.js';
|
||||
import 'prismjs/components/prism-abnf.js';
|
||||
import 'prismjs/components/prism-actionscript.js';
|
||||
import 'prismjs/components/prism-ada.js';
|
||||
import 'prismjs/components/prism-agda.js';
|
||||
import 'prismjs/components/prism-al.js';
|
||||
import 'prismjs/components/prism-antlr4.js';
|
||||
import 'prismjs/components/prism-apacheconf.js';
|
||||
import 'prismjs/components/prism-apex.js';
|
||||
import 'prismjs/components/prism-apl.js';
|
||||
import 'prismjs/components/prism-applescript.js';
|
||||
import 'prismjs/components/prism-aql.js';
|
||||
import 'prismjs/components/prism-arff.js';
|
||||
import 'prismjs/components/prism-armasm.js';
|
||||
import 'prismjs/components/prism-arturo.js';
|
||||
import 'prismjs/components/prism-asciidoc.js';
|
||||
import 'prismjs/components/prism-asm6502.js';
|
||||
import 'prismjs/components/prism-asmatmel.js';
|
||||
import 'prismjs/components/prism-aspnet.js';
|
||||
import 'prismjs/components/prism-autohotkey.js';
|
||||
import 'prismjs/components/prism-autoit.js';
|
||||
import 'prismjs/components/prism-avisynth.js';
|
||||
import 'prismjs/components/prism-avro-idl.js';
|
||||
import 'prismjs/components/prism-awk.js';
|
||||
import 'prismjs/components/prism-bash.js';
|
||||
import 'prismjs/components/prism-basic.js';
|
||||
import 'prismjs/components/prism-batch.js';
|
||||
import 'prismjs/components/prism-bbcode.js';
|
||||
import 'prismjs/components/prism-bbj.js';
|
||||
import 'prismjs/components/prism-bicep.js';
|
||||
import 'prismjs/components/prism-birb.js';
|
||||
import 'prismjs/components/prism-bnf.js';
|
||||
import 'prismjs/components/prism-bqn.js';
|
||||
import 'prismjs/components/prism-brainfuck.js';
|
||||
import 'prismjs/components/prism-brightscript.js';
|
||||
import 'prismjs/components/prism-bro.js';
|
||||
import 'prismjs/components/prism-bsl.js';
|
||||
import 'prismjs/components/prism-c.js';
|
||||
import 'prismjs/components/prism-cfscript.js';
|
||||
import 'prismjs/components/prism-cil.js';
|
||||
import 'prismjs/components/prism-cilkc.js';
|
||||
import 'prismjs/components/prism-cilkcpp.js';
|
||||
// PERF: Prism used to import every bundled language (~574 KB lazy chunk). We now
|
||||
// ship a curated subset covering the languages actually seen in chat. Imports
|
||||
// MUST stay in dependency order (Prism component files assume their base grammar
|
||||
// is already registered): base grammars (markup/css/clike/javascript) first,
|
||||
// then languages that extend them (e.g. c→cpp, javascript→typescript,
|
||||
// markup+javascript→jsx, jsx+typescript→tsx, markup→markdown).
|
||||
import 'prismjs/components/prism-markup.js'; // markup / html / xml / svg
|
||||
import 'prismjs/components/prism-css.js';
|
||||
import 'prismjs/components/prism-clike.js';
|
||||
import 'prismjs/components/prism-clojure.js';
|
||||
import 'prismjs/components/prism-cmake.js';
|
||||
import 'prismjs/components/prism-cobol.js';
|
||||
import 'prismjs/components/prism-coffeescript.js';
|
||||
import 'prismjs/components/prism-concurnas.js';
|
||||
import 'prismjs/components/prism-cooklang.js';
|
||||
import 'prismjs/components/prism-coq.js';
|
||||
import 'prismjs/components/prism-javascript.js'; // js
|
||||
import 'prismjs/components/prism-json.js';
|
||||
import 'prismjs/components/prism-yaml.js';
|
||||
import 'prismjs/components/prism-bash.js'; // bash / shell / sh
|
||||
import 'prismjs/components/prism-python.js';
|
||||
import 'prismjs/components/prism-rust.js';
|
||||
import 'prismjs/components/prism-go.js';
|
||||
import 'prismjs/components/prism-java.js';
|
||||
import 'prismjs/components/prism-c.js';
|
||||
import 'prismjs/components/prism-cpp.js';
|
||||
import 'prismjs/components/prism-csharp.js';
|
||||
import 'prismjs/components/prism-cshtml.js';
|
||||
import 'prismjs/components/prism-csp.js';
|
||||
import 'prismjs/components/prism-css-extras.js';
|
||||
import 'prismjs/components/prism-css.js';
|
||||
import 'prismjs/components/prism-csv.js';
|
||||
import 'prismjs/components/prism-cue.js';
|
||||
import 'prismjs/components/prism-cypher.js';
|
||||
import 'prismjs/components/prism-d.js';
|
||||
import 'prismjs/components/prism-dart.js';
|
||||
import 'prismjs/components/prism-dataweave.js';
|
||||
import 'prismjs/components/prism-dax.js';
|
||||
import 'prismjs/components/prism-dhall.js';
|
||||
import 'prismjs/components/prism-diff.js';
|
||||
import 'prismjs/components/prism-dns-zone-file.js';
|
||||
import 'prismjs/components/prism-docker.js';
|
||||
import 'prismjs/components/prism-dot.js';
|
||||
import 'prismjs/components/prism-ebnf.js';
|
||||
import 'prismjs/components/prism-editorconfig.js';
|
||||
import 'prismjs/components/prism-eiffel.js';
|
||||
import 'prismjs/components/prism-ejs.js';
|
||||
import 'prismjs/components/prism-elixir.js';
|
||||
import 'prismjs/components/prism-elm.js';
|
||||
import 'prismjs/components/prism-erb.js';
|
||||
import 'prismjs/components/prism-erlang.js';
|
||||
import 'prismjs/components/prism-etlua.js';
|
||||
import 'prismjs/components/prism-excel-formula.js';
|
||||
import 'prismjs/components/prism-factor.js';
|
||||
import 'prismjs/components/prism-false.js';
|
||||
import 'prismjs/components/prism-firestore-security-rules.js';
|
||||
import 'prismjs/components/prism-flow.js';
|
||||
import 'prismjs/components/prism-fortran.js';
|
||||
import 'prismjs/components/prism-fsharp.js';
|
||||
import 'prismjs/components/prism-ftl.js';
|
||||
import 'prismjs/components/prism-gap.js';
|
||||
import 'prismjs/components/prism-gcode.js';
|
||||
import 'prismjs/components/prism-gdscript.js';
|
||||
import 'prismjs/components/prism-gedcom.js';
|
||||
import 'prismjs/components/prism-gettext.js';
|
||||
import 'prismjs/components/prism-gherkin.js';
|
||||
import 'prismjs/components/prism-git.js';
|
||||
import 'prismjs/components/prism-glsl.js';
|
||||
import 'prismjs/components/prism-gml.js';
|
||||
import 'prismjs/components/prism-gn.js';
|
||||
import 'prismjs/components/prism-go-module.js';
|
||||
import 'prismjs/components/prism-go.js';
|
||||
import 'prismjs/components/prism-gradle.js';
|
||||
import 'prismjs/components/prism-graphql.js';
|
||||
import 'prismjs/components/prism-groovy.js';
|
||||
import 'prismjs/components/prism-haml.js';
|
||||
import 'prismjs/components/prism-handlebars.js';
|
||||
import 'prismjs/components/prism-haskell.js';
|
||||
import 'prismjs/components/prism-haxe.js';
|
||||
import 'prismjs/components/prism-hcl.js';
|
||||
import 'prismjs/components/prism-hlsl.js';
|
||||
import 'prismjs/components/prism-hoon.js';
|
||||
import 'prismjs/components/prism-hpkp.js';
|
||||
import 'prismjs/components/prism-hsts.js';
|
||||
import 'prismjs/components/prism-http.js';
|
||||
import 'prismjs/components/prism-ichigojam.js';
|
||||
import 'prismjs/components/prism-icon.js';
|
||||
import 'prismjs/components/prism-icu-message-format.js';
|
||||
import 'prismjs/components/prism-idris.js';
|
||||
import 'prismjs/components/prism-iecst.js';
|
||||
import 'prismjs/components/prism-ignore.js';
|
||||
import 'prismjs/components/prism-inform7.js';
|
||||
import 'prismjs/components/prism-ini.js';
|
||||
import 'prismjs/components/prism-io.js';
|
||||
import 'prismjs/components/prism-j.js';
|
||||
import 'prismjs/components/prism-java.js';
|
||||
import 'prismjs/components/prism-javadoclike.js';
|
||||
import 'prismjs/components/prism-javascript.js';
|
||||
import 'prismjs/components/prism-javastacktrace.js';
|
||||
import 'prismjs/components/prism-jexl.js';
|
||||
import 'prismjs/components/prism-jolie.js';
|
||||
import 'prismjs/components/prism-jq.js';
|
||||
import 'prismjs/components/prism-js-extras.js';
|
||||
import 'prismjs/components/prism-js-templates.js';
|
||||
import 'prismjs/components/prism-json.js';
|
||||
import 'prismjs/components/prism-json5.js';
|
||||
import 'prismjs/components/prism-jsonp.js';
|
||||
import 'prismjs/components/prism-jsstacktrace.js';
|
||||
import 'prismjs/components/prism-jsx.js';
|
||||
import 'prismjs/components/prism-julia.js';
|
||||
import 'prismjs/components/prism-keepalived.js';
|
||||
import 'prismjs/components/prism-keyman.js';
|
||||
import 'prismjs/components/prism-kotlin.js';
|
||||
import 'prismjs/components/prism-kumir.js';
|
||||
import 'prismjs/components/prism-kusto.js';
|
||||
import 'prismjs/components/prism-latex.js';
|
||||
import 'prismjs/components/prism-latte.js';
|
||||
import 'prismjs/components/prism-less.js';
|
||||
import 'prismjs/components/prism-lilypond.js';
|
||||
import 'prismjs/components/prism-linker-script.js';
|
||||
import 'prismjs/components/prism-liquid.js';
|
||||
import 'prismjs/components/prism-lisp.js';
|
||||
import 'prismjs/components/prism-livescript.js';
|
||||
import 'prismjs/components/prism-llvm.js';
|
||||
import 'prismjs/components/prism-log.js';
|
||||
import 'prismjs/components/prism-lolcode.js';
|
||||
import 'prismjs/components/prism-lua.js';
|
||||
import 'prismjs/components/prism-magma.js';
|
||||
import 'prismjs/components/prism-makefile.js';
|
||||
import 'prismjs/components/prism-markdown.js';
|
||||
import 'prismjs/components/prism-markup-templating.js';
|
||||
import 'prismjs/components/prism-markup.js';
|
||||
import 'prismjs/components/prism-mata.js';
|
||||
import 'prismjs/components/prism-matlab.js';
|
||||
import 'prismjs/components/prism-maxscript.js';
|
||||
import 'prismjs/components/prism-mel.js';
|
||||
import 'prismjs/components/prism-mermaid.js';
|
||||
import 'prismjs/components/prism-metafont.js';
|
||||
import 'prismjs/components/prism-mizar.js';
|
||||
import 'prismjs/components/prism-mongodb.js';
|
||||
import 'prismjs/components/prism-monkey.js';
|
||||
import 'prismjs/components/prism-moonscript.js';
|
||||
import 'prismjs/components/prism-n1ql.js';
|
||||
import 'prismjs/components/prism-n4js.js';
|
||||
import 'prismjs/components/prism-nand2tetris-hdl.js';
|
||||
import 'prismjs/components/prism-naniscript.js';
|
||||
import 'prismjs/components/prism-nasm.js';
|
||||
import 'prismjs/components/prism-neon.js';
|
||||
import 'prismjs/components/prism-nevod.js';
|
||||
import 'prismjs/components/prism-nginx.js';
|
||||
import 'prismjs/components/prism-nim.js';
|
||||
import 'prismjs/components/prism-nix.js';
|
||||
import 'prismjs/components/prism-nsis.js';
|
||||
import 'prismjs/components/prism-objectivec.js';
|
||||
import 'prismjs/components/prism-ocaml.js';
|
||||
import 'prismjs/components/prism-odin.js';
|
||||
import 'prismjs/components/prism-opencl.js';
|
||||
import 'prismjs/components/prism-openqasm.js';
|
||||
import 'prismjs/components/prism-oz.js';
|
||||
import 'prismjs/components/prism-parigp.js';
|
||||
import 'prismjs/components/prism-parser.js';
|
||||
import 'prismjs/components/prism-pascal.js';
|
||||
import 'prismjs/components/prism-pascaligo.js';
|
||||
import 'prismjs/components/prism-pcaxis.js';
|
||||
import 'prismjs/components/prism-peoplecode.js';
|
||||
import 'prismjs/components/prism-perl.js';
|
||||
import 'prismjs/components/prism-php-extras.js';
|
||||
import 'prismjs/components/prism-php.js';
|
||||
import 'prismjs/components/prism-phpdoc.js';
|
||||
import 'prismjs/components/prism-plant-uml.js';
|
||||
import 'prismjs/components/prism-powerquery.js';
|
||||
import 'prismjs/components/prism-powershell.js';
|
||||
import 'prismjs/components/prism-processing.js';
|
||||
import 'prismjs/components/prism-prolog.js';
|
||||
import 'prismjs/components/prism-promql.js';
|
||||
import 'prismjs/components/prism-properties.js';
|
||||
import 'prismjs/components/prism-protobuf.js';
|
||||
import 'prismjs/components/prism-psl.js';
|
||||
import 'prismjs/components/prism-pug.js';
|
||||
import 'prismjs/components/prism-puppet.js';
|
||||
import 'prismjs/components/prism-pure.js';
|
||||
import 'prismjs/components/prism-purebasic.js';
|
||||
import 'prismjs/components/prism-purescript.js';
|
||||
import 'prismjs/components/prism-python.js';
|
||||
import 'prismjs/components/prism-q.js';
|
||||
import 'prismjs/components/prism-qml.js';
|
||||
import 'prismjs/components/prism-qore.js';
|
||||
import 'prismjs/components/prism-qsharp.js';
|
||||
import 'prismjs/components/prism-r.js';
|
||||
import 'prismjs/components/prism-reason.js';
|
||||
import 'prismjs/components/prism-regex.js';
|
||||
import 'prismjs/components/prism-rego.js';
|
||||
import 'prismjs/components/prism-renpy.js';
|
||||
import 'prismjs/components/prism-rescript.js';
|
||||
import 'prismjs/components/prism-rest.js';
|
||||
import 'prismjs/components/prism-rip.js';
|
||||
import 'prismjs/components/prism-roboconf.js';
|
||||
import 'prismjs/components/prism-robotframework.js';
|
||||
import 'prismjs/components/prism-ruby.js';
|
||||
import 'prismjs/components/prism-rust.js';
|
||||
import 'prismjs/components/prism-sas.js';
|
||||
import 'prismjs/components/prism-sass.js';
|
||||
import 'prismjs/components/prism-scala.js';
|
||||
import 'prismjs/components/prism-scheme.js';
|
||||
import 'prismjs/components/prism-scss.js';
|
||||
import 'prismjs/components/prism-shell-session.js';
|
||||
import 'prismjs/components/prism-smali.js';
|
||||
import 'prismjs/components/prism-smalltalk.js';
|
||||
import 'prismjs/components/prism-smarty.js';
|
||||
import 'prismjs/components/prism-sml.js';
|
||||
import 'prismjs/components/prism-solidity.js';
|
||||
import 'prismjs/components/prism-solution-file.js';
|
||||
import 'prismjs/components/prism-soy.js';
|
||||
import 'prismjs/components/prism-splunk-spl.js';
|
||||
import 'prismjs/components/prism-sqf.js';
|
||||
import 'prismjs/components/prism-sql.js';
|
||||
import 'prismjs/components/prism-squirrel.js';
|
||||
import 'prismjs/components/prism-stan.js';
|
||||
import 'prismjs/components/prism-stata.js';
|
||||
import 'prismjs/components/prism-stylus.js';
|
||||
import 'prismjs/components/prism-supercollider.js';
|
||||
import 'prismjs/components/prism-swift.js';
|
||||
import 'prismjs/components/prism-systemd.js';
|
||||
import 'prismjs/components/prism-t4-templating.js';
|
||||
import 'prismjs/components/prism-t4-vb.js';
|
||||
import 'prismjs/components/prism-tap.js';
|
||||
import 'prismjs/components/prism-tcl.js';
|
||||
import 'prismjs/components/prism-textile.js';
|
||||
import 'prismjs/components/prism-toml.js';
|
||||
import 'prismjs/components/prism-tremor.js';
|
||||
import 'prismjs/components/prism-diff.js';
|
||||
import 'prismjs/components/prism-docker.js';
|
||||
import 'prismjs/components/prism-markdown.js';
|
||||
import 'prismjs/components/prism-typescript.js'; // ts
|
||||
import 'prismjs/components/prism-jsx.js';
|
||||
import 'prismjs/components/prism-tsx.js';
|
||||
import 'prismjs/components/prism-tt2.js';
|
||||
import 'prismjs/components/prism-turtle.js';
|
||||
import 'prismjs/components/prism-twig.js';
|
||||
import 'prismjs/components/prism-typescript.js';
|
||||
import 'prismjs/components/prism-typoscript.js';
|
||||
import 'prismjs/components/prism-unrealscript.js';
|
||||
import 'prismjs/components/prism-uorazor.js';
|
||||
import 'prismjs/components/prism-uri.js';
|
||||
import 'prismjs/components/prism-v.js';
|
||||
import 'prismjs/components/prism-vala.js';
|
||||
import 'prismjs/components/prism-vbnet.js';
|
||||
import 'prismjs/components/prism-velocity.js';
|
||||
import 'prismjs/components/prism-verilog.js';
|
||||
import 'prismjs/components/prism-vhdl.js';
|
||||
import 'prismjs/components/prism-vim.js';
|
||||
import 'prismjs/components/prism-visual-basic.js';
|
||||
import 'prismjs/components/prism-warpscript.js';
|
||||
import 'prismjs/components/prism-wasm.js';
|
||||
import 'prismjs/components/prism-web-idl.js';
|
||||
import 'prismjs/components/prism-wgsl.js';
|
||||
import 'prismjs/components/prism-wiki.js';
|
||||
import 'prismjs/components/prism-wolfram.js';
|
||||
import 'prismjs/components/prism-wren.js';
|
||||
import 'prismjs/components/prism-xeora.js';
|
||||
import 'prismjs/components/prism-xml-doc.js';
|
||||
import 'prismjs/components/prism-xojo.js';
|
||||
import 'prismjs/components/prism-xquery.js';
|
||||
import 'prismjs/components/prism-yaml.js';
|
||||
import 'prismjs/components/prism-yang.js';
|
||||
import 'prismjs/components/prism-zig.js';
|
||||
import 'prismjs/components/prism-arduino.js';
|
||||
|
||||
// Broken:
|
||||
//
|
||||
// import 'prismjs/components/prism-bison.js';
|
||||
// import 'prismjs/components/prism-chaiscript.js';
|
||||
// import 'prismjs/components/prism-core.js';
|
||||
// import 'prismjs/components/prism-crystal.js';
|
||||
// import 'prismjs/components/prism-django.js';
|
||||
// import 'prismjs/components/prism-javadoc.js';
|
||||
// import 'prismjs/components/prism-jsdoc.js';
|
||||
// import 'prismjs/components/prism-plsql.js';
|
||||
// import 'prismjs/components/prism-racket.js';
|
||||
// import 'prismjs/components/prism-sparql.js';
|
||||
// import 'prismjs/components/prism-t4-cs.js';
|
||||
|
||||
import './ReactPrism.css';
|
||||
// using classNames .prism-dark .prism-light from ReactPrism.css
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { addRecentEmoji, getRecentEmojis, IRecentEmojiContent } from './recent-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { emojis } from './emoji';
|
||||
import { emojis, loadEmojiData } from './emoji';
|
||||
|
||||
// A Map-backed MatrixClient stub supporting get/setAccountData.
|
||||
const createMx = () => {
|
||||
@@ -25,6 +25,9 @@ const createMx = () => {
|
||||
const getStored = (store: Map<string, unknown>): IRecentEmojiContent['recent_emoji'] =>
|
||||
(store.get(AccountDataEvent.ElementRecentEmoji) as IRecentEmojiContent | undefined)?.recent_emoji;
|
||||
|
||||
// Emoji data is now loaded lazily; populate `emojis` before the round trips.
|
||||
await loadEmojiData();
|
||||
|
||||
// Pick two real unicode emojis to drive add->get round trips.
|
||||
const u1 = emojis[0].unicode;
|
||||
const u2 = emojis[1].unicode;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SoundboardClip, SoundboardClipInfo } from './types';
|
||||
|
||||
/** Parallels custom-emoji/PackImageReader, for a soundboard clip. */
|
||||
export class SoundboardClipReader {
|
||||
public readonly shortcode: string;
|
||||
|
||||
public readonly url: string;
|
||||
|
||||
private readonly clip: Omit<SoundboardClip, 'url'>;
|
||||
|
||||
constructor(shortcode: string, url: string, clip: Omit<SoundboardClip, 'url'>) {
|
||||
this.shortcode = shortcode;
|
||||
this.url = url;
|
||||
this.clip = clip;
|
||||
}
|
||||
|
||||
static fromClip(shortcode: string, clip: SoundboardClip): SoundboardClipReader | undefined {
|
||||
const { url } = clip;
|
||||
if (typeof url !== 'string' || !url.startsWith('mxc://')) return undefined;
|
||||
return new SoundboardClipReader(shortcode, url, clip);
|
||||
}
|
||||
|
||||
get body(): string | undefined {
|
||||
const { body } = this.clip;
|
||||
return typeof body === 'string' ? body : undefined;
|
||||
}
|
||||
|
||||
/** Display name — the clip body, falling back to the shortcode. */
|
||||
get name(): string {
|
||||
return this.body ?? this.shortcode;
|
||||
}
|
||||
|
||||
get emoji(): string | undefined {
|
||||
const { emoji } = this.clip;
|
||||
return typeof emoji === 'string' && emoji.length > 0 ? emoji : undefined;
|
||||
}
|
||||
|
||||
/** Per-clip volume 0–100; defaults to 100 when unset/invalid. */
|
||||
get volume(): number {
|
||||
const v = this.clip.volume;
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) return 100;
|
||||
return Math.min(100, Math.max(0, v));
|
||||
}
|
||||
|
||||
get info(): SoundboardClipInfo | undefined {
|
||||
return this.clip.info;
|
||||
}
|
||||
|
||||
get content(): SoundboardClip {
|
||||
return {
|
||||
url: this.url,
|
||||
body: this.clip.body,
|
||||
emoji: this.clip.emoji,
|
||||
volume: this.clip.volume,
|
||||
info: this.clip.info,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SoundboardClipReader } from './SoundboardClipReader';
|
||||
import { SoundboardClips } from './types';
|
||||
|
||||
/** Parallels custom-emoji/PackImagesReader. */
|
||||
export class SoundboardClipsReader {
|
||||
private readonly rawClips: SoundboardClips;
|
||||
|
||||
private shortcodeToClips: Map<string, SoundboardClipReader> | undefined;
|
||||
|
||||
constructor(clips: SoundboardClips) {
|
||||
this.rawClips = clips;
|
||||
}
|
||||
|
||||
get collection(): Map<string, SoundboardClipReader> {
|
||||
if (this.shortcodeToClips) return this.shortcodeToClips;
|
||||
|
||||
const shortcodeToClips: Map<string, SoundboardClipReader> = new Map();
|
||||
Object.entries(this.rawClips).forEach(([shortcode, clip]) => {
|
||||
const reader = SoundboardClipReader.fromClip(shortcode, clip);
|
||||
if (reader) shortcodeToClips.set(shortcode, reader);
|
||||
});
|
||||
|
||||
this.shortcodeToClips = shortcodeToClips;
|
||||
return this.shortcodeToClips;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SoundboardMeta } from './types';
|
||||
|
||||
/** Parallels custom-emoji/PackMetaReader (no usage tiers for soundboard). */
|
||||
export class SoundboardMetaReader {
|
||||
private readonly meta: SoundboardMeta;
|
||||
|
||||
constructor(meta: SoundboardMeta) {
|
||||
this.meta = meta;
|
||||
}
|
||||
|
||||
get name(): string | undefined {
|
||||
const displayName = this.meta.display_name;
|
||||
return typeof displayName === 'string' ? displayName : undefined;
|
||||
}
|
||||
|
||||
get avatar(): string | undefined {
|
||||
const avatarURL = this.meta.avatar_url;
|
||||
return typeof avatarURL === 'string' ? avatarURL : undefined;
|
||||
}
|
||||
|
||||
get attribution(): string | undefined {
|
||||
const { attribution } = this.meta;
|
||||
return typeof attribution === 'string' ? attribution : undefined;
|
||||
}
|
||||
|
||||
get content(): SoundboardMeta {
|
||||
return this.meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { PackAddress } from '../custom-emoji/PackAddress';
|
||||
import { SoundboardClipReader } from './SoundboardClipReader';
|
||||
import { SoundboardClipsReader } from './SoundboardClipsReader';
|
||||
import { SoundboardMetaReader } from './SoundboardMetaReader';
|
||||
import { SoundboardContent } from './types';
|
||||
|
||||
/** Parallels custom-emoji/ImagePack. Holds a soundboard pack's meta + clips. */
|
||||
export class SoundboardPack {
|
||||
public readonly id: string;
|
||||
|
||||
public readonly deleted: boolean;
|
||||
|
||||
public readonly address: PackAddress | undefined;
|
||||
|
||||
public readonly meta: SoundboardMetaReader;
|
||||
|
||||
public readonly clips: SoundboardClipsReader;
|
||||
|
||||
private clipsMemo: SoundboardClipReader[] | undefined;
|
||||
|
||||
constructor(id: string, content: SoundboardContent, address: PackAddress | undefined) {
|
||||
this.id = id;
|
||||
this.address = address;
|
||||
this.deleted = content.pack === undefined && content.clips === undefined;
|
||||
this.meta = new SoundboardMetaReader(content.pack ?? {});
|
||||
this.clips = new SoundboardClipsReader(content.clips ?? {});
|
||||
}
|
||||
|
||||
static fromMatrixEvent(id: string, matrixEvent: MatrixEvent): SoundboardPack {
|
||||
const roomId = matrixEvent.getRoomId();
|
||||
const stateKey = matrixEvent.getStateKey();
|
||||
const address =
|
||||
roomId && typeof stateKey === 'string' ? new PackAddress(roomId, stateKey) : undefined;
|
||||
return new SoundboardPack(id, matrixEvent.getContent<SoundboardContent>(), address);
|
||||
}
|
||||
|
||||
getClips(): SoundboardClipReader[] {
|
||||
if (this.clipsMemo) return this.clipsMemo;
|
||||
this.clipsMemo = Array.from(this.clips.collection.values());
|
||||
return this.clipsMemo;
|
||||
}
|
||||
|
||||
getAvatarUrl(): string | undefined {
|
||||
if (this.meta.avatar) return this.meta.avatar;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types';
|
||||
export * from './SoundboardClipReader';
|
||||
export * from './SoundboardClipsReader';
|
||||
export * from './SoundboardMetaReader';
|
||||
export * from './SoundboardPack';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,52 @@
|
||||
// [P5-15 v2] Soundboard packs — a near-parallel of the MSC2545 custom-emoji
|
||||
// image packs (see ../custom-emoji/types.ts), for shareable in-call audio clips.
|
||||
|
||||
/** io.lotus.soundboard_rooms content (global refs) — mirrors EmoteRoomsContent. */
|
||||
export type SoundboardPackStateKeyToObject = Record<string, object>;
|
||||
export type SoundboardRoomIdToStateKey = Record<string, SoundboardPackStateKeyToObject>;
|
||||
export type SoundboardRoomsContent = {
|
||||
rooms?: SoundboardRoomIdToStateKey;
|
||||
};
|
||||
|
||||
/** Per-clip media info (audio, so no width/height — unlike IImageInfo). */
|
||||
export type SoundboardClipInfo = {
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
/** Clip duration in milliseconds, if known. */
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
/** A single soundboard clip (parallels PackImage). Keyed by shortcode in the pack. */
|
||||
export type SoundboardClip = {
|
||||
url: string; // mxc://
|
||||
body?: string; // display name
|
||||
emoji?: string; // emoji tag (like a Discord soundboard emoji)
|
||||
volume?: number; // 0–100, per-clip gain
|
||||
info?: SoundboardClipInfo;
|
||||
};
|
||||
|
||||
export type SoundboardClips = Record<string, SoundboardClip>;
|
||||
|
||||
export type SoundboardMeta = {
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
attribution?: string;
|
||||
};
|
||||
|
||||
/** io.lotus.soundboard (user account data) + io.lotus.soundboard (room state). */
|
||||
export type SoundboardContent = {
|
||||
pack?: SoundboardMeta;
|
||||
clips?: SoundboardClips;
|
||||
};
|
||||
|
||||
/** Legacy v1 personal soundboard shape (flat list), migrated to a pack on read. */
|
||||
export type LegacySoundboardClip = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
export type LegacySoundboardContent = {
|
||||
clips?: LegacySoundboardClip[];
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { migrateUserSoundboardContent, slugifyClipName, uniqueShortcode } from './utils';
|
||||
|
||||
describe('slugifyClipName', () => {
|
||||
test('lowercases, replaces spaces, strips punctuation', () => {
|
||||
assert.equal(slugifyClipName(' Air Horn!! '), 'air_horn');
|
||||
assert.equal(slugifyClipName('Ba-Dum Tss'), 'ba-dum_tss');
|
||||
});
|
||||
test('falls back to "clip" when empty', () => {
|
||||
assert.equal(slugifyClipName(' '), 'clip');
|
||||
assert.equal(slugifyClipName('!!!'), 'clip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueShortcode', () => {
|
||||
test('returns the slug when free', () => {
|
||||
assert.equal(uniqueShortcode('Airhorn', new Set()), 'airhorn');
|
||||
});
|
||||
test('suffixes on collision', () => {
|
||||
assert.equal(uniqueShortcode('Airhorn', new Set(['airhorn'])), 'airhorn-2');
|
||||
assert.equal(uniqueShortcode('Airhorn', new Set(['airhorn', 'airhorn-2'])), 'airhorn-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateUserSoundboardContent', () => {
|
||||
test('migrates the v1 flat list into a v2 pack keyed by slug', () => {
|
||||
const v1 = {
|
||||
clips: [
|
||||
{ id: 'a', name: 'Air Horn', url: 'mxc://x/1', mimetype: 'audio/mpeg', size: 100 },
|
||||
{ id: 'b', name: 'Applause', url: 'mxc://x/2' },
|
||||
],
|
||||
};
|
||||
const out = migrateUserSoundboardContent(v1);
|
||||
assert.deepEqual(Object.keys(out.clips ?? {}).sort(), ['air_horn', 'applause']);
|
||||
assert.equal(out.clips?.air_horn.url, 'mxc://x/1');
|
||||
assert.equal(out.clips?.air_horn.body, 'Air Horn');
|
||||
assert.equal(out.clips?.air_horn.info?.mimetype, 'audio/mpeg');
|
||||
assert.ok(out.pack?.display_name);
|
||||
});
|
||||
|
||||
test('dedupes colliding v1 names', () => {
|
||||
const v1 = {
|
||||
clips: [
|
||||
{ id: 'a', name: 'Horn', url: 'mxc://x/1' },
|
||||
{ id: 'b', name: 'Horn', url: 'mxc://x/2' },
|
||||
],
|
||||
};
|
||||
const out = migrateUserSoundboardContent(v1);
|
||||
assert.deepEqual(Object.keys(out.clips ?? {}).sort(), ['horn', 'horn-2']);
|
||||
});
|
||||
|
||||
test('skips v1 entries without a url', () => {
|
||||
const out = migrateUserSoundboardContent({ clips: [{ id: 'a', name: 'Bad' } as never] });
|
||||
assert.deepEqual(out.clips, {});
|
||||
});
|
||||
|
||||
test('passes a v2 pack through unchanged', () => {
|
||||
const v2 = { pack: { display_name: 'P' }, clips: { horn: { url: 'mxc://x/1', volume: 50 } } };
|
||||
assert.deepEqual(migrateUserSoundboardContent(v2), v2);
|
||||
});
|
||||
|
||||
test('handles empty / non-object input', () => {
|
||||
assert.deepEqual(migrateUserSoundboardContent({}), {});
|
||||
assert.deepEqual(migrateUserSoundboardContent(null), {});
|
||||
assert.deepEqual(migrateUserSoundboardContent(undefined), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { SoundboardPack } from './SoundboardPack';
|
||||
import {
|
||||
LegacySoundboardContent,
|
||||
SoundboardClips,
|
||||
SoundboardContent,
|
||||
SoundboardRoomsContent,
|
||||
} from './types';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room';
|
||||
|
||||
/** Normalize a display name into a pack shortcode key (parallels emoji shortcodes). */
|
||||
export function slugifyClipName(name: string): string {
|
||||
const s = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9_-]/g, '');
|
||||
return s || 'clip';
|
||||
}
|
||||
|
||||
/** Pick a shortcode not already present in `taken`, suffixing on collision. */
|
||||
export function uniqueShortcode(base: string, taken: Set<string>): string {
|
||||
const code = slugifyClipName(base);
|
||||
if (!taken.has(code)) return code;
|
||||
let i = 2;
|
||||
while (taken.has(`${code}-${i}`)) i += 1;
|
||||
return `${code}-${i}`;
|
||||
}
|
||||
|
||||
export function makeSoundboardPacks(packEvents: MatrixEvent[]): SoundboardPack[] {
|
||||
return packEvents.reduce<SoundboardPack[]>((packs, packEvent) => {
|
||||
const packId = packEvent.getId();
|
||||
if (!packId) return packs;
|
||||
packs.push(SoundboardPack.fromMatrixEvent(packId, packEvent));
|
||||
return packs;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function getRoomSoundboardPack(room: Room, stateKey: string): SoundboardPack | undefined {
|
||||
const packEvent = getStateEvent(room, StateEvent.LotusSoundboardRoom, stateKey);
|
||||
if (!packEvent) return undefined;
|
||||
const packId = packEvent.getId();
|
||||
if (!packId) return undefined;
|
||||
return SoundboardPack.fromMatrixEvent(packId, packEvent);
|
||||
}
|
||||
|
||||
export function getRoomSoundboardPacks(room: Room): SoundboardPack[] {
|
||||
return makeSoundboardPacks(getStateEvents(room, StateEvent.LotusSoundboardRoom));
|
||||
}
|
||||
|
||||
export function getGlobalSoundboardPacks(mx: MatrixClient): SoundboardPack[] {
|
||||
const content = getAccountData(mx, AccountDataEvent.LotusSoundboardRooms)?.getContent() as
|
||||
| SoundboardRoomsContent
|
||||
| undefined;
|
||||
const roomIdToPackInfo = content?.rooms;
|
||||
if (typeof roomIdToPackInfo !== 'object' || !roomIdToPackInfo) return [];
|
||||
|
||||
return Object.keys(roomIdToPackInfo).flatMap((roomId) => {
|
||||
if (typeof roomIdToPackInfo[roomId] !== 'object') return [];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return [];
|
||||
const stateKeys = roomIdToPackInfo[roomId];
|
||||
const globalEvents = getStateEvents(room, StateEvent.LotusSoundboardRoom).filter((mE) => {
|
||||
const stateKey = mE.getStateKey();
|
||||
return typeof stateKey === 'string' ? !!stateKeys[stateKey] : false;
|
||||
});
|
||||
return makeSoundboardPacks(globalEvents);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a personal soundboard account-data content to the v2 pack shape,
|
||||
* migrating the v1 flat-list form (`{clips: [{id,name,url}]}`) on the fly.
|
||||
*/
|
||||
export function migrateUserSoundboardContent(raw: unknown): SoundboardContent {
|
||||
if (typeof raw !== 'object' || raw === null) return {};
|
||||
const legacy = raw as LegacySoundboardContent;
|
||||
if (!Array.isArray(legacy.clips)) return raw as SoundboardContent; // already v2 (or empty)
|
||||
|
||||
const clips: SoundboardClips = {};
|
||||
const taken = new Set<string>();
|
||||
legacy.clips.forEach((c) => {
|
||||
if (!c || typeof c.url !== 'string') return;
|
||||
const shortcode = uniqueShortcode(c.name || 'clip', taken);
|
||||
taken.add(shortcode);
|
||||
clips[shortcode] = {
|
||||
url: c.url,
|
||||
body: c.name,
|
||||
info: { mimetype: c.mimetype, size: c.size },
|
||||
};
|
||||
});
|
||||
return { pack: { display_name: 'My Soundboard' }, clips };
|
||||
}
|
||||
|
||||
export function getUserSoundboardPack(mx: MatrixClient): SoundboardPack | undefined {
|
||||
const packEvent = getAccountData(mx, AccountDataEvent.LotusSoundboard);
|
||||
const userId = mx.getUserId();
|
||||
if (!packEvent || !userId) return undefined;
|
||||
const content = migrateUserSoundboardContent(packEvent.getContent());
|
||||
return new SoundboardPack(userId, content, undefined);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export enum RoomSettingsPage {
|
||||
MembersPage,
|
||||
PermissionsPage,
|
||||
EmojisStickersPage,
|
||||
SoundboardPage,
|
||||
DeveloperToolsPage,
|
||||
ExportPage,
|
||||
ActivityLogPage,
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum SpaceSettingsPage {
|
||||
MembersPage,
|
||||
PermissionsPage,
|
||||
EmojisStickersPage,
|
||||
SoundboardPage,
|
||||
DeveloperToolsPage,
|
||||
PolicyListsPage,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test } from 'node:test';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Guard against same-directory filenames that differ only by case (e.g.
|
||||
* `threadSummary.ts` vs `ThreadSummary.tsx`). On case-insensitive filesystems
|
||||
* (the Windows release runner) an extensionless import of one can resolve to
|
||||
* the OTHER file — rolldown tries `.ts` before `.tsx` — producing
|
||||
* MISSING_EXPORT failures that never reproduce on the Linux/macOS machines the
|
||||
* project is developed and web-deployed on. This broke the desktop release
|
||||
* build twice before being diagnosed; this test makes the collision a local,
|
||||
* immediate failure instead.
|
||||
*/
|
||||
const findCaseCollisions = (dir: string, collisions: string[]): void => {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const seen = new Map<string, string>();
|
||||
entries.forEach((entry) => {
|
||||
// Compare basenames without extension: `Foo.tsx` collides with `foo.ts`
|
||||
// because module resolution is extensionless.
|
||||
const stem = entry.isDirectory() ? entry.name : entry.name.replace(/\.[^.]+$/, '');
|
||||
const key = stem.toLowerCase();
|
||||
const existing = seen.get(key);
|
||||
if (existing !== undefined && existing !== stem) {
|
||||
collisions.push(`${dir}: "${existing}" vs "${stem}"`);
|
||||
}
|
||||
if (existing === undefined) seen.set(key, stem);
|
||||
if (entry.isDirectory()) {
|
||||
findCaseCollisions(join(dir, entry.name), collisions);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
test('no same-directory filenames differing only by case under src/', () => {
|
||||
const collisions: string[] = [];
|
||||
findCaseCollisions('src', collisions);
|
||||
assert.deepEqual(
|
||||
collisions,
|
||||
[],
|
||||
`Case-colliding names break Windows builds:\n${collisions.join('\n')}`,
|
||||
);
|
||||
});
|
||||
@@ -1,25 +1,9 @@
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { downloadMedia, mxcUrlToHttp } from './matrix';
|
||||
|
||||
/**
|
||||
* [P5-15] A user-uploaded soundboard clip. Stored (as a list) in the
|
||||
* `io.lotus.soundboard` account data event, so clips sync across a user's
|
||||
* devices exactly like custom emoji / sticker packs.
|
||||
*/
|
||||
export type SoundboardClip = {
|
||||
/** Stable local id (not shared with peers). */
|
||||
id: string;
|
||||
/** Display name / shortcode shown on the tile. */
|
||||
name: string;
|
||||
/** mxc:// URI of the uploaded audio. */
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type SoundboardContent = {
|
||||
clips?: SoundboardClip[];
|
||||
};
|
||||
// [P5-15 v2] Shared media helpers for the soundboard. Clip storage/metadata now
|
||||
// lives in the soundboard pack plugin (plugins/soundboard); this module only
|
||||
// handles resolving an mxc clip for playback + local preview.
|
||||
|
||||
export const SOUNDBOARD_NAME_MAX = 24;
|
||||
/** Keep clips short: they publish to every peer and hold a track open. */
|
||||
@@ -53,20 +37,19 @@ export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Pr
|
||||
* Play a resolved clip locally so the person who pressed it gets immediate
|
||||
* feedback — LiveKit doesn't loop a participant's own published track back to
|
||||
* them, so without this the presser would hear nothing. `volume` is 0–1.
|
||||
* Returns the audio element so callers can track when it ends (or undefined if
|
||||
* playback couldn't start).
|
||||
*/
|
||||
export const playClipLocally = (objectUrl: string, volume: number): void => {
|
||||
export const playClipLocally = (
|
||||
objectUrl: string,
|
||||
volume: number,
|
||||
): HTMLAudioElement | undefined => {
|
||||
try {
|
||||
const audio = new Audio(objectUrl);
|
||||
audio.volume = Math.max(0, Math.min(1, volume));
|
||||
audio.play().catch(() => undefined);
|
||||
return audio;
|
||||
} catch {
|
||||
/* best effort */
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const readSoundboardClips = (mx: MatrixClient): SoundboardClip[] => {
|
||||
const content = mx.getAccountData('io.lotus.soundboard' as never)?.getContent() as
|
||||
| SoundboardContent
|
||||
| undefined;
|
||||
return Array.isArray(content?.clips) ? content.clips : [];
|
||||
};
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
|
||||
export type {};
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
// Replaced at build time by vite-plugin-pwa (injectManifest) with the list of
|
||||
// hashed build assets to precache. See vite.config.js VitePWA injectManifest.
|
||||
__WB_MANIFEST: Array<string | PrecacheEntry>;
|
||||
};
|
||||
|
||||
/**
|
||||
* PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time
|
||||
* by vite-plugin-pwa with the list of hashed build assets
|
||||
* (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns).
|
||||
*
|
||||
* DEPLOY-SAFETY INVARIANTS (do not break):
|
||||
* 1. index.html / navigations are NEVER precached or precache-routed. The
|
||||
* manifest globs only `assets/**` (content-hashed), so index.html (served
|
||||
* from the app root) is absent from it and navigation requests fall through
|
||||
* to the network — a new deploy is picked up immediately, no stale SPA
|
||||
* shell. We deliberately do NOT register a navigation route /
|
||||
* createHandlerBoundToURL fallback.
|
||||
* 2. precacheAndRoute only matches its own manifest URLs (same-origin hashed
|
||||
* assets). It never matches the media-auth paths handled by the fetch
|
||||
* listener below — those are cross-origin homeserver URLs absent from the
|
||||
* manifest — so the existing media fetch behaviour is fully preserved. It
|
||||
* is registered before that listener; for a media request the precache
|
||||
* route finds no match and does not call respondWith, so the media handler
|
||||
* still runs.
|
||||
* 3. Assets are content-hashed, so a changed asset ships under a new filename;
|
||||
* PrecacheController drops entries no longer in the current manifest on
|
||||
* activate, so the precache self-updates each deploy without unbounded
|
||||
* growth.
|
||||
*/
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
type SessionInfo = {
|
||||
accessToken: string;
|
||||
|
||||
@@ -10,9 +10,12 @@ export enum AccountDataEvent {
|
||||
PoniesUserEmotes = 'im.ponies.user_emotes',
|
||||
PoniesEmoteRooms = 'im.ponies.emote_rooms',
|
||||
|
||||
// [P5-15] Personal, uploadable in-call soundboard clips (synced across
|
||||
// devices like custom emoji/sticker packs).
|
||||
// [P5-15] Personal soundboard pack (synced across devices). v2 content is a
|
||||
// SoundboardContent pack ({pack, clips}); v1 was {clips: [...]} (migrated on read).
|
||||
LotusSoundboard = 'io.lotus.soundboard',
|
||||
// [P5-15 v2] Global refs: room soundboard packs the user enabled everywhere
|
||||
// (mirrors im.ponies.emote_rooms).
|
||||
LotusSoundboardRooms = 'io.lotus.soundboard_rooms',
|
||||
|
||||
// [P4-1] Per-thread notification mode overrides (All/Mentions/Mute) plus the
|
||||
// global default behavior for threads.
|
||||
|
||||
@@ -42,6 +42,9 @@ export enum StateEvent {
|
||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||
LotusVoiceLimit = 'io.lotus.voice_limit',
|
||||
LotusRoomQuality = 'io.lotus.room_quality',
|
||||
// [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per
|
||||
// state-key, aggregated with parent-space packs like custom emoji.
|
||||
LotusSoundboardRoom = 'io.lotus.soundboard',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
|
||||
+13
-1
@@ -261,7 +261,19 @@ export default defineConfig({
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
injectionPoint: undefined,
|
||||
// PRECACHE (P5): emit `self.__WB_MANIFEST` into src/sw.ts so it can
|
||||
// precacheAndRoute the hashed build assets. index.html is deliberately
|
||||
// EXCLUDED from the manifest (globs only `assets/**`) so navigations
|
||||
// stay network-first and a new deploy is picked up immediately — see
|
||||
// the deploy-safety invariants documented in src/sw.ts.
|
||||
injectionPoint: 'self.__WB_MANIFEST',
|
||||
globPatterns: ['assets/**/*.{js,css,wasm}'],
|
||||
// Assets are content-hashed, so the filename is the cache key — don't
|
||||
// append a revision cache-busting param.
|
||||
dontCacheBustURLsMatching: /assets\//,
|
||||
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
|
||||
// (hash-busted and hot on every session) is precached deliberately.
|
||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
|
||||
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user