diff --git a/.eslintrc.cjs b/.eslintrc.cjs index dca01a3d..9d5a27d9 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -31,15 +31,12 @@ module.exports = { 'linebreak-style': 0, 'no-underscore-dangle': 0, "no-shadow": "off", - "max-classes-per-file": "off", - "no-useless-constructor": "off", - "no-empty-function": "off", "import/prefer-default-export": "off", "import/extensions": "off", "import/no-unresolved": "off", "import/no-extraneous-dependencies": [ - "off", + "error", { devDependencies: true, }, @@ -61,10 +58,8 @@ module.exports = { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", - "@typescript-eslint/no-useless-constructor": "error", - "@typescript-eslint/no-empty-function": "error", "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/no-shadow": "error", + "@typescript-eslint/no-shadow": "error" }, overrides: [ { diff --git a/.prettierignore b/.prettierignore index e0aa18a1..036ee8bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ dist node_modules package.json package-lock.json -LICENSE \ No newline at end of file +LICENSE +harness diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index 83b47ade..00000000 --- a/.zed/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "format_on_save": "on", - "prettier": { - "parser": "", - "allowed": true - } -} diff --git a/build.config.ts b/build.config.ts index ec8a41d0..ac51db97 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,3 @@ export default { - base: '/', + base: process.env.CINNY_BASE ?? '/', }; diff --git a/call.config.json b/call.config.json deleted file mode 100644 index 0967ef42..00000000 --- a/call.config.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/config.json b/config.json index a61bb966..e945d1c4 100644 --- a/config.json +++ b/config.json @@ -5,17 +5,7 @@ "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#cinny:matrix.org", - "#community:matrix.org", - "#space:unredacted.org", - "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" - ], + "spaces": ["#gamecult:gamecult.org"], "rooms": [ "#tuwunel:grin.hu", "#freesoftware:matrix.org", diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md new file mode 100644 index 00000000..166c5c72 --- /dev/null +++ b/docs/gamecult/ratatoskr-target.md @@ -0,0 +1,352 @@ +# Ratatoskr: target and prose map + +Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge +`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). +Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside +the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it +is handed to Void's mind once, by procedure, as soon as that hand-off procedure +exists (`hand_off` is parked); after that every new ruling on it is admitted in +Void's mind. Nothing here is written into Void's mind before then. + +The mind holds the questions, rulings, specs, reports and verdicts. This file +holds what no document kind carries: the design truths, body facts, the model +page, rationale, the reconstructed collaboration plan, and the cut sequence. +All times are UTC. + +## Why + +GameCult is the only server this client talks to. A general Matrix client +treats it as one community among thousands, so the operator meets GameCult's +structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm` +10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The +client should open on GameCult's estate as data: its spaces, its project rooms +grouped by steward, its Minds, and the person's own place in it. It should +also be a pleasant place to be in its own right: emotes, sound, and weather in +the background. + +## Design truths (from rulings in force) + +- **We own the interface, not the engine.** The fork keeps Cinny's engine + (matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every + user-facing string and its translation is ours, and upstream locale files + are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`). +- **Mobile first.** On phones, inactive elements hide, vertical space is + cheaper than horizontal, and text never gets squeezed into narrow columns + (`eureka-body:ruling:chat-client-base-cinny-mobile-first`). +- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote + slot on every bubble (a pose id carried on the event); room and participant + lists tiered (category, then items) and reached from the header menu, not + side columns; participants grouped by estate (Minds, Members, Users, + Patrons); rooms grouped by kind (general, estates, projects, work rooms); + Minds indexed by stewardship, read from Matrix state projected from the + stewardship records in the Minds' own minds (the minds stay the truth); one + room per project, a Space child, with the steward joined. +- **Rooms are visible without a join dance.** No invite screen in the way; a + room the server lets you join shows in the same list as joined ones; opening + one shows its name and a one-tap Join; joining happens on that tap or a first + post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12 + "Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost"). + Rooms you may not enter are refused by the server, not hidden by the client + (Heimdall 04:10, accepted by Void 04:11 and 04:15). +- **Language.** A user's language is the BCP 47 tag in profile field + `org.gamecult.language`, owned by the user through the client's language + picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships + localized; people talk with stewards in their own language; rooms are made + for a language, with no per-language mirrors + (`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One + host Mind sits in a language room, Weksa for `#pt-br` + (`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this + must work for is a non-English, non-technical patron (placeholder + `patron-pt1`) whose account we set up and walk through login + (`gamecult-chat:ruling:patron-in-his-own-language`). +- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for + now; Web Push to the installed web app is a different thing and is open + (question `ratatoskr:question:pwa-web-push`). +- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in + Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not + deployed, so the plan below is its interim home; Atlas imports it later. + +## Body facts + +Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. + +1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main + `1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile): + a GameCult theme as default, member drawer hidden by default, bubble + layout, and steward grouping in `src/app/pages/client/space/Space.tsx` + (`useStewardGroups` at 429-437, group headers at 505-524) over + `useSpaceJoinedHierarchy` (409). Read by `git diff`. +2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the + client API, counts only). Root Space `#gamecult:gamecult.org`, room version + 12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52 + children; 50 readable rooms, all room version 12. Server `/hierarchy` from + the root returns 59 rooms, 2 of them spaces. **Every room's join rule is + `invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward` + with content `{mind, repo, source, steward: }`. The root carries + `org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`. +3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are + invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md` + 146-158: "Members and every Mind are invited; the steward alone joins"). + So a Member's project rooms are pending invites. Void's grouping reads + `useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot + read, so for a typical Member it groups nothing and the rooms stay in the + invite list. This is the operator's "three clicks deep" complaint, seen from + the data. +4. **Provisioning contract.** gamecult-ops + `scripts/synapse-accounts-hand-managed.py` writes the rooms and both state + events from the Minds' stewardship documents; each project room's alias is + `#` (runbook 146-150). This is the only place the client + can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` + joined to the root `org.gamecult.stewardships`. Follow-up + `ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to + name room ids so the client stops leaning on the alias convention. +5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses + matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the + Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2 + `/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold + start is therefore an IndexedDB load plus an incremental sync from the + stored token, not a full initial sync. +6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1 + (127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`: + simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it. +7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is + served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br + are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The + service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`, + a session `message`, and authenticated media `fetch` (79-158); the + `vite-plugin-pwa` config sets `injectionPoint: undefined` + (`vite.config.ts` 101-113), so nothing is precached. +8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler; + no code subscribes through `PushManager`; the only pusher the client sets is + the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx` + 22-40). Notifications and sounds fire only from a live page: + `src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258 + (messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at + 156, `notification.ogg` played at 175-178). Nothing in `src/` listens for + `visibilitychange` or calls `retryImmediately`. +9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read + 2026-10-31; the page itself is marked partly out of date): a `webpush` app + takes `vapid_private_key`, `vapid_contact_email`, optional + `allowed_endpoints` and `ttl`; the client's pusher `data` carries + `endpoint`, `auth`, `p256dh` and optional `default_payload`; the push + carries `room_id`, `room_name`, `sender_display_name`, `content`, unread + counts and so on; browsers need a visible notification for each push + (`events_only` avoids event-less pushes). + +10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image + `node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1 + file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61 + MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm + 5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with + 793 errors in 246 files (455 of them TS2614, named imports from + `matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as + is; a cut gates on zero errors in the files it adds and no rise in the + total. + +Not probed: phone frame times, the real resume timeline on the operator's +Android Chrome install (needs her device or remote DevTools), the Rust crypto +wasm size, and the router base under `/preview/` (its `config.json` sets +`hashRouter.basename` to `/`). + +## Why the PWA resumes slowly (diagnosis, moderate confidence) + +Android freezes a backgrounded installed web app and later discards it. On a +**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a +phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB +store load, then a catch-up `/sync` over every room since the stored token +(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and +the SDK only notices on its own timers because the client never nudges it on +`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms +and the swarm's chat volume, which is exactly where sliding sync helps +(fact 6), but the first two costs are cheaper to remove and should be measured +first. Without Web Push, a frozen page cannot notify at all, which is the +second half of the operator's report. + +## Model page: the estate + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Stewardship | `:stewardship:.n` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth | +| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) | +| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives | +| Room membership | the user's `m.room.member` | join on tap or first post | the user | +| Navigator view | derived per render | never stored | the client's estate index | + +## Authority map for the navigator + +- **Owner:** one estate index in the client (`src/app/features/estate/`): + `buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the + person sees, in which tier and group, and with what membership affordance. +- **Inputs:** the paged server `/hierarchy` of the root Space; root Space + state `org.gamecult.stewardships`; the person's membership per room from the + client store; unread and selection atoms for emphasis only; the `m.direct` + list for the direct-messages tier. +- **Outputs:** an ordered list of tiers (one per root room group and per + sub-Space, plus Direct), each holding steward groups or rooms, each room + marked joined, invited or joinable. +- **Derived:** steward group labels (Mind display names); counts; collapsed + state is per-device UI state only. +- **Demoted or dead:** the sidebar rail and its account-data ordering + (`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides + what is listed; Void's per-room steward reader is deleted, not kept beside + the index. +- **Shared paths:** every nav slot (home, direct, space routes) renders the + same navigator; tapping Join and sending a first post both go through + `mx.joinRoom`; deep links into a room still resolve through the existing + room routes. + +## Reconstructed collaboration plan (Atlas import) + +Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status +as of 2026-10-31 ~15:00. + +**Who holds which part** + +| Part | Holder | Status | Evidence | +|---|---|---|---| +| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report | +| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 | +| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` | +| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` | +| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." | +| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void | +| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall | + +**Agreements** + +- Dings are pre-rendered clips shipped as static assets, no synth in the + browser: `dings//.ogg` plus a small manifest; an + unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void + `#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the + instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound: + a mention, a DM, an invite, a message in an unmuted room; mentions and DMs + loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19). + The operator wants far more than four motifs (08:32). The instrument per + Mind belongs in a typed document on the Mind side, not hardcoded (Eureka + 03:19, advice). Aqua's ear check comes before anything is called done, and + the operator's ear is ground truth (Aqua 07:41). Browsers block audio until + the first click, so the first ding after a fresh load may be silent. +- The background has its own canvas and budget, separate from the bubble + flourishes; capped at 30 fps, paused when hidden; under reduced motion one + static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22). +- The background is an infinite raymarched terrain: CultMath noise plus the + Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm", + 05:24 "all we need is noise", 05:25 erosion, restated by the operator to + Self on 10-31: "use CultMath noise and erosion filter running in the shader + to have an infinite terrain. We can just raymarch that instead"). Voronoi + and Apollonian are dropped (05:24). +- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should + be the star of the show now, so really the camera should just pull back a + bit when you login and the personas should be orbiting the tree, dancing + around each other in accordance with their activity"). On login the camera + pulls back from the tree; there is no separate intro to cut away from. The + Personas orbit the tree and move around each other by their live activity. +- One activity vocabulary drives both sound and motion: Aqua's activity-kinds + grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent + activity in typed state") is the shared contract between Aqua's motifs and + Nibu's orbits, not two vocabularies. The live feed behind it is the same + typed state the swarm dashboard projects + (`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby; + `libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace. + What reaches the client is a member-safe projection: kind, Mind, time and + intensity, never briefs or mind contents. Matrix presence and typing cover + humans and are too coarse for Minds. Where the feed comes from is + `ratatoskr:question:activity-feed-source`. +- The scene renders in two passes, not one loop (operator to Self, 10-31: + "The tape optimized solid surface march should run really fast, i imagine, + way faster than the volumetric version, so they should not be one loop"). + Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces + with the field's Lipschitz bound and interval tile culling, at its own + resolution, and writes depth. Pass 2, the volumetric atmosphere march on + the site-ground framework (reduced resolution, TAA), is bounded by pass 1's + depth, so it marches only up to the surface hit. Each pass has its own + budget and its own measurement. Nibu owns both passes. +- The web math path is CultLib's own: HLSL source lowered by dxc and + SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, + `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through + .NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby + 06:34). No TS or WGSL port. The gamecult.org site ground already + concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled, + TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`, + spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the + base, not new tape work. +- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`, + `af_fbm` exist) is added in CultMath, not in the client + (`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what + you need and I'll fill it at the library"). +- Mood and flourish ride as metadata on the event, never guessed from text; + bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59). +- XP is two numbers, never one: a cosmetic, labelled game bar fed by things + worth doing, and contribution credit shown only once a real measure exists + (Void 06:45, with Nibu and Kiko). + +**Open items** + +- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath + tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be + probed before the march is trusted. +- Libby: public `fbm` in CultMath, with its GLSL mirror. +- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and + march and TAA tuning on the site ground's framework at a phone budget; + waits on Libby's `fbm` and the erosion bound. Then the intro scene in the + same world. +- Aqua: render the first earcon set once Faust builds in her sandbox; then the + instrument-per-Mind list and the manifest; then the soundscape design. +- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so + motifs and orbits read the same kinds. +- Void: the navigator (next cut); the Dings hook at + `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. +- Eureka: compression and caching for the shell on the chat host + (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's + room ids; a Web Push gateway if the operator picks it. + +## Cut sequence + +`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). +Each later cut is one line, with what it waits on. + +- `resume-fast`: precache the app shell in the service worker, nudge sync on + `visibilitychange`, and measure cold and warm resume on the operator's phone + before and after; waits on nothing; runs beside `estate-navigator` in its + own worktree, and pairs with the ops follow-up on compression. +- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a + `webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the + chat host; waits on `ratatoskr:question:pwa-web-push`. +- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which + the server already offers; waits on `resume-fast`'s measurements showing + catch-up sync dominates. +- `dings`: per-Mind earcons through the manifest at the existing notification + hook, and the same motifs in push notifications; waits on Aqua's renders. +- `emote-slot`: the pose-id emote on every bubble and the picker; waits on + `estate-navigator` (shared header work). +- `participant-tiers`: participants tiered by estate in the header menu, Minds + indexed by stewardship; waits on `estate-navigator`. +- `language`: our own strings and translations, the `org.gamecult.language` + picker, and the walk-through login for `patron-pt1`; waits on + `estate-navigator`. +- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on + the site ground's march and TAA framework, in its own canvas and budget, as + two passes (solid-surface sphere trace writing depth, then the volumetric + atmosphere bounded by it), each measured on a phone; Nibu on the scene and + both passes, Libby's CultMath underneath, Void + integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in + CultLib), then a phone measurement. +- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world; + on login the camera pulls back from the tree, and it may play during the + cold start or resume sync but never holds the client up; Nibu on the scene, + Libby on any CultMath it needs, Void integrating, Aqua's soundscape under + it; waits on `terrain-background`. +- `orbiting-personas`: Personas orbit the tree and dance around each other by + live activity, from the shared activity vocabulary; waits on + `yggdrasil-scene`, Aqua's activity-kinds grammar, and + `ratatoskr:question:activity-feed-source`. +- `bubbles-and-juice`: animated bubbles and mood flourishes from event + metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. +- `attention-bubbles`: per-person attention bubbles from Huginn's typed + attention document, scoped to room members; waits on Huginn publishing it. +- `rename-ratatoskr`: package name, app name and branding, manifest and + titles, the `/preview/` path and its deploy, and every in-repo `cinny` + reference that is ours (not upstream engine paths); waits on Void's forge + rename of `GameCult/cinny` to `GameCult/Ratatoskr` + (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo + list moves with it. diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs new file mode 100644 index 00000000..656d532e --- /dev/null +++ b/harness/estate/check.cjs @@ -0,0 +1,46 @@ +// Assertions for the estate drives: every drive states the outcome it exists to show and exits +// non-zero when the page or the fake homeserver's log says otherwise. +const fs = require('fs'); +const fails = []; +exports.expect = (label, ok, detail = '') => { + console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); + if (!ok) fails.push(label); +}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 0); +/** The fake homeserver's request log (HSLOG is shared with the server process). */ +exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); +/** Bodies of the account-data PUTs for m.direct, in order. */ +exports.mDirectPuts = () => + exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); +/** Join requests the server saw for a room id. */ +exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ +const tierOf = (pg, name) => + pg.evaluate((n) => { + const s = document.body.innerText.split('\n').map((x) => x.trim()); + const i = s.indexOf(n); + if (i < 0) return 'absent'; + for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; + return 'none-above'; + }, name); +/** + * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is + * opened only if the row is not already showing, so reading never closes what is open. + */ +exports.revealedTier = async (pg, name) => { + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(2500); + let t = await tierOf(pg, name); + for (const h of HEADERS) { + if (t !== 'absent') break; + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } + } + return t; +}; +/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ +exports.sawText = (pg, re, ms) => + pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); +exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs new file mode 100644 index 00000000..d53d88a4 --- /dev/null +++ b/harness/estate/drive-failures.cjs @@ -0,0 +1,72 @@ +// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh +// against fakehs.cjs with the env flag each mode needs. +const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); +const MODE = process.env.MODE; +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; +// Close the browser, but never wait on it: a hung close must not hang the run. +const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); }); + const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => revealedTier(pg, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); + const acceptInInbox = async (name) => { + // reach the inbox through the navigator's own Inbox tab + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); + const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count()); + if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); } + const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); } + log('URL', pg.url()); + if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); } + { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } } + for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); } + const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120))); + const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last(); + await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t()); + }; + if (MODE === 'pjfail') { + await tap('Friend DM'); await pg.waitForTimeout(3000); + const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); + expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); + expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); + expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); + } else if (MODE === 'jdrop') { + await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); + expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); + await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); + expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); + expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); + } else if (MODE === 'admin') { + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); + expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); + } else if (MODE === 'inboxadmin') { + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); + expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); + } else if (MODE === 'inboxreload') { + await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); + await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); + expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); + } else if (MODE === 'pjreload') { + await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); + await pg.waitForTimeout(8000); await tiers('pjreload'); + const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); + expect('a join cut off by a reload stays joinable', n === 1, String(n)); + } + await quit(done()); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs new file mode 100644 index 00000000..75c9a8df --- /dev/null +++ b/harness/estate/drive-routes.cjs @@ -0,0 +1,42 @@ +const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); +const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + let joined = false; + if (MODE === 'deeplink') { + for (const route of ['direct', 'home']) { + await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); + const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); + const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); + if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + } + const f = await tiers('after deeplink join'); + const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); + const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; + expect('the deep link offers one Join button', joined, 'no Join button'); + expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); + expect('the DM is filed under Direct Messages', isDm(f), f); + } else if (MODE === 'gw504') { + const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); + for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); + const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); + expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); + await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); + expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); + expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); + } + await quit(done()); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs new file mode 100644 index 00000000..03bd2ad0 --- /dev/null +++ b/harness/estate/drive.cjs @@ -0,0 +1,48 @@ +const { chromium } = require('playwright'); +const { expect, done, isDm, revealedTier } = require('./check.cjs'); +const MODE = process.env.MODE; // twodm | reload +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let tierBefore; +(async () => { + const b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); }); + const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const text = () => pg.evaluate(() => document.body.innerText); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => revealedTier(pg, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + if (MODE === 'twodm') { + await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); + await pg.waitForTimeout(45000); + } else if (MODE === 'convert') { + await tap('Friend DM'); await pg.waitForTimeout(6000); + tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); + await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); + const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); + await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); + log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600))); + await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300)); + await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>''))); + if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); } + await pg.waitForTimeout(8000); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + } else { + await tap('Slow DM'); await pg.waitForTimeout(3000); + log('RELOAD', t()); await open(); + await pg.waitForTimeout(30000); + } + const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); + log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); + if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } + else if (MODE === 'convert') { + expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); + expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); + } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); + await b.close(); process.exitCode = done(); +})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs new file mode 100644 index 00000000..4a6875e0 --- /dev/null +++ b/harness/estate/fakehs.cjs @@ -0,0 +1,127 @@ +// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content. +// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s); +// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast); +// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve. +const http = require('http'), fs = require('fs'), path = require('path'); +const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const NOROOT = process.env.NOROOT === '1'; +const log = []; +const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; +const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test'; +const SLOW = new Set([PROJ[5].id, DM2, DM3]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; +PROJ.forEach((r) => (NAMES[r.id] = r.name)); +const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0; +const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); +const strip = (id, name, space, direct) => [ + { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, + { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, + ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), + { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, +]; +const joinedRoom = (id, name, extra = [], space = false) => ({ + state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, + timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false }, + ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {}, +}); +const invites = () => { + const o = {}; + o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } }; + o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; + o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; + PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); + o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; + o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; + o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } }; + return o; +}; +const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); +const hierarchy = () => [ + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }), + hroom(GEN, 'General', '#general:test'), + hroom(OP, 'Operator', '#operator:test'), + hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }), + ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), +]; +let syncN = 1; +const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); }; +const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; +const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n')); +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname); + if (req.method === 'OPTIONS') return json(res, 200, {}); + if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) { + let f = path.join(DIST, p === '/' ? 'index.html' : p); + if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html'); + res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' }); + return fs.createReadStream(f).pipe(res); + } + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body); + log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); + if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); + if (p.endsWith('/sync')) { + const since = u.searchParams.get('since'); + if (!since) { + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); + return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + } + const respond = () => { + const now = Date.now(); const join = {}; + for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { + const id = pending[i].id; pending.splice(i, 1); + const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); + } + for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } + const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks + if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); + return json(res, 200, { next_batch: since, rooms: {} }); + }; + return setTimeout(respond, 300); + } + const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); + if (req.method === 'POST' && jm) { + const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); + if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); + const prior = pending.find((q) => q.id === DM2 || q.id === DM3); + pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } + if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } + if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } + return json(res, 200, { room_id: id }); + } + const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); + if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } + if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); } + if (p.includes('/account_data/') && req.method === 'PUT') { + const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1'; + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); } + return json(res, 200, {}); + } + if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); + if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); + if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); + if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); + if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); + if (p.includes('/members')) return json(res, 200, { chunk: [] }); + if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); + if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} }); + if (p.includes('/devices')) return json(res, 200, { devices: [] }); + if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' }); + if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] }); + return json(res, 200, {}); + }); +}).listen(PORT, () => console.log('fakehs up')); +setInterval(flush, 300); diff --git a/harness/estate/run.sh b/harness/estate/run.sh new file mode 100644 index 00000000..65242ab0 --- /dev/null +++ b/harness/estate/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: +# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs. +set -e +cd /src +npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } +npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } +echo BUILD_OK $(git rev-parse HEAD) +printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 +cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +set +e +for m in twodm reload convert; do + HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" + kill $HS; wait $HS 2>/dev/null; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt +done +failure() { m=$1; shift + env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" + kill $HS; wait $HS 2>/dev/null; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } +failure pjfail FAIL_MDIRECT=1 +failure pjreload DELAY_MDIRECT=1 +failure jdrop JOIN_DROP=1 +failure admin INVITER_ADMIN=1 +failure inboxadmin INVITER_ADMIN=1 +failure inboxreload JOIN_DELAY=1 +DRIVER=drive-routes failure deeplink +DRIVER=drive-routes failure gw504 JOIN_504=1 +echo "FAILED_DRIVES:${FAILED:- none}" +[ -z "$FAILED" ] diff --git a/package-lock.json b/package-lock.json index 0e089645..dc774000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "cinny", - "version": "4.12.7", + "version": "4.12.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.12.7", + "version": "4.12.8", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -32,21 +35,20 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.7.3", + "folds": "2.7.2", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "4.0.3", - "idb": "8.0.3", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", - "matrix-widget-api": "1.20.0", + "matrix-js-sdk": "41.7.0", + "matrix-widget-api": "1.16.1", "millify": "6.1.0", - "neurone": "0.0.9", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", @@ -59,7 +61,7 @@ "react-i18next": "15.0.0", "react-range": "1.8.14", "react-router-dom": "6.30.6", - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "slate": "0.123.0", "slate-dom": "0.123.0", "slate-history": "0.113.1", @@ -67,7 +69,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.28.0", + "@element-hq/element-call-embedded": "0.20.1", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -96,11 +98,12 @@ "husky": "9.1.7", "lint-staged": "16.3.2", "prettier": "2.8.1", - "typescript": "6.0.3", + "typescript": "4.9.4", "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" }, "engines": { "node": ">=16.0.0" @@ -1775,9 +1778,9 @@ } }, "node_modules/@element-hq/element-call-embedded": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.28.0.tgz", - "integrity": "sha512-wM5tLXrSCLbveGlvQg1Pj6dNAp75mhJe2Zr3Vb8dtK6z9tuuFMcsn1q1Gjy1KYWQdzBzKK2wLc1O8H7DzYZRHQ==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz", + "integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==", "dev": true }, "node_modules/@emotion/hash": { @@ -2189,11 +2192,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", + "integrity": "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/inter": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", "integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q==" }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.5.tgz", + "integrity": "sha512-/OA+bdg1yqUArDUnEeCqDwgTDiIpvED56TxYskje8UKpsvjl/0A9eNod7IcdHz6/Osi70vo7briRO3la8/u+5Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ubuntu": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ubuntu/-/ubuntu-5.2.5.tgz", + "integrity": "sha512-VKVFVqmJ9MGnOJW2dsQ982qHN8Zr+tNeEwEHnzt5VdGi9ZWL37wYFD32YsqukWlI/+I7v5ZkgHTEAvsZxU6aDA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", @@ -2308,6 +2338,19 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2348,9 +2391,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -4331,6 +4375,13 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5131,6 +5182,143 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5157,6 +5345,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5449,6 +5650,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -5858,6 +6069,25 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5880,6 +6110,19 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6216,6 +6459,19 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "optional": true }, + "node_modules/content-type": { + "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": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/conventional-commit-types": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", @@ -6602,6 +6858,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6703,6 +6972,16 @@ "node": ">=8" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7626,6 +7905,85 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -7894,9 +8252,9 @@ } }, "node_modules/folds": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.7.3.tgz", - "integrity": "sha512-oMdMOBmU5RCUI4nmQKiEKpJNh+ZmCijvR4+aalk/btZPp1jIRGha7MXBb2NFOy1y9od+RynNFp+bw6E7GMATBA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.7.2.tgz", + "integrity": "sha512-T/PQuN+ku/55hMVabamwsGp1uPSKkFw4xPEf7uZjAZNoejUL1s0leecA00sudYM2HvZaIUzUrosp+f0+8rWC6w==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", @@ -8066,6 +8424,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", @@ -8109,6 +8477,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8513,6 +8894,16 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -8581,10 +8972,10 @@ } }, "node_modules/idb": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", - "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", - "license": "ISC" + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true }, "node_modules/ieee754": { "version": "1.2.1", @@ -9014,6 +9405,18 @@ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9072,9 +9475,10 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9436,6 +9840,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9464,14 +9877,20 @@ } }, "node_modules/launder": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", - "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", + "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", "license": "MIT", "dependencies": { - "dayjs": "^1.11.7" + "dayjs": "^1.11.23" } }, + "node_modules/launder/node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9679,6 +10098,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9977,6 +10413,19 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/longest": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", @@ -9998,6 +10447,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10051,10 +10510,40 @@ "node": ">= 0.4" } }, + "node_modules/matrix-events-sdk": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", + "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==", + "license": "Apache-2.0" + }, + "node_modules/matrix-js-sdk": { + "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.3.1", + "another-json": "^0.2.0", + "bs58": "^6.0.0", + "content-type": "^2.0.0", + "jwt-decode": "^4.0.0", + "loglevel": "^1.9.2", + "matrix-events-sdk": "0.0.1", + "matrix-widget-api": "^1.16.1", + "oidc-client-ts": "^3.0.1", + "p-retry": "8", + "sdp-transform": "^3.0.0", + "unhomoglyph": "^1.0.6" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/matrix-widget-api": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz", - "integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz", + "integrity": "sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", @@ -10090,6 +10579,13 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10278,15 +10774,16 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -10306,24 +10803,6 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, - "node_modules/neurone": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/neurone/-/neurone-0.0.9.tgz", - "integrity": "sha512-A//2eeyxJLwuDGCZh7N6jNy0tloQ1pgY/0pNna5SrOsj5UPKb2W7U3wE9+2q7sN2kT+0SZr2xijfi06EGkrlyQ==", - "license": "Apache-2.0", - "dependencies": { - "@matrix-org/matrix-sdk-crypto-wasm": "18.3.1", - "another-json": "0.2.0", - "bs58": "6.0.0", - "idb": "8.0.0" - } - }, - "node_modules/neurone/node_modules/idb": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", - "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==", - "license": "ISC" - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10373,6 +10852,35 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -10516,6 +11024,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-client-ts": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz", + "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10655,6 +11175,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz", + "integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.3.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -10737,6 +11272,16 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==" }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/pdfjs-dist": { "version": "4.2.67", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz", @@ -10787,9 +11332,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -10804,10 +11349,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -10877,6 +11423,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11590,18 +12171,17 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.7", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", - "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", + "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==", "license": "MIT", "dependencies": { - "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", - "is-plain-object": "^5.0.0", - "launder": "^1.7.1", + "is-plain-object": "^5.1.0", + "launder": "^1.7.2", "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" + "postcss": "^8.5.27" }, "engines": { "node": ">=22.12.0" @@ -11725,6 +12305,15 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/sdp-transform": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz", + "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -11891,6 +12480,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12058,9 +12654,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12117,6 +12714,20 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12280,6 +12891,19 @@ "node": ">=10" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12292,6 +12916,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz", @@ -12423,6 +13067,13 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -12450,6 +13101,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -12543,6 +13214,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -12630,9 +13311,9 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", + "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -12640,7 +13321,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/ua-parser-js": { @@ -12684,6 +13365,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unhomoglyph": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", + "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==", + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -13365,6 +14052,89 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -13498,6 +14268,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -13526,13 +14313,6 @@ "workbox-core": "7.3.0" } }, - "node_modules/workbox-background-sync/node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true, - "license": "ISC" - }, "node_modules/workbox-broadcast-update": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz", @@ -13679,13 +14459,6 @@ "workbox-core": "7.3.0" } }, - "node_modules/workbox-expiration/node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true, - "license": "ISC" - }, "node_modules/workbox-google-analytics": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz", diff --git a/package.json b/package.json index 054bb1cb..298fbf36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.12.7", + "version": "4.12.8", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -18,7 +18,8 @@ "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js" + "bump": "node scripts/update-version.js", + "test": "vitest run" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -56,21 +57,20 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.7.3", + "folds": "2.7.2", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "4.0.3", - "idb": "8.0.3", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", - "matrix-widget-api": "1.20.0", + "matrix-js-sdk": "41.7.0", + "matrix-widget-api": "1.16.1", "millify": "6.1.0", - "neurone": "0.0.9", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", @@ -83,15 +83,18 @@ "react-i18next": "15.0.0", "react-range": "1.8.14", "react-router-dom": "6.30.6", - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "slate": "0.123.0", "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35" + "ua-parser-js": "1.0.35", + "@fontsource/ubuntu": "5.2.5", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ibm-plex-mono": "5.2.5" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.28.0", + "@element-hq/element-call-embedded": "0.20.1", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -120,10 +123,11 @@ "husky": "9.1.7", "lint-staged": "16.3.2", "prettier": "2.8.1", - "typescript": "6.0.3", + "typescript": "4.9.4", "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" } } diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index d328a849..2dbaf1f1 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -14,7 +14,7 @@ import { Scroll, config, } from 'folds'; -import { AccountDataEvent } from 'neurone/lib/api/types'; +import { MatrixError } from 'matrix-js-sdk'; import { Cursor } from '../plugins/text-area'; import { syntaxErrorPosition } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; @@ -28,12 +28,17 @@ const EDITOR_INTENT_SPACE_COUNT = 2; export type AccountDataSubmitCallback = (type: string, content: object) => Promise; +type AccountDataInfo = { + type: string; + content: object; +}; + type AccountDataEditProps = { type: string; defaultContent: string; submitChange: AccountDataSubmitCallback; onCancel: () => void; - onSave: (info: AccountDataEvent) => void; + onSave: (info: AccountDataInfo) => void; }; function AccountDataEdit({ type, @@ -52,7 +57,7 @@ function AccountDataEdit({ EDITOR_INTENT_SPACE_COUNT ); - const [submitState, submit] = useAsyncCallback(submitChange); + const [submitState, submit] = useAsyncCallback(submitChange); const submitting = submitState.status === AsyncStatus.Loading; const handleSubmit: FormEventHandler = (evt) => { @@ -154,9 +159,7 @@ function AccountDataEdit({ {submitState.status === AsyncStatus.Error && ( - - {submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'} - + {submitState.error.message} )} @@ -239,30 +242,34 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) } export type AccountDataEditorProps = { - event?: AccountDataEvent; + type?: string; + content?: object; submitChange: AccountDataSubmitCallback; requestClose: () => void; }; -export function AccountDataEditor({ event, submitChange, requestClose }: AccountDataEditorProps) { - const [data, setData] = useState( - event ?? { - type: '', - content: {}, - } - ); +export function AccountDataEditor({ + type, + content, + submitChange, + requestClose, +}: AccountDataEditorProps) { + const [data, setData] = useState({ + type: type ?? '', + content: content ?? {}, + }); - const [edit, setEdit] = useState(!data.type); + const [edit, setEdit] = useState(!type); const closeEdit = useCallback(() => { - if (!event) { + if (!type) { requestClose(); return; } setEdit(false); - }, [event, requestClose]); + }, [type, requestClose]); - const handleSave = useCallback((info: AccountDataEvent) => { + const handleSave = useCallback((info: AccountDataInfo) => { setData(info); setEdit(false); }, []); diff --git a/src/app/components/ActionUIA.tsx b/src/app/components/ActionUIA.tsx index 63bd5a69..b9cd122f 100644 --- a/src/app/components/ActionUIA.tsx +++ b/src/app/components/ActionUIA.tsx @@ -1,48 +1,33 @@ -import React, { ReactNode, useState } from 'react'; -import { - config, - Dialog, - Header, - Text, - IconButton, - Box, - Icon, - Icons, - Button, - OverlayBackdrop, - Overlay, - OverlayCenter, -} from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { AuthResponse, AuthType, getUIAFlowForStages, UIAFlow } from '../utils/matrix-uia'; +import React, { ReactNode } from 'react'; +import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk'; +import { getUIAFlowForStages } from '../utils/matrix-uia'; import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows'; import { UIAFlowOverlay } from './UIAFlowOverlay'; -import { OAuthStage, PasswordStage, SSOStage } from './uia-stages'; -import { useClient } from '../hooks/useClient'; +import { PasswordStage, SSOStage } from './uia-stages'; +import { useMatrixClient } from '../hooks/useMatrixClient'; -export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso, AuthType.OAuth]; +export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso]; + +export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => { + const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]); + if (passwordFlow) return passwordFlow; + return getUIAFlowForStages(uiaFlows, [AuthType.Sso]); +}; type ActionUIAProps = { - authData: AuthResponse; + authData: IAuthData; ongoingFlow: UIAFlow; - action: (authDict: object) => void; + action: (authDict: AuthDict) => void; onCancel: () => void; }; export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) { - const ct = useClient(); + const mx = useMatrixClient(); const completed = useUIACompleted(authData); const { getStageToComplete } = useUIAFlow(authData, ongoingFlow); const stageToComplete = getStageToComplete(); if (!stageToComplete) return null; - - const ssoRedirectURL = new URL(`/auth/${AuthType.Sso}/fallback/web`, ct.session.baseUrl); - - if (stageToComplete.session) { - ssoRedirectURL.searchParams.set('session', stageToComplete.session); - } - return ( {stageToComplete.type === AuthType.Password && ( )} - {stageToComplete.type === AuthType.OAuth && ( - - )} ); } -function UIAFlowPicker({ - flows, - onCancel, - onSelect, -}: { - flows: UIAFlow[]; - onCancel: () => void; - onSelect: (flow: UIAFlow) => void; -}) { - const passwordFlow = getUIAFlowForStages(flows, [AuthType.Password]); - const ssoFlow = getUIAFlowForStages(flows, [AuthType.Sso]); - - return ( - }> - - - -
- - Authentication - - - - -
- - - Choose an authentication method to perform this action. - - - {passwordFlow && ( - - )} - {ssoFlow && ( - - )} - - -
-
-
-
- ); -} - type ActionUIAFlowsLoaderProps = { - authData: AuthResponse; - onCancel: () => void; + authData: IAuthData; unsupported: () => ReactNode; children: (ongoingFlow: UIAFlow) => ReactNode; }; export function ActionUIAFlowsLoader({ authData, - onCancel, unsupported, children, }: ActionUIAFlowsLoaderProps) { const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES); - const [ongoingFlow, setOngoingFlow] = useState( - supportedFlows.length === 1 ? supportedFlows[0] : undefined - ); + const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined; - if (supportedFlows.length === 0) return unsupported(); - - if (!ongoingFlow) { - return ; - } + if (!ongoingFlow) return unsupported(); return children(ongoingFlow); } diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx index 14ba56b2..f21bad04 100644 --- a/src/app/components/AuthFlowsLoader.tsx +++ b/src/app/components/AuthFlowsLoader.tsx @@ -1,18 +1,14 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react'; -import { getLogin, GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login'; -import { postRegister } from 'neurone/lib/api/matrix/client/v3/register'; -import { MatrixErrorData, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; +import { MatrixError, createClient } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo'; -import { promiseFulfilledResult } from '../utils/common'; +import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common'; import { AuthFlows, RegisterFlowStatus, RegisterFlowsResponse, parseRegisterErrResp, } from '../hooks/useAuthFlows'; -import { getJSONRequest } from '../../client/request'; -import { AuthResponse } from '../utils/matrix-uia'; type AuthFlowsLoaderProps = { fallback?: () => ReactNode; @@ -23,33 +19,24 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr const autoDiscoveryInfo = useAutoDiscoveryInfo(); const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url; - const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]); + const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); const [state, load] = useAsyncCallback( useCallback(async () => { - const loginEndpoint = getLogin({}); - const registerEndpoint = postRegister({}); - - const [loginResponse, registerResponse] = ( - await Promise.allSettled([requestJSON(loginEndpoint), requestJSON(registerEndpoint)]) - ).map(promiseFulfilledResult); - + const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]); + const loginFlows = promiseFulfilledResult(result[0]); + const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined; let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest }; - const [registerError] = - typeof registerResponse === 'object' - ? await parseMatrixResp(registerResponse) - : []; - if (registerError) { - registerFlows = parseRegisterErrResp(registerError); + if (typeof registerResp === 'object' && registerResp.httpStatus) { + registerFlows = parseRegisterErrResp(registerResp); } - if (!loginResponse) { + if (!loginFlows) { throw new Error('Missing auth flow!'); } - const [loginError, loginFlows] = await parseMatrixResp(loginResponse); - if (loginError) { - throw new Error(`Failed to load auth flow! ${printMatrixError(loginError)}`); + if ('errcode' in loginFlows) { + throw new Error('Failed to load auth flow!'); } const authFlows: AuthFlows = { @@ -58,7 +45,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr }; return authFlows; - }, [requestJSON]) + }, [mx]) ); useEffect(() => { diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index bd6289a3..068f437b 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -1,4 +1,6 @@ import React, { MouseEventHandler, useCallback, useState } from 'react'; +import { useAtom } from 'jotai'; +import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api'; import { Badge, Box, @@ -17,113 +19,130 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore'; import { InfoCard } from './info-card'; +import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; +import { + useKeyBackupInfo, + useKeyBackupStatus, + useKeyBackupSync, + useKeyBackupTrust, +} from '../hooks/useKeyBackup'; import { stopPropagation } from '../utils/keyboard'; -import { bytesToSize } from '../utils/common'; -import { useObservableValue } from '../hooks/useObservableValue'; -import { useClient } from '../hooks/useClient'; -import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; +import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; type BackupStatusProps = { - active: boolean; + enabled: boolean; }; -function BackupStatus({ active }: BackupStatusProps) { +function BackupStatus({ enabled }: BackupStatusProps) { return ( - + - {active ? 'Connected' : 'Disconnected'} + {enabled ? 'Connected' : 'Disconnected'} ); } - -function BackupSyncing() { - const ct = useClient(); - const [countState] = useAsyncCallbackValue(useCallback(() => ct.machine.roomKeys.count(), [ct])); - - const count = countState.status === AsyncStatus.Success ? countState.data : undefined; - - const remaining = count ? count.total - count.backedUp : undefined; - +type BackupSyncingProps = { + count: number; +}; +function BackupSyncing({ count }: BackupSyncingProps) { return ( - Syncing ({remaining ?? '...'}) + Syncing ({count}) ); } -function BackupProgressFetching({ - receivedBytes, - totalBytes, -}: { - receivedBytes: number; - totalBytes?: number; -}) { - const receviedStr = bytesToSize(receivedBytes); - const totalStr = totalBytes ? bytesToSize(totalBytes) : undefined; - +function BackupProgressFetching() { return ( - Downloading + Restoring: 0% - + - - {totalStr ? `${receivedBytes} / ${totalStr}` : receviedStr} - + ); } type BackupProgressProps = { - total?: number; - imported: number; + total: number; + downloaded: number; }; -function BackupProgress({ total, imported }: BackupProgressProps) { +function BackupProgress({ total, downloaded }: BackupProgressProps) { return ( - - Restoring: {total ? `${Math.round(percent(0, total, imported))}%` : '0%'} - + Restoring: {`${Math.round(percent(0, total, downloaded))}%`} - + - {total && ( - - - {imported} / {total} - - + + + {downloaded} / {total} + + + + ); +} + +type BackupTrustInfoProps = { + crypto: CryptoApi; + backupInfo: KeyBackupInfo; +}; +function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) { + const trust = useKeyBackupTrust(crypto, backupInfo); + + if (!trust) return null; + + return ( + + {trust.matchesDecryptionKey ? ( + + Backup has trusted decryption key. + + ) : ( + + Backup does not have trusted decryption key! + + )} + {trust.trusted ? ( + + Backup has trusted by signature. + + ) : ( + + Backup does not have trusted signature! + )} ); } -export function BackupRestoreTile() { - const ct = useClient(); - const backupInfo = useObservableValue(ct.backupInfo); - const keyBackup = useObservableValue(ct.megolmBackup.keyBackup); - const restoreState = useObservableValue(ct.megolmBackup.restoreState); - const backupInProgress = useObservableValue(ct.megolmBackup.backupInProgress); - const restoring = restoreState.type === 'download' || restoreState.type === 'import'; +type BackupRestoreTileProps = { + crypto: CryptoApi; +}; +export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { + const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom); + const restoring = + restoreProgress.status === BackupProgressStatus.Fetching || + restoreProgress.status === BackupProgressStatus.Loading; + + const backupEnabled = useKeyBackupStatus(crypto); + const backupInfo = useKeyBackupInfo(crypto); + const [remainingSession, syncFailure] = useKeyBackupSync(); const [menuCords, setMenuCords] = useState(); @@ -131,11 +150,19 @@ export function BackupRestoreTile() { setMenuCords(evt.currentTarget.getBoundingClientRect()); }; + const [restoreState, restoreBackup] = useAsyncCallback( + useCallback(async () => { + await crypto.restoreKeyBackup({ + progressCallback(progress) { + setRestoreProgress(progress); + }, + }); + }, [crypto, setRestoreProgress]) + ); + const handleRestore = () => { setMenuCords(undefined); - if (keyBackup) { - ct.megolmBackup.restore(keyBackup); - } + restoreBackup(); }; return ( @@ -144,99 +171,111 @@ export function BackupRestoreTile() { title="Encryption Backup" after={ - {backupInfo && ( - <> - {backupInProgress ? : } - - - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - - Version: {backupInfo.version} -
- Keys: {backupInfo.count} - - } - /> -
- -
-
- - } - /> - + {remainingSession === 0 ? ( + + ) : ( + )} + + + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + + + Version: {backupInfo?.version ?? 'NIL'} +
+ Keys: {backupInfo?.count ?? 'NIL'} + + } + /> +
+ +
+
+ + } + />
} > - {!backupInfo && ( + {syncFailure && ( - No key backup has been configured. + {syncFailure} )} - - {backupInfo && !keyBackup && ( + {!backupEnabled && backupInfo === null && ( - Cannot start key backup because the backup could not be verified. + No backup present on server! )} - {restoreState.type === 'download' && ( - + {!syncFailure && !backupEnabled && backupInfo && ( + )} - {restoreState.type === 'import' && ( + {restoreState.status === AsyncStatus.Loading && !restoring && } + {restoreProgress.status === BackupProgressStatus.Fetching && } + {restoreProgress.status === BackupProgressStatus.Loading && ( )} - {restoreState.type === 'error' && ( + {restoreState.status === AsyncStatus.Error && ( - {restoreState.error.data.error ?? restoreState.error.data.errcode} + {restoreState.error.message} )} ); } + +export function AutoRestoreBackupOnVerification() { + useRestoreBackupOnVerification(); + + return null; +} diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 6c346b58..a6f6dec5 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -17,10 +17,15 @@ import { Text, toRem, } from 'folds'; -import { putSend } from 'neurone/lib/api/matrix/client/v3/rooms'; -import { roomTimeline } from 'neurone/lib/utils/sync/v3/join'; -import { ClientEventWithoutRoomId } from 'neurone/lib/api/types'; -import { ObjectReader } from 'neurone/lib/utils'; +import { + EventTimelineSetHandlerMap, + EventType, + RelationType, + Room, + RoomEvent, +} from 'matrix-js-sdk'; +import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types'; +import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, @@ -34,73 +39,57 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; +import { useMatrixClient } from '../hooks/useMatrixClient'; import CallSound from '../../../public/sound/call.ogg'; import { useCallMembersChange, useCallSession } from '../hooks/useCall'; +import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { mDirectAtom } from '../state/mDirectList'; import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; +import { mxcUrlToHttp } from '../utils/matrix'; import { RoomAvatar, RoomIcon } from './room-avatar'; import { useRoomNavigate } from '../hooks/useRoomNavigate'; -import { CallEventType, MessageEvent, StateEvent, StateEventType } from '../../types/matrix/room'; +import { getStateEvent } from '../utils/room'; +import { StateEvent } from '../../types/matrix/room'; +import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; +import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; +import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions'; import { useLivekitSupport } from '../hooks/useLivekitSupport'; import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; -import { useClient } from '../hooks/useClient'; -import { useGetRoomName } from '../hooks/storage/useGetRoomName'; -import { useRoomHeroes } from '../hooks/storage/useRoomHeros'; -import { useGetRoomAvatar } from '../hooks/storage/useGetRoomAvatar'; -import { mxcToHttp } from '../../client/mxcToHttp'; -import { RelationType } from '../../types/matrix/common'; -import { ensureEncryptionSession } from '../hooks/useEnsureRoomEncryptionSession'; -import { SyncValue } from '../../client/sync'; -import { isCallRoom } from '../utils/room'; -import { reference } from '../../client/room/relations'; -import { rContent, rMentions, rRelatesTo } from '../../client/reader'; -import { getJoinRoomStateId } from '../../client/storage/collections'; -import { getPowerLevels } from '../hooks/usePowerLevels'; -import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions'; type IncomingCallInfo = { - roomId: string; + room: Room; sender: string; senderTs: number; lifetime: number; intent?: string; - silent?: boolean; + notificationType: RTCNotificationType; refEventId: string; }; type IncomingCallProps = { dm: boolean; info: IncomingCallInfo; onIgnore: () => void; - onAnswer: (roomId: string, video: boolean) => void; - onReject: (roomId: string, eventId: string) => void; + onAnswer: (room: Room, video: boolean) => void; + onReject: (room: Room, eventId: string) => void; }; function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) { - const ct = useClient(); + const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const livekitSupported = useLivekitSupport(); const rtcSupported = webRTCSupported(); const canAnswer = livekitSupported && rtcSupported; - const { roomId } = info; + const { room } = info; const audioRef = useRef(null); - const getRoomHeroes = useRoomHeroes(); - const getRoomName = useGetRoomName(getRoomHeroes); - const getRoomAvatar = useGetRoomAvatar(getRoomHeroes); - - const roomName = getRoomName(roomId); - const roomAvatar = getRoomAvatar(roomId); - + const roomName = useRoomName(room); + const roomAvatar = useRoomAvatar(room, dm); const avatarUrl = roomAvatar - ? mxcToHttp(ct.session.baseUrl, roomAvatar, useAuthentication).thumbnail({ - width: 96, - height: 96, - method: 'crop', - }) + ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; - const session = useCallSession(roomId); + const session = useCallSession(room); useCallMembersChange( session, useCallback( @@ -119,10 +108,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr }, []); useEffect(() => { - if (!info.silent) { + if (info.notificationType === 'ring') { playSound(); } - }, [playSound, info.silent]); + }, [playSound, info.notificationType]); return ( <> @@ -145,14 +134,14 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr ( )} @@ -192,7 +181,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr variant="Success" size="400" radii="400" - onClick={() => onAnswer(roomId, info.intent === 'video')} + onClick={() => onAnswer(room, info.intent === 'video')} before={ (dm ? onReject(roomId, info.refEventId) : onIgnore())} + onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} before={} > @@ -237,150 +226,117 @@ type IncomingCallListenerProps = { joined?: boolean; }; function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) { - const ct = useClient(); + const mx = useMatrixClient(); const directs = useAtomValue(mDirectAtom); const { navigateRoom } = useRoomNavigate(); const [callInfo, setCallInfo] = useState(); - const dm = callInfo ? directs.has(callInfo.roomId) : false; - const startCall = useCallStart(); + const dm = callInfo ? directs.has(callInfo.room.roomId) : false; + const startCall = useCallStart(dm); - const handleReferenceEvent = useCallback( - async (roomId: string, event: ClientEventWithoutRoomId) => { - let targetEvent = event; + const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( + async (event, room, toStartOfTimeline, removed, data) => { + // only process rtc notification reference events. + // we do not want to wait to decrypt all events. + if (event.getRelation()?.rel_type !== RelationType.Reference) return; + if (room?.isCallRoom()) return; - if (ct.encryptedRooms.read.has(roomId) && event.type === MessageEvent.RoomMessageEncrypted) { - targetEvent = await ct.decryption.tryDecryptEvent(roomId, event); + if (event.isEncrypted()) { + if (!event.isBeingDecrypted()) { + await event.attemptDecryption(mx.getCrypto() as CryptoBackend); + } + await event.getDecryptionPromise(); } if ( - targetEvent.type !== CallEventType.RTCInvite || - targetEvent.sender === ct.session.userId + !room || + event.getType() !== EventType.RTCNotification || + event.getSender() === mx.getSafeUserId() || + !data.liveEvent ) { return; } - const { sender, content } = targetEvent; - - const senderTs = ObjectReader.number(content, 'sender_ts') ?? targetEvent.origin_server_ts; - const lifetime = Math.min(ObjectReader.number(content, 'lifetime') ?? 120000, 120000); - - const refEventId = rRelatesTo.eventId(rContent.relatesTo(content)); + const sender = event.getSender(); + const content = event.getContent(); + const senderTs = + content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts; + const lifetime = Math.min(content.lifetime, 120000); + const notificationType = content.notification_type; + const relation = + event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined; + const refEventId = relation?.event_id; const mention = - rMentions.room(rContent.mentions(content)) || - rMentions.userIds(rContent.mentions(content))?.includes(ct.session.userId); - - if (!refEventId || !mention || Date.now() >= senderTs + lifetime) { + content['m.mentions']?.room || + content['m.mentions']?.user_ids?.includes(mx.getSafeUserId()); + if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) { return; } - const powerLevelsObj = await ct.storage.joinRoomState.read.get( - getJoinRoomStateId(roomId, StateEventType.RoomPowerLevels, '') - ); - if (!powerLevelsObj) return; - const powerLevels = getPowerLevels(powerLevelsObj); - const creators = ct.roomToCreators.read.get(roomId); + const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels); + const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent); + const creators = getRoomCreatorsForRoomId(mx, room.roomId); const permissions = getRoomPermissionsAPI(creators, powerLevels); const hasCallPermission = permissions.stateEvent( StateEvent.GroupCallMemberPrefix, - ct.session.userId + mx.getSafeUserId() ); if (!hasCallPermission) return; const info: IncomingCallInfo = { - roomId, + room, sender, senderTs, lifetime, - intent: ObjectReader.string(content, 'm.call.intent'), - silent: !ct.directRooms.read.has(roomId), + intent: + 'm.call.intent' in content && typeof content['m.call.intent'] === 'string' + ? content['m.call.intent'] + : undefined, + notificationType, refEventId, }; - setCallInfo((currentInfo) => { - if (currentInfo) return currentInfo; - return info; - }); + setCallInfo(info); }, - [ct] + [mx] ); useEffect(() => { - const unsub = ct.sync.observable.subscribe(async (syncValue: SyncValue) => { - const [, data] = syncValue ?? []; - if (!data) return; - - Object.keys(data.rooms?.join ?? {}).forEach(async (roomId) => { - if (isCallRoom(ct.roomToType.read.get(roomId))) return; - - const joinedRoom = data.rooms?.join?.[roomId]; - if (!joinedRoom) return; - - const timelineEvents = roomTimeline(joinedRoom); - Array.from(timelineEvents) - .reverse() - .forEach((event) => { - if (reference(event)) handleReferenceEvent(roomId, event); - }); - }); - }); + mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { - unsub(); + mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; - }, [ct, handleReferenceEvent]); + }, [mx, handleTimelineEvent]); const handleIgnore = useCallback(() => { setCallInfo(undefined); }, []); const handleReject = useCallback( - async (roomId: string, eventId: string) => { - setCallInfo(undefined); - const content = { + (room: Room, eventId: string) => { + mx.sendEvent(room.roomId, EventType.RTCDecline, { 'm.relates_to': { rel_type: RelationType.Reference, event_id: eventId, }, - }; - - let encContent: object | undefined; - if (ct.encryptedRooms.read.has(roomId)) { - await ensureEncryptionSession(ct, roomId); - - const encStr = await ct.machine.roomEncryption.encryptMessageEvent( - roomId, - CallEventType.RTCDecline, - content - ); - encContent = JSON.parse(encStr); - } - - ct.requestAuthJSON( - putSend({ - pathParams: { - roomId, - eventType: encContent ? MessageEvent.RoomMessageEncrypted : CallEventType.RTCDecline, - txnId: ct.getTxnId(), - }, - body: encContent ?? content, - }) - ); + }); + setCallInfo(undefined); }, - [ct] + [mx] ); const handleAnswer = useCallback( - (roomId: string, video: boolean) => { - startCall(roomId, { microphone: true, video, sound: true }); + (room: Room, video: boolean) => { + startCall(room, { microphone: true, video, sound: true }); setCallInfo(undefined); - navigateRoom(roomId); + navigateRoom(room.roomId); }, [startCall, navigateRoom] ); - if (callInfo && callEmbed?.roomId === callInfo.roomId) { + if (callInfo && callEmbed?.roomId === callInfo.room.roomId) { return null; } return !joined && callInfo ? ( diff --git a/src/app/components/CapabilitiesLoader.tsx b/src/app/components/CapabilitiesLoader.tsx index bfdae4e5..667191e9 100644 --- a/src/app/components/CapabilitiesLoader.tsx +++ b/src/app/components/CapabilitiesLoader.tsx @@ -1,23 +1,15 @@ import { ReactNode, useCallback, useEffect } from 'react'; -import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities'; -import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { Capabilities } from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useClient } from '../hooks/useClient'; -import { Capabilities } from '../hooks/useCapabilities'; +import { useMatrixClient } from '../hooks/useMatrixClient'; type CapabilitiesLoaderProps = { children: (capabilities: Capabilities | undefined) => ReactNode; }; export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) { - const ct = useClient(); + const mx = useMatrixClient(); - const [state, load] = useAsyncCallback( - useCallback(async () => { - const resp = await ct.requestAuthJSON(getCapabilities({})); - const [, result] = await parseMatrixResp(resp); - return result?.capabilities; - }, [ct]) - ); + const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx])); useEffect(() => { load(); diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index ced0ae25..d2502ccf 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -1,4 +1,11 @@ +import { + ShowSasCallbacks, + VerificationPhase, + VerificationRequest, + Verifier, +} from 'matrix-js-sdk/lib/crypto-api'; import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; +import { VerificationMethod } from 'matrix-js-sdk/lib/types'; import { Box, Button, @@ -16,24 +23,13 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { - CancelableVerificationStage, - CancelVerificationStage, - DoneVerificationStage, - InboundRequestStage, - InboundVerification, - OutboundRequestStage, - OutboundVerification, - ReadyVerificationStage, - SasAcceptStage, - SasCancelState, - SasMethod, - SasStartStage, - StartVerificationStage, -} from 'neurone/lib/crypto/machine/verification'; -import { useVerificationRequestReceived } from '../hooks/useVerificationRequest'; + useVerificationRequestPhase, + useVerificationRequestReceived, + useVerifierCancel, + useVerifierShowSas, +} from '../hooks/useVerificationRequest'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { ContainerColor } from '../styles/ContainerColor.css'; -import { useObservableValue } from '../hooks/useObservableValue'; const DialogHeaderStyles: CSSProperties = { padding: `0 ${config.space.S200} 0 ${config.space.S400}`, @@ -74,12 +70,10 @@ function VerificationWaitAccept() { } type VerificationAcceptProps = { - stage: InboundRequestStage; + onAccept: () => Promise; }; -function VerificationAccept({ stage }: VerificationAcceptProps) { - const [acceptState, accept] = useAsyncCallback( - useCallback(() => stage.ready(['m.sas.v1']), [stage]) - ); +function VerificationAccept({ onAccept }: VerificationAcceptProps) { + const [acceptState, accept] = useAsyncCallback(onAccept); const accepting = acceptState.status === AsyncStatus.Loading; return ( @@ -108,12 +102,12 @@ function VerificationWaitStart() { } type VerificationStartProps = { - stage: ReadyVerificationStage; + onStart: () => Promise; }; -function AutoVerificationStart({ stage }: VerificationStartProps) { +function AutoVerificationStart({ onStart }: VerificationStartProps) { useEffect(() => { - stage.startSas(); - }, [stage]); + onStart(); + }, [onStart]); return ( @@ -122,8 +116,8 @@ function AutoVerificationStart({ stage }: VerificationStartProps) { ); } -function CompareEmoji({ stage }: { stage: SasAcceptStage }) { - const [confirmState, confirm] = useAsyncCallback(useCallback(() => stage.match(), [stage])); +function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { + const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const confirming = confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success; @@ -141,17 +135,17 @@ function CompareEmoji({ stage }: { stage: SasAcceptStage }) { wrap="Wrap" justifyContent="Center" > - {stage.emoji?.map((emoji, index) => ( + {sasData.sas.emoji?.map(([emoji, name], index) => ( - {emoji.emoji} - {emoji.description} + {emoji} + {name} ))} @@ -168,7 +162,7 @@ function CompareEmoji({ stage }: { stage: SasAcceptStage }) { @@ -237,18 +228,23 @@ function VerificationCanceled({ stage, onClose }: VerificationCanceledProps) { } type DeviceVerificationProps = { - request: InboundVerification | OutboundVerification; + request: VerificationRequest; onExit: () => void; }; export function DeviceVerification({ request, onExit }: DeviceVerificationProps) { - const stage = useObservableValue(request.stage); + const phase = useVerificationRequestPhase(request); const handleCancel = useCallback(() => { - if (stage instanceof CancelableVerificationStage) { - stage.cancel(); + if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) { + request.cancel(); } onExit(); - }, [stage, onExit]); + }, [request, onExit]); + + const handleAccept = useCallback(() => request.accept(), [request]); + const handleStart = useCallback(async () => { + await request.startVerification(VerificationMethod.Sas); + }, [request]); return ( }> @@ -270,27 +266,30 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) - {stage instanceof InboundRequestStage && } - {stage instanceof OutboundRequestStage && } - {stage instanceof ReadyVerificationStage && - (request instanceof OutboundVerification ? ( - + {phase === VerificationPhase.Requested && + (request.initiatedByMe ? ( + + ) : ( + + ))} + {phase === VerificationPhase.Ready && + (request.initiatedByMe ? ( + ) : ( ))} - {stage instanceof StartVerificationStage && - (stage.method ? ( - + {phase === VerificationPhase.Started && + (request.verifier ? ( + ) : ( ))} - - {stage instanceof DoneVerificationStage && } - {stage instanceof CancelVerificationStage && ( - + {phase === VerificationPhase.Done && } + {phase === VerificationPhase.Cancelled && ( + )} @@ -301,7 +300,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) } export function ReceiveSelfDeviceVerification() { - const [request, setRequest] = useState(); + const [request, setRequest] = useState(); useVerificationRequestReceived(setRequest); @@ -311,7 +310,7 @@ export function ReceiveSelfDeviceVerification() { if (!request) return null; - if (!request.selfVerification) { + if (!request.isSelfVerification) { return null; } diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index f482e569..433fa6a1 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -1,11 +1,4 @@ -import React, { - FormEventHandler, - forwardRef, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react'; import { Dialog, Header, @@ -19,236 +12,154 @@ import { Chip, color, Spinner, - ProgressBar, } from 'folds'; import FileSaver from 'file-saver'; -import { BackupManager } from 'neurone/lib/crypto/machine/backup'; -import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; -import { - AesHmacSha2, - AesHmacSha2KeyDescription, - DefaultKey, - KeyStorage, - Passphrase, - RecoveryKey, - Secret, - SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2, -} from 'neurone/lib/crypto/ssss'; -import { - MatrixRequestRetrier, - MatrixUIAData, - MatrixUIAHandler, - randomString, - sleep, -} from 'neurone/lib/utils'; -import { postVersion, PostVersionBody } from 'neurone/lib/api/matrix/client/v3/roomKeys'; -import { parseMatrixResp } from 'neurone/lib/matrix-request'; -import { postDeviceSigningUpload } from 'neurone/lib/api/matrix/client/v3/keys'; +import to from 'await-to-js'; +import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; import { PasswordInput } from './password-input'; import { ContainerColor } from '../styles/ContainerColor.css'; import { copyToClipboard } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; +import { clearSecretStorageKeys } from '../../client/secretStorageKeys'; import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; -import { UseStateProvider } from './UseStateProvider'; -import { useClient } from '../hooks/useClient'; -import { getMxIdLocalPart, getMxIdServer } from '../utils/matrix'; -import { AccountDataType } from '../../types/matrix/accountData'; +import { useMatrixClient } from '../hooks/useMatrixClient'; import { useAlive } from '../hooks/useAlive'; +import { UseStateProvider } from './UseStateProvider'; -type SetupProgressChecklist = { - bootstrapCrossSinging?: boolean; - putMasterAccountData?: boolean; - putSelfAccountData?: boolean; - putUserAcountData?: boolean; - verfiyOwnDevice?: boolean; - postBackupVersion?: boolean; - putBackupAccountData?: boolean; - putRecoveryKeyAccountData?: boolean; - putDefaultKeyAccountData?: boolean; +type UIACallback = ( + authDict: AuthDict | null +) => Promise<[IAuthData, undefined] | [undefined, T]>; + +type PerformAction = (authDict: AuthDict | null) => Promise; + +type UIAAction = { + authData: IAuthData; + callback: UIACallback; + cancelCallback: () => void; }; +function makeUIAAction( + authData: IAuthData, + performAction: PerformAction, + resolve: (data: T) => void, + reject: (error?: any) => void +): UIAAction { + const action: UIAAction = { + authData, + callback: async (authDict) => { + const [error, data] = await to(performAction(authDict)); + + if (error instanceof MatrixError && error.httpStatus === 401) { + return [error.data as IAuthData, undefined]; + } + + if (error) { + reject(error); + throw error; + } + + resolve(data); + return [undefined, data]; + }, + cancelCallback: reject, + }; + + return action; +} + type SetupVerificationProps = { onComplete: (recoveryKey: string) => void; }; function SetupVerification({ onComplete }: SetupVerificationProps) { - const ct = useClient(); - const uia = useMemo(() => new MatrixUIAHandler(), []); - const [uiaData, setUIAData] = useState(); - const [setupProgress, setSetupProgress] = useState(); + const mx = useMatrixClient(); const alive = useAlive(); - useEffect(() => uia.onUIA.subscribe(setUIAData), [uia]); + const [uiaAction, setUIAAction] = useState>(); + const [nextAuthData, setNextAuthData] = useState(); // null means no next action. const handleAction = useCallback( - (authDict: object) => { - setUIAData(undefined); - uia.retry(authDict); + async (authDict: AuthDict) => { + if (!uiaAction) { + throw new Error('Unexpected Error! UIA action is perform without data.'); + } + if (alive()) { + setNextAuthData(null); + } + const [authData] = await uiaAction.callback(authDict); + + if (alive() && authData) { + setNextAuthData(authData); + } }, - [uia] + [uiaAction, alive] ); - const handleCancel = useCallback(() => { - setUIAData(undefined); - uia.clear(); - }, [uia]); + const resetUIA = useCallback(() => { + if (!alive()) return; + setUIAAction(undefined); + setNextAuthData(undefined); + }, [alive]); + + const authUploadDeviceSigningKeys: UIAuthCallback = useCallback( + (makeRequest) => + new Promise((resolve, reject) => { + makeRequest(null) + .then(() => { + resolve(); + resetUIA(); + }) + .catch((error) => { + if (error instanceof MatrixError && error.httpStatus === 401) { + const authData = error.data as IAuthData; + const action = makeUIAAction( + authData, + makeRequest as PerformAction, + resolve, + (err) => { + resetUIA(); + reject(err); + } + ); + if (alive()) { + setUIAAction(action); + } else { + reject(new Error('Authentication failed! Failed to setup device verification.')); + } + return; + } + reject(error); + }); + }), + [alive, resetUIA] + ); const [setupState, setup] = useAsyncCallback( useCallback( async (passphrase) => { - setSetupProgress({}); - const retrier = new MatrixRequestRetrier(sleep); + const crypto = mx.getCrypto(); + if (!crypto) throw new Error('Unexpected Error! Crypto module not found!'); - const defaultKeyId = randomString(16); - - const passData = passphrase ? await Passphrase.create(passphrase) : undefined; - const recoveryKey = passData?.key ?? AesHmacSha2.generateRawKey(); - - const uploadDeviceSigningKeys = async (bodyStr: string) => { - const body = JSON.parse(bodyStr); - const resp = await retrier.execute(() => - uia.execute((authData) => - ct.requestAuthJSON(postDeviceSigningUpload({ body: { ...body, auth: authData } })) - ) - ); - const [err] = await parseMatrixResp(resp); - if (err) throw new Error(err.data.error ?? err.data.errcode); - }; - - await ct.machine.crossSigning.bootstrapCrossSigning(uploadDeviceSigningKeys); - const crossSigning = await ct.machine.crossSigning.exportKeys(); - if (!crossSigning || !crossSigning.master || !crossSigning.self || !crossSigning.user) { - throw new Error('Failed to reset cross signing. No cross signing keys available.'); + const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase); + if (!recoveryKeyData.encodedPrivateKey) { + throw new Error('Unexpected Error! Failed to create recovery key.'); } - if (alive()) setSetupProgress((v) => ({ ...v, bootstrapCrossSinging: true })); + clearSecretStorageKeys(); - const masterEnc = await AesHmacSha2.encrypt( - recoveryKey, - AccountDataType.CrossSigningMaster, - crossSigning.master - ); - const selfEnc = await AesHmacSha2.encrypt( - recoveryKey, - AccountDataType.CrossSigningSelf, - crossSigning.self - ); - const userEnc = await AesHmacSha2.encrypt( - recoveryKey, - AccountDataType.CrossSigningUser, - crossSigning.user - ); + await crypto.bootstrapSecretStorage({ + createSecretStorageKey: async () => recoveryKeyData, + setupNewSecretStorage: true, + }); - const { userId } = ct.session; - const masterSecret: Secret = { encrypted: { [defaultKeyId]: masterEnc } }; - const selfSecret: Secret = { encrypted: { [defaultKeyId]: selfEnc } }; - const userSecret: Secret = { encrypted: { [defaultKeyId]: userEnc } }; - await retrier.execute(() => - ct.requestAuthJSON( - putAccountData({ - pathParams: { userId, type: AccountDataType.CrossSigningMaster }, - body: masterSecret, - }) - ) - ); - if (alive()) setSetupProgress((v) => ({ ...v, putMasterAccountData: true })); - await retrier.execute(() => - ct.requestAuthJSON( - putAccountData({ - pathParams: { userId, type: AccountDataType.CrossSigningSelf }, - body: selfSecret, - }) - ) - ); - if (alive()) setSetupProgress((v) => ({ ...v, putSelfAccountData: true })); - await retrier.execute(() => - ct.requestAuthJSON( - putAccountData({ - pathParams: { userId, type: AccountDataType.CrossSigningUser }, - body: userSecret, - }) - ) - ); - if (alive()) setSetupProgress((v) => ({ ...v, putUserAcountData: true })); + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + setupNewCrossSigning: true, + }); - const device = await ct.machine.userTracker.getDevice( - ct.session.userId, - ct.session.deviceId - ); - if (device && !device.crossSigningTrusted) { - const resp = await device.verify(); - const [err] = await parseMatrixResp(resp); - if (err) { - throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.'); - } - await ct.machine.outgoingRequests.queryKeys([ct.session.userId]); - } - if (alive()) setSetupProgress((v) => ({ ...v, verfiyOwnDevice: true })); + await crypto.resetKeyBackup(); - const backupKey = BackupManager.createBackupKey(); - await ct.megolmBackup.cacheDecryptionKey(backupKey.privateKey); - - const backupEnc = await AesHmacSha2.encrypt( - recoveryKey, - AccountDataType.MegolmBackupV1, - backupKey.privateKey - ); - - const backupAuthData = { - public_key: backupKey.publicKey, - }; - await ct.machine.sign(backupAuthData); - - const backupVersion: PostVersionBody = { - algorithm: backupKey.algorithm, - auth_data: backupAuthData, - }; - await retrier.execute(() => ct.requestAuthJSON(postVersion({ body: backupVersion }))); - if (alive()) setSetupProgress((v) => ({ ...v, postBackupVersion: true })); - - const backupSecret: Secret = { encrypted: { [defaultKeyId]: backupEnc } }; - - await retrier.execute(() => - ct.requestAuthJSON( - putAccountData({ - pathParams: { userId, type: AccountDataType.MegolmBackupV1 }, - body: backupSecret, - }) - ) - ); - if (alive()) setSetupProgress((v) => ({ ...v, putBackupAccountData: true })); - - const { iv, mac } = await AesHmacSha2.driveKeyMetaData(recoveryKey); - - const keyDescription: AesHmacSha2KeyDescription = { - algorithm: SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2, - passphrase: passData?.passphrase, - iv, - mac, - }; - await retrier.execute(() => - ct.requestAuthJSON( - putAccountData({ - pathParams: { userId, type: KeyStorage.keyIdentifier(defaultKeyId) }, - body: keyDescription, - }) - ) - ); - if (alive()) setSetupProgress((v) => ({ ...v, putRecoveryKeyAccountData: true })); - const defaultKey: DefaultKey = { - key: defaultKeyId, - }; - await retrier.execute(() => - ct.requestAuthJSON( - putAccountData({ - pathParams: { userId, type: KeyStorage.defaultKeyIdentifier() }, - body: defaultKey, - }) - ) - ); - if (alive()) setSetupProgress((v) => ({ ...v, putDefaultKeyAccountData: true })); - - onComplete(RecoveryKey.encode(recoveryKey)); + onComplete(recoveryKeyData.encodedPrivateKey); }, - [ct, uia, onComplete, alive] + [mx, onComplete, authUploadDeviceSigningKeys] ) ); @@ -268,80 +179,43 @@ function SetupVerification({ onComplete }: SetupVerificationProps) { setup(passphrase); }; - const setupSteps: { - key: keyof SetupProgressChecklist; - label: string; - }[] = useMemo( - () => [ - { key: 'bootstrapCrossSinging', label: 'Setting up cross-signing' }, - { key: 'putMasterAccountData', label: 'Securing your master signing key' }, - { key: 'putSelfAccountData', label: 'Securing your self signing key' }, - { key: 'putUserAcountData', label: 'Securing your user signing key' }, - { key: 'verfiyOwnDevice', label: 'Verifying this device' }, - { key: 'postBackupVersion', label: 'Creating your secure backup' }, - { key: 'putBackupAccountData', label: 'Securing your backup key' }, - { key: 'putRecoveryKeyAccountData', label: 'Saving your recovery key information' }, - { key: 'putDefaultKeyAccountData', label: 'Setting up your recovery key' }, - ], - [] - ); - - const completedSteps = setupSteps.filter(({ key }) => setupProgress?.[key] === true).length; - return ( - {setupProgress ? ( - - Setup progress - - {setupSteps.map(({ key, label }) => ( - - {setupProgress[key] ? '✅' : '⏳'} {label} - - ))} - - - - ) : ( - <> - - Generate a Recovery Key for verifying identity if you do not have access to other - devices. Additionally, setup a passphrase as a memorable alternative. - - - Passphrase (Optional) - - - - - )} + + Generate a Recovery Key for verifying identity if you do not have access to other + devices. Additionally, setup a passphrase as a memorable alternative. + + + Passphrase (Optional) + + + {setupState.status === AsyncStatus.Error && ( {setupState.error ? setupState.error.message : 'Unexpected Error!'} )} - {uiaData && ( + {nextAuthData !== null && uiaAction && ( ( - + Authentication steps to perform this action are not supported by client. )} > {(ongoingFlow) => ( )} @@ -354,7 +228,6 @@ type RecoveryKeyDisplayProps = { recoveryKey: string; }; function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { - const ct = useClient(); const [show, setShow] = useState(false); const handleCopy = () => { @@ -362,13 +235,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { }; const handleDownload = () => { - const username = getMxIdLocalPart(ct.session.userId); - const servername = getMxIdServer(ct.session.userId); - const blob = new Blob([recoveryKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, `Recovery Key - ${username} (${servername}).txt`); + FileSaver.saveAs(blob, 'recovery-key.txt'); }; const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); @@ -400,12 +270,12 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { - - + ); diff --git a/src/app/components/DeviceVerificationStatus.ts b/src/app/components/DeviceVerificationStatus.ts index 3f168313..e8a6b0c1 100644 --- a/src/app/components/DeviceVerificationStatus.ts +++ b/src/app/components/DeviceVerificationStatus.ts @@ -1,21 +1,24 @@ import { ReactNode } from 'react'; +import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api'; import { useDeviceVerificationStatus, VerificationStatus, } from '../hooks/useDeviceVerificationStatus'; type DeviceVerificationStatusProps = { + crypto?: CryptoApi; userId: string; deviceId: string; children: (verificationStatus: VerificationStatus) => ReactNode; }; export function DeviceVerificationStatus({ + crypto, userId, deviceId, children, }: DeviceVerificationStatusProps) { - const status = useDeviceVerificationStatus(userId, deviceId); + const status = useDeviceVerificationStatus(crypto, userId, deviceId); return children(status); } diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index 58cf039b..bbc0a65d 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -13,43 +13,44 @@ import { Button, Spinner, } from 'folds'; +import { JoinRule } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../utils/keyboard'; import { getRoomIconSrc } from '../utils/room'; -import { JoinRule } from '../../types/matrix/room'; export type ExtraJoinRules = 'knock_restricted'; +export type ExtendedJoinRules = JoinRule | ExtraJoinRules; -type JoinRuleIcons = Record; +type JoinRuleIcons = Record; export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons => useMemo( () => ({ - invite: getRoomIconSrc(Icons, roomType, 'invite'), - knock: getRoomIconSrc(Icons, roomType, 'knock'), - restricted: getRoomIconSrc(Icons, roomType, 'restricted'), - knock_restricted: getRoomIconSrc(Icons, roomType, 'knock_restricted'), - public: getRoomIconSrc(Icons, roomType, 'public'), - private: getRoomIconSrc(Icons, roomType, 'private'), + [JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite), + [JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock), + knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), + [JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), + [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), + [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), }), [roomType] ); -type JoinRuleLabels = Record; +type JoinRuleLabels = Record; export const useRoomJoinRuleLabel = (): JoinRuleLabels => useMemo( () => ({ - invite: 'Invite Only', - knock: 'Knock & Invite', + [JoinRule.Invite]: 'Invite Only', + [JoinRule.Knock]: 'Knock & Invite', knock_restricted: 'Space Members or Knock', - restricted: 'Space Members', - public: 'Public', - private: 'Invite Only', + [JoinRule.Restricted]: 'Space Members', + [JoinRule.Public]: 'Public', + [JoinRule.Private]: 'Invite Only', }), [] ); -type JoinRulesSwitcherProps = { +type JoinRulesSwitcherProps = { icons: JoinRuleIcons; labels: JoinRuleLabels; rules: T; @@ -58,7 +59,7 @@ type JoinRulesSwitcherProps = { disabled?: boolean; changing?: boolean; }; -export function JoinRulesSwitcher({ +export function JoinRulesSwitcher({ icons, labels, rules, @@ -74,7 +75,7 @@ export function JoinRulesSwitcher({ }; const handleChange = useCallback( - (selectedRule: JoinRule) => { + (selectedRule: ExtendedJoinRules) => { setCords(undefined); onChange(selectedRule); }, @@ -126,7 +127,7 @@ export function JoinRulesSwitcher({ fill="Soft" radii="300" outlined - before={} + before={} after={ changing ? ( diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index 42206ead..cc9bf067 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,9 +1,10 @@ import React, { forwardRef, useCallback } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useClient } from '../hooks/useClient'; +import { logoutClient } from '../../client/initMatrix'; +import { useMatrixClient } from '../hooks/useMatrixClient'; +import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { InfoCard } from './info-card'; -import { useCrossSigning } from '../hooks/useCrossSigning'; import { useDeviceVerificationStatus, VerificationStatus, @@ -14,16 +15,19 @@ type LogoutDialogProps = { }; export const LogoutDialog = forwardRef( ({ handleClose }, ref) => { - const ct = useClient(); - const hasEncryptedRoom = ct.encryptedRooms.read.size > 0; - const crossSigning = useCrossSigning(); - - const verificationStatus = useDeviceVerificationStatus(ct.session.userId, ct.session.deviceId); + const mx = useMatrixClient(); + const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); + const crossSigningActive = useCrossSigningActive(); + const verificationStatus = useDeviceVerificationStatus( + mx.getCrypto(), + mx.getSafeUserId(), + mx.getDeviceId() ?? undefined + ); const [logoutState, logout] = useAsyncCallback( useCallback(async () => { - await ct.logout(); - }, [ct]) + await logoutClient(mx); + }, [mx]) ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; @@ -44,7 +48,7 @@ export const LogoutDialog = forwardRef( {hasEncryptedRoom && - (crossSigning ? ( + (crossSigningActive ? ( verificationStatus === VerificationStatus.Unverified && ( ) => { - const status = await ct.machine.crossSigning.getStatus(); - const machineHasKeys = status.master && status.self && status.user; - - if (!machineHasKeys && csSecretData) { - const crossSigningKeys = await getCrossSigningPrivateKeys(recoveryKey, csSecretData); - - const s = await ct.machine.crossSigning.importKeys(crossSigningKeys); - if (!s.master || !s.self || !s.user) { - throw new Error('Failed to import to CrossSigningSecrets keys!'); - } + async (recoveryKey: Uint8Array) => { + const crypto = mx.getCrypto(); + if (!crypto) { + throw new Error('Unexpected Error! Crypto object not found.'); } - const { userId, deviceId } = ct.session; - const device = await ct.machine.userTracker.getDevice(userId, deviceId); - if (device && !device.crossSigningTrusted) { - const resp = await device.verify(); - const [err] = await parseMatrixResp(resp); - if (err) { - throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.'); - } + storePrivateKey(secretStorageKeyId, recoveryKey); - // device.verify uploads signatures to mark device as verifed - // query keys to receive signs now instead of update via /sync - // if we don't do that device remains unverified until next sync device_list change - await ct.machine.outgoingRequests.queryKeys([ct.session.userId]); - } + await crypto.bootstrapCrossSigning({}); + await crypto.bootstrapSecretStorage({}); - const backupKeyEncryptedData = ct.secrets.megolmBackup.read?.get(secretStorageKeyId); - if (!backupKeyEncryptedData) { - return; - } - const backupKey = await AesHmacSha2.decrypt( - recoveryKey, - AccountDataType.MegolmBackupV1, - backupKeyEncryptedData - ); - await ct.megolmBackup.saveDecryptionKey(backupKey); + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); }, - [ct, secretStorageKeyId, csSecretData] + [mx, secretStorageKeyId] ); - const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback< - void, - Error, - [Uint8Array] - >(verifyAndRestoreBackup); + const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback( + verifyAndRestoreBackup + ); const verifying = verifyState.status === AsyncStatus.Loading; return ( @@ -205,7 +174,7 @@ export function ManualVerificationTile({ {method === ManualVerificationMethod.RecoveryKey && ( )} @@ -213,8 +182,8 @@ export function ManualVerificationTile({ secretStorageKeyContent.passphrase && ( )} diff --git a/src/app/components/MediaConfigLoader.tsx b/src/app/components/MediaConfigLoader.tsx index 634973dd..9fd99330 100644 --- a/src/app/components/MediaConfigLoader.tsx +++ b/src/app/components/MediaConfigLoader.tsx @@ -1,25 +1,15 @@ import { ReactNode, useCallback, useEffect } from 'react'; -import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media'; -import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; +import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; -import { useClient } from '../hooks/useClient'; type MediaConfigLoaderProps = { children: (mediaConfig: MediaConfig | undefined) => ReactNode; }; export function MediaConfigLoader({ children }: MediaConfigLoaderProps) { - const ct = useClient(); + const mx = useMatrixClient(); - const [state, load] = useAsyncCallback( - useCallback(async () => { - const resp = await ct.requestAuthJSON(getConfig({})); - - const [, config] = await parseMatrixResp(resp); - - return config; - }, [ct]) - ); + const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx])); useEffect(() => { load(); diff --git a/src/app/components/MessageContent.tsx b/src/app/components/MessageContent.tsx deleted file mode 100644 index 336a9bbd..00000000 --- a/src/app/components/MessageContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; -import { DecryptionManager } from '../../client/decryption'; -import { MessageEvent } from '../../types/matrix/room'; -import { EncryptedMessageEvent } from './encrypted-event'; - -type MessageContentProps = { - event: MessageEventWithoutRoomId; - editedEvent?: MessageEventWithoutRoomId; - roomId: string; - decryptionManager: DecryptionManager; - reply?: boolean; - children: (event: MessageEventWithoutRoomId) => React.ReactNode; -}; - -export function MessageContent({ - event, - editedEvent, - roomId, - decryptionManager, - reply, - children, -}: MessageContentProps) { - const effectiveEvent = editedEvent ?? event; - - if (effectiveEvent.type === MessageEvent.RoomMessageEncrypted) { - return ( - - {(decryptedEvent) => children(decryptedEvent)} - - ); - } - - return children(effectiveEvent); -} diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index a73906f2..4cfcb7dc 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,30 +1,43 @@ import React from 'react'; +import { MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts } from 'linkifyjs'; import { config } from 'folds'; import { - EmptyContent, + AudioContent, + DownloadFile, + FileContent, + ImageContent, MAudio, + MBadEncrypted, MEmote, MFile, MImage, MLocation, MNotice, - MSticker, MText, MVideo, + ReadPdfFile, + ReadTextFile, + RenderBody, + ThumbnailContent, UnsupportedContent, + VideoContent, } from './message'; -import { MsgType } from '../../types/matrix/common'; -import { rContent } from '../../client/reader'; -import { MessageEvent } from '../../types/matrix/room'; +import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; +import { Image, MediaControl, Video } from './media'; +import { ImageViewer } from './image-viewer'; +import { PdfViewer } from './Pdf-viewer'; +import { TextViewer } from './text-viewer'; +import { testMatrixTo } from '../plugins/matrix-to'; +import { IImageContent } from '../../types/matrix/common'; type RenderMessageContentProps = { displayName: string; - eventType: string; + msgType: string; ts: number; edited?: boolean; - content: object; + getContent: () => T; mediaAutoLoad?: boolean; urlPreview?: boolean; highlightRegex?: RegExp; @@ -34,10 +47,10 @@ type RenderMessageContentProps = { }; export function RenderMessageContent({ displayName, - eventType, + msgType, ts, edited, - content, + getContent, mediaAutoLoad, urlPreview, highlightRegex, @@ -45,53 +58,90 @@ export function RenderMessageContent({ linkifyOpts, outlineAttachment, }: RenderMessageContentProps) { - if (eventType === MessageEvent.Sticker) { + const renderUrlsPreview = (urls: string[]) => { + const filteredUrls = urls.filter((url) => !testMatrixTo(url)); + if (filteredUrls.length === 0) return undefined; return ( - + + {filteredUrls.map((url) => ( + + ))} + ); - } - - if (eventType !== MessageEvent.RoomMessage) { - return ; - } - + }; const renderCaption = () => { - const filename = rContent.filename(content); - const body = rContent.body(content); - - if (filename && filename !== body && body) { + const content: IImageContent = getContent(); + if (content.filename && content.filename !== content.body) { return ( ( + + )} + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } return null; }; - const msgType = rContent.msgtype(content); + const renderFile = () => ( + <> + ( + ( + } + /> + )} + renderAsTextFile={() => ( + } + /> + )} + > + + + )} + outlined={outlineAttachment} + /> + {renderCaption()} + + ); if (msgType === MsgType.Text) { return ( ( + + )} + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } @@ -101,12 +151,16 @@ export function RenderMessageContent({ ( + + )} + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } @@ -115,12 +169,16 @@ export function RenderMessageContent({ return ( ( + + )} + renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} /> ); } @@ -128,7 +186,18 @@ export function RenderMessageContent({ if (msgType === MsgType.Image) { return ( <> - + ( + } + renderViewer={(p) => } + /> + )} + outlined={outlineAttachment} + /> {renderCaption()} ); @@ -137,7 +206,31 @@ export function RenderMessageContent({ if (msgType === MsgType.Video) { return ( <> - + ( + ( + ( + {body} + )} + /> + ) + : undefined + } + renderVideo={(p) =>