diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 11b5056c..4d741994 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: ".node-version" package-manager-cache: false diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 302c3788..02f37e1f 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -22,7 +22,7 @@ jobs: uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Hub #Do not update this action from a outside PR if: github.event.pull_request.head.repo.fork == false diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml index 12107685..6ca393f3 100644 --- a/.github/workflows/netlify-dev.yml +++ b/.github/workflows/netlify-dev.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: ".node-version" package-manager-cache: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 2a8192db..1b11c524 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: '.node-version' package-manager-cache: false @@ -71,7 +71,7 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Hub #Do not update this action from a outside PR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: diff --git a/.prettierignore b/.prettierignore index 036ee8bc..e0aa18a1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,5 +2,4 @@ dist node_modules package.json package-lock.json -LICENSE -harness +LICENSE \ No newline at end of file diff --git a/build.config.ts b/build.config.ts index ac51db97..ec8a41d0 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,3 @@ export default { - base: process.env.CINNY_BASE ?? '/', + base: '/', }; diff --git a/config.json b/config.json index e945d1c4..a61bb966 100644 --- a/config.json +++ b/config.json @@ -5,7 +5,17 @@ "featuredCommunities": { "openAsDefault": false, - "spaces": ["#gamecult:gamecult.org"], + "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" + ], "rooms": [ "#tuwunel:grin.hu", "#freesoftware:matrix.org", diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md deleted file mode 100644 index 166c5c72..00000000 --- a/docs/gamecult/ratatoskr-target.md +++ /dev/null @@ -1,352 +0,0 @@ -# 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 deleted file mode 100644 index 656d532e..00000000 --- a/harness/estate/check.cjs +++ /dev/null @@ -1,46 +0,0 @@ -// 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 deleted file mode 100644 index d53d88a4..00000000 --- a/harness/estate/drive-failures.cjs +++ /dev/null @@ -1,72 +0,0 @@ -// 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 deleted file mode 100644 index 75c9a8df..00000000 --- a/harness/estate/drive-routes.cjs +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 03bd2ad0..00000000 --- a/harness/estate/drive.cjs +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 4a6875e0..00000000 --- a/harness/estate/fakehs.cjs +++ /dev/null @@ -1,127 +0,0 @@ -// 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 deleted file mode 100644 index 65242ab0..00000000 --- a/harness/estate/run.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/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 dc774000..87340857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,18 @@ { "name": "cinny", - "version": "4.12.8", + "version": "4.12.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.12.8", + "version": "4.12.7", "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", @@ -40,7 +37,7 @@ "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", - "i18next-http-backend": "4.0.3", + "i18next-http-backend": "2.5.2", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", @@ -61,7 +58,7 @@ "react-i18next": "15.0.0", "react-range": "1.8.14", "react-router-dom": "6.30.6", - "sanitize-html": "2.18.1", + "sanitize-html": "2.17.7", "slate": "0.123.0", "slate-dom": "0.123.0", "slate-history": "0.113.1", @@ -102,8 +99,7 @@ "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", - "vitest": "1.6.0" + "vite-plugin-top-level-await": "1.4.4" }, "engines": { "node": ">=16.0.0" @@ -2192,38 +2188,11 @@ "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", @@ -2338,19 +2307,6 @@ "@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", @@ -2391,10 +2347,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "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" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -4375,13 +4330,6 @@ "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", @@ -5182,143 +5130,6 @@ "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", @@ -5345,19 +5156,6 @@ "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", @@ -5650,16 +5448,6 @@ "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", @@ -6069,25 +5857,6 @@ "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", @@ -6110,19 +5879,6 @@ "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", @@ -6858,19 +6614,6 @@ } } }, - "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", @@ -6972,16 +6715,6 @@ "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", @@ -7905,85 +7638,6 @@ "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", @@ -8424,16 +8078,6 @@ "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", @@ -8477,19 +8121,6 @@ "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", @@ -8894,16 +8525,6 @@ "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", @@ -8951,11 +8572,19 @@ } }, "node_modules/i18next-http-backend": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.3.tgz", - "integrity": "sha512-B5GJysCsJwqiDQRpUcikWsut82Vmekix58WIyiCpYtxaZ/wY8kwJCicMRvVcMoIa1x84siiRl5vBt6iCBVO6zg==", - "engines": { - "node": ">=18" + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.2.tgz", + "integrity": "sha512-+K8HbDfrvc1/2X8jpb7RLhI9ZxBDpx3xogYkQwGKlWAUXLSEGXzgdt3EcUjLlBCdMwdQY+K+EUF6oh8oB6rwHw==", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "node_modules/i18next-http-backend/node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" } }, "node_modules/iconv-lite": { @@ -9475,10 +9104,9 @@ } }, "node_modules/is-plain-object": { - "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", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "engines": { "node": ">=0.10.0" } @@ -9877,20 +9505,14 @@ } }, "node_modules/launder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", - "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", "license": "MIT", "dependencies": { - "dayjs": "^1.11.23" + "dayjs": "^1.11.7" } }, - "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", @@ -10098,23 +9720,6 @@ "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", @@ -10447,16 +10052,6 @@ "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", @@ -10579,13 +10174,6 @@ "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", @@ -10774,16 +10362,15 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -10807,7 +10394,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -10852,35 +10438,6 @@ "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", @@ -11272,16 +10829,6 @@ "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", @@ -11332,9 +10879,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", + "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", "funding": [ { "type": "opencollective", @@ -11349,11 +10896,10 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.20", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11423,41 +10969,6 @@ "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", @@ -12171,17 +11682,18 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", - "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==", + "version": "2.17.7", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", + "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", "license": "MIT", "dependencies": { + "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", - "is-plain-object": "^5.1.0", - "launder": "^1.7.2", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", "parse-srcset": "^1.0.2", - "postcss": "^8.5.27" + "postcss": "^8.3.11" }, "engines": { "node": ">=22.12.0" @@ -12480,13 +11992,6 @@ "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", @@ -12654,10 +12159,9 @@ } }, "node_modules/source-map-js": { - "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", + "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==", "engines": { "node": ">=0.10.0" } @@ -12714,20 +12218,6 @@ "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", @@ -12891,19 +12381,6 @@ "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", @@ -12916,26 +12393,6 @@ "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", @@ -13067,13 +12524,6 @@ "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", @@ -13101,26 +12551,6 @@ "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", @@ -13149,8 +12579,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "optional": true + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/tsconfig-paths": { "version": "3.15.0", @@ -13214,16 +12643,6 @@ "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", @@ -14052,89 +13471,6 @@ "@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", @@ -14156,14 +13492,12 @@ "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "optional": true + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -14268,23 +13602,6 @@ "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", diff --git a/package.json b/package.json index 298fbf36..0c0cc066 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.12.8", + "version": "4.12.7", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -18,8 +18,7 @@ "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js", - "test": "vitest run" + "bump": "node scripts/update-version.js" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -62,7 +61,7 @@ "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", - "i18next-http-backend": "4.0.3", + "i18next-http-backend": "2.5.2", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", @@ -83,15 +82,12 @@ "react-i18next": "15.0.0", "react-range": "1.8.14", "react-router-dom": "6.30.6", - "sanitize-html": "2.18.1", + "sanitize-html": "2.17.7", "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", - "@fontsource/ubuntu": "5.2.5", - "@fontsource/montserrat": "5.2.5", - "@fontsource/ibm-plex-mono": "5.2.5" + "ua-parser-js": "1.0.35" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", @@ -127,7 +123,6 @@ "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", - "vitest": "1.6.0" + "vite-plugin-top-level-await": "1.4.4" } } diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 64aaaacb..34a7e24b 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,7 +31,6 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -198,10 +197,8 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(async () => { - await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); - }, [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 2e4e3ebc..ce550992 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,7 +17,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; -import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -44,7 +43,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) + useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/features/estate/EstateNavigator.css.ts b/src/app/features/estate/EstateNavigator.css.ts deleted file mode 100644 index 157d69cd..00000000 --- a/src/app/features/estate/EstateNavigator.css.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { globalStyle, style } from '@vanilla-extract/css'; -import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; - -/** On phones every control inside the navigator is at least 44 px square to tap. */ -export const Targets = style({}); - -globalStyle(`${Targets} button, ${Targets} a`, { - '@media': { - [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { - minHeight: 44, - minWidth: 44, - }, - }, -}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx deleted file mode 100644 index 6fa4bf48..00000000 --- a/src/app/features/estate/EstateNavigator.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; -import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavItem, - NavItemContent, -} from '../../components/nav'; -import { UseStateProvider } from '../../components/UseStateProvider'; -import { JoinAddressPrompt } from '../../components/join-address-prompt'; -import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; -import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; -import { - encodeSearchParamValueArray, - getDirectRoomPath, - getHomeCreatePath, - getHomeRoomPath, - getSpaceRoomPath, - withSearchParam, -} from '../../pages/pathUtils'; -import { _RoomSearchParams } from '../../pages/paths'; -import { searchModalAtom } from '../../state/searchModal'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; -import { useCallEmbed } from '../../hooks/useCallEmbed'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../hooks/useRoomsNotificationPreferences'; -import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories'; -import { makeNavCategoryId } from '../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../state/room/roomToUnread'; -import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { EstateRoom, EstateTier } from './buildEstate'; -import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; -import { useEstate, useEstateRootId } from './useEstate'; -import * as css from './EstateNavigator.css'; - -/** How long a tap waits for the client store to hold the room it just joined. */ -const JOIN_ARRIVAL_TIMEOUT_MS = 20000; - -const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { - const alias = getCanonicalAliasOrRoomId(mx, room.roomId); - if (room.direct) return getDirectRoomPath(alias); - // A Space the user has not joined has no Space route; the room still resolves from Home. - if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') { - return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); - } - return getHomeRoomPath(alias); -}; - -function JoinRow({ room }: { room: EstateRoom }) { - const mx = useMatrixClient(); - const navigate = useNavigate(); - const [state, join] = useAsyncCallback( - useCallback(async () => { - const result = await activateEstateRoom(mx, room); - if (result.kind === 'refused') throw new Error(result.message); - // joinRoom resolves before the store holds the room; opening it now shows the invite card. - if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself. - throw new Error('Joined. The room is still on its way and will open from this list.'); - } - navigate(estateRoomPath(mx, room)); - }, [mx, navigate, room]) - ); - const busy = state.status === AsyncStatus.Loading; - - return ( - - - - - {room.name} - - - {state.status === AsyncStatus.Error && ( - - {(state.error as Error).message} - - )} - : } - onClick={join} - disabled={busy} - aria-label={`Join ${room.name}`} - > - Join - - - - ); -} - -function EstateRow({ room }: { room: EstateRoom }) { - const mx = useMatrixClient(); - const selectedRoomId = useSelectedRoom(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null; - - if (!mxRoom) return ; - return ( - - ); -} - -type CollapsibleProps = { - categoryId: string; - label: string; - rooms: EstateRoom[]; - children: ReactNode; -}; -function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { - const mobile = useScreenSizeContext() === ScreenSize.Mobile; - const selectedRoomId = useSelectedRoom(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const callRoomId = useCallEmbed()?.roomId; - const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); - - const active = rooms.some( - (r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId) - ); - // Phones start collapsed unless the section holds something live; wide screens start open. - // A tap flips that default; the flip is remembered per device and per screen class. - const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId); - const closed = (mobile && !active) !== toggled.has(key); - - const handleClick: MouseEventHandler = () => - setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key }); - - return ( - - - - {label} - - - {!closed && children} - - ); -} - -function Tier({ tier }: { tier: EstateTier }) { - const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); - return ( - - {tier.loose.map((r) => ( - - ))} - {tier.groups.map((g) => ( - - {g.rooms.map((r) => ( - - ))} - - ))} - - ); -} - -function EntryPoints() { - const navigate = useNavigate(); - return ( - - - navigate(getHomeCreatePath())}> - - - - - - - Create Room - - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - Join with Address - - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - ); -} - -/** A short message that wraps inside the navigator; never a raw request error. */ -function EstateError({ message }: { message: string }) { - return ( - - {message} - - ); -} - -function EstateTiers({ rootId }: { rootId: string }) { - const { fetching, error, tiers } = useEstate(rootId); - - return ( - <> - {tiers.map((tier) => ( - - ))} - {fetching && ( - - - - )} - {error && ( - - )} - - ); -} - -/** The single navigator: every nav slot that lists rooms mounts this. */ -export function EstateNavigator() { - const openRoomFinder = useSetAtom(searchModalAtom); - const { rootId, error } = useEstateRootId(); - const scrollRef = useRef(null); - const mx = useMatrixClient(); - const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; - - return ( - - - - - - {title} - - - openRoomFinder(true)} - > - - - - - - - - {rootId && } - {!rootId && error && ( - - )} - {!rootId && !error && ( - - - - )} - - - - - - - - - ); -} diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts deleted file mode 100644 index 8f898a0f..00000000 --- a/src/app/features/estate/buildEstate.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; - -const ROOT = '!root:gamecult.org'; -const PROJECTS = '!projects:gamecult.org'; - -/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */ -const space = ( - id: string, - name: string, - children: string[], - stewards: Record = {} -): EstateHierarchyRoom => ({ - room_id: id, - name, - room_type: 'm.space', - children_state: children.map((c) => ({ - state_key: c, - content: { - via: ['gamecult.org'], - ...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}), - }, - })), -}); -const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ - room_id: id, - name, - canonical_alias: alias, -}); - -const hierarchy = [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), - room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space( - PROJECTS, - 'Projects', - ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'], - { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' } - ), - room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), - room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), - room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), -]; - -const input = (over: Partial = {}): EstateInput => ({ - rootId: ROOT, - hierarchy, - membership: new Map(), - directRoomIds: [], - ...over, -}); - -const find = (tiers: ReturnType, roomId: string) => - tiers - .flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)]) - .find((r) => r.roomId === roomId); - -describe('buildEstate', () => { - it('lists an invited project room as invited', () => { - const tiers = buildEstate( - input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }) - ); - expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); - }); - - it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => { - const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) })); - expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined'); - expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable'); - expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']); - }); - - it('omits a room absent from the hierarchy even when the client store knows it', () => { - const hidden = '!estate-secret:gamecult.org'; - const tiers = buildEstate( - input({ - membership: new Map([[hidden, 'join']]), - // The root names the room as a child, but the server did not return it. - hierarchy: [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]), - ...hierarchy.slice(1), - ], - }) - ); - expect(find(tiers, hidden)).toBeUndefined(); - }); - - it('lists every room the server returned, whatever the user may join', () => { - const tiers = buildEstate(input()); - expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); - }); - - it('groups rooms by steward Mind from the steward on each Space child entry', () => { - const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ - ['eureka', ['!cultlib:gamecult.org']], - ['void', ['!aeth:gamecult.org']], - ]); - }); - - it('sorts steward groups by display label', () => { - const projects = buildEstate( - input({ - mindLabels: new Map([ - ['eureka', 'Zed'], - ['void', 'Alpha'], - ]), - }) - ).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); - }); - - it('lists a room with no alias match loose in its tier', () => { - const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; - expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); - }); - - it('groups an invited-only user by the same child-entry stewards, with no root state', () => { - const invitee = new Map([ - [ROOT, 'invite'], - [PROJECTS, 'invite'], - ['!cultlib:gamecult.org', 'invite'], - ['!aeth:gamecult.org', 'invite'], - ]); - const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([ - ['eureka', ['invited']], - ['void', ['invited']], - ]); - }); - - it('ignores a steward that is not a non-empty string', () => { - const odd = (steward: unknown): EstateHierarchyRoom[] => [ - { - ...space(ROOT, 'GameCult', ['!a:gamecult.org']), - children_state: [ - { state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } }, - ], - }, - room('!a:gamecult.org', 'A'), - ]; - [42, '', undefined, null].forEach((m) => { - const t = buildEstate(input({ hierarchy: odd(m) }))[0]; - expect(t.groups).toEqual([]); - expect(t.loose).toHaveLength(1); - }); - }); - - it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { - const second = '!second:gamecult.org'; - const tiers = buildEstate( - input({ - hierarchy: [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]), - ...hierarchy.slice(1), - space(second, 'Workshop', ['!w1:gamecult.org']), - room('!w1:gamecult.org', 'Bench'), - ], - directRoomIds: ['!dm:gamecult.org'], - membership: new Map([['!dm:gamecult.org', 'join']]), - }) - ); - expect(tiers.map((t) => t.label)).toEqual([ - 'GameCult', - 'Projects', - 'Workshop', - 'Direct Messages', - ]); - expect(tiers[3].loose[0].direct).toBe(true); - expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); - expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); - }); - - it('takes join servers from the matching children_state entry', () => { - const tiers = buildEstate( - input({ - hierarchy: [ - { - ...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']), - children_state: [ - { state_key: '!a:gamecult.org', content: { via: ['one.example'] } }, - { state_key: '!b:gamecult.org', content: { via: ['two.example'] } }, - ], - }, - room('!a:gamecult.org', 'A'), - room('!b:gamecult.org', 'B'), - ], - }) - ); - expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']); - expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']); - }); - - it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate( - input({ - hierarchy: [ - ...hierarchy.slice(0, 2), - space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], { - '!cultlib:gamecult.org': 'eureka', - '!aeth:gamecult.org': 'eureka', - }), - ...hierarchy.slice(3, 5), - ], - }) - ); - const projects = tiers.find((t) => t.id === PROJECTS)!; - expect(projects.groups).toHaveLength(1); - expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ - '!cultlib:gamecult.org', - '!aeth:gamecult.org', - ]); - }); - - it('sorts group labels without regard to case', () => { - const projects = buildEstate( - input({ - mindLabels: new Map([ - ['eureka', 'beta'], - ['void', 'Alpha'], - ]), - }) - ).find((t) => t.id === PROJECTS)!; - expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); - }); - - it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => { - const tiers = buildEstate( - input({ - hierarchy: [ - space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), - room('!general:gamecult.org', 'General'), - space(PROJECTS, 'Projects', ['!hidden:gamecult.org']), - ], - }) - ); - expect(tiers.map((t) => t.id)).toEqual([ROOT]); - }); - - it('puts direct messages in the final tier, off any Space', () => { - const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] })); - expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] }); - }); - - it('is identical for identical inputs', () => { - expect(buildEstate(input())).toEqual(buildEstate(input())); - }); -}); - -describe('stewardOfChild', () => { - it('reads the Mind from the org.gamecult.steward key of the child content', () => { - expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void'); - }); - - it('has no steward without the key, content or a string Mind', () => { - expect(stewardOfChild(undefined)).toBeUndefined(); - expect(stewardOfChild({ via: ['x'] })).toBeUndefined(); - expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined(); - }); -}); - -describe('Elsewhere', () => { - const strays = (over: Partial) => - buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find( - (t) => t.id === 'elsewhere' - ); - - it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { - const t = strays({ - strayRoomIds: ['!adhoc:x'], - roomNames: new Map([['!adhoc:x', 'Meeting']]), - })!; - expect(t.label).toBe('Elsewhere'); - expect(t.loose).toEqual([ - { - roomId: '!adhoc:x', - name: 'Meeting', - state: 'joined', - via: [], - parentId: null, - direct: false, - }, - ]); - }); - - it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => { - const tiers = buildEstate( - input({ - membership: new Map([ - ['!general:gamecult.org', 'join'], - ['!dm:x', 'join'], - ['!adhoc:x', 'join'], - ]), - strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'], - directRoomIds: ['!dm:x'], - }) - ); - expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']); - expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']); - }); - - it('never lists a room the user has not joined', () => { - expect( - strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] }) - ).toBeUndefined(); - }); - - it('is absent when there are no strays', () => { - expect(strays({})).toBeUndefined(); - }); -}); - -describe('direct invites', () => { - it('lists an invited DM in the Direct tier as invited, named from the store', () => { - const tiers = buildEstate( - input({ - membership: new Map([['!dm:x', 'invite']]), - directRoomIds: ['!dm:x'], - roomNames: new Map([['!dm:x', 'Void']]), - }) - ); - expect(tiers[tiers.length - 1].loose).toEqual([ - { roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true }, - ]); - }); -}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts deleted file mode 100644 index b9232beb..00000000 --- a/src/app/features/estate/buildEstate.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * The estate index: the only code that decides which rooms the navigator lists, - * in which tier and steward group, and whether each is joined, invited or joinable. - * - * The server's /hierarchy of the root Space is the access rule. A room the - * hierarchy does not return is never listed, whatever the client store knows; - * a room it returns is never hidden by the client. The one licensed addition is - * the user's own joined rooms that sit outside the Space (the Elsewhere tier). - * - * A room's steward is read from the hierarchy too: the content of the - * m.space.child entry that lists the room carries org.gamecult.steward, which an - * invited-only member can read (she cannot read the Space's state). - */ - -export const STEWARD_KEY = 'org.gamecult.steward'; - -export type SpaceChildContent = { - via?: string[]; - /** The Mind who stewards the room, as the accounts script writes it. */ - [STEWARD_KEY]?: { mind?: unknown }; -}; - -export type EstateHierarchyRoom = { - room_id: string; - name?: string; - canonical_alias?: string; - room_type?: string; - children_state?: { state_key: string; content?: SpaceChildContent }[]; -}; - -export type EstateMembership = 'joined' | 'invited' | 'joinable'; - -export type EstateRoom = { - roomId: string; - name: string; - state: EstateMembership; - /** Servers to join through, from the hierarchy's children_state. */ - via: string[]; - /** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */ - parentId: string | null; - direct: boolean; -}; - -export type EstateGroup = { - mind: string; - label: string; - rooms: EstateRoom[]; -}; - -export type EstateTier = { - id: string; - label: string; - /** Rooms without a steward. */ - loose: EstateRoom[]; - groups: EstateGroup[]; -}; - -export type EstateInput = { - rootId: string; - /** Rooms from the server's hierarchy of the root, in hierarchy order. */ - hierarchy: readonly EstateHierarchyRoom[]; - /** The user's own membership per room id, from the client store. */ - membership: ReadonlyMap; - /** Joined and invited direct messages. */ - directRoomIds: readonly string[]; - /** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */ - strayRoomIds?: readonly string[]; - /** Names for rooms the hierarchy does not name (direct and stray rooms). */ - roomNames?: ReadonlyMap; - /** Display names for Minds, keyed by localpart. */ - mindLabels?: ReadonlyMap; -}; - -export const DIRECT_TIER_ID = 'direct'; -export const ELSEWHERE_TIER_ID = 'elsewhere'; - -const SPACE_TYPE = 'm.space'; - -/** The Mind a Space child entry names as the room's steward, if any. */ -export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => { - const mind = content?.[STEWARD_KEY]?.mind; - return typeof mind === 'string' && mind !== '' ? mind : undefined; -}; - -const stateOf = ( - roomId: string, - membership: ReadonlyMap -): EstateMembership => { - const m = membership.get(roomId); - if (m === 'join') return 'joined'; - if (m === 'invite') return 'invited'; - return 'joinable'; -}; - -const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id; - -export const buildEstate = ({ - rootId, - hierarchy, - membership, - directRoomIds, - strayRoomIds = [], - roomNames, - mindLabels, -}: EstateInput): EstateTier[] => { - const direct = new Set(directRoomIds); - const byId = new Map(hierarchy.map((r) => [r.room_id, r])); - const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE); - const root = byId.get(rootId); - - // Root first, then every sub-Space in hierarchy order. - const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces; - - const tiers: EstateTier[] = tierSpaces.map((space) => { - const childIds = new Set((space.children_state ?? []).map((c) => c.state_key)); - const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] }; - const groups = new Map(); - - hierarchy.forEach((r) => { - if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; - const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content; - const room: EstateRoom = { - roomId: r.room_id, - name: roomLabel(r), - state: stateOf(r.room_id, membership), - via: content?.via ?? [], - parentId: space.room_id, - direct: false, - }; - const mind = stewardOfChild(content); - if (!mind) { - tier.loose.push(room); - return; - } - let group = groups.get(mind); - if (!group) { - group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] }; - groups.set(mind, group); - } - group.rooms.push(room); - }); - - tier.groups = [...groups.values()].sort((a, b) => - a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) - ); - return tier; - }); - - const directTier: EstateTier = { - id: DIRECT_TIER_ID, - label: 'Direct Messages', - loose: directRoomIds.map((roomId) => ({ - roomId, - name: roomNames?.get(roomId) ?? roomId, - state: stateOf(roomId, membership), - via: [], - parentId: null, - direct: true, - })), - groups: [], - }; - - const listedIds = new Set(hierarchy.map((r) => r.room_id)); - const elsewhereTier: EstateTier = { - id: ELSEWHERE_TIER_ID, - label: 'Elsewhere', - loose: strayRoomIds - .filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join') - .map((roomId) => ({ - roomId, - name: roomNames?.get(roomId) ?? roomId, - state: 'joined' as const, - via: [], - parentId: null, - direct: false, - })), - groups: [], - }; - - const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); - return [ - ...listed, - ...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []), - ...(directTier.loose.length > 0 ? [directTier] : []), - ]; -}; diff --git a/src/app/features/estate/config.test.ts b/src/app/features/estate/config.test.ts deleted file mode 100644 index 4304e1c2..00000000 --- a/src/app/features/estate/config.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readFileSync } from 'fs'; -import { describe, expect, it } from 'vitest'; - -describe('repo config.json', () => { - it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => { - const config = JSON.parse(readFileSync('config.json', 'utf8')); - expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org'); - }); -}); diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts deleted file mode 100644 index 761586c4..00000000 --- a/src/app/features/estate/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './EstateNavigator'; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts deleted file mode 100644 index 932e7cce..00000000 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - activateEstateRoom, - awaitMyJoin, - joinEstateRoomIdOrAlias, - joinEstateRoomOrThrow, - runJoinCommand, -} from './joinEstateRoom'; - -const room = (state: 'joined' | 'invited' | 'joinable') => ({ - roomId: '!r:gamecult.org', - state, - via: ['gamecult.org'], -}); - -/** A client that records every call; anything but joinRoom is a peek or a side door. */ -const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => { - const calls: string[] = []; - const mx = new Proxy({} as Record, { - get: (_t, prop: string) => { - if (prop === 'joinRoom') { - return (...args: unknown[]) => { - calls.push('joinRoom'); - return joinRoom(...args); - }; - } - if (prop === 'getRoom') return () => null; - if (prop === 'getSafeUserId') return () => '@me:test'; - return () => { - calls.push(prop); - throw new Error(`unexpected client call: ${prop}`); - }; - }, - }); - return { mx: mx as never, calls, joinRoom }; -}; - -describe('activateEstateRoom', () => { - it('opens a joined room without touching the client', async () => { - const { mx, calls } = recordingClient(); - expect(await activateEstateRoom(mx, room('joined'))).toEqual({ - kind: 'open', - roomId: '!r:gamecult.org', - }); - expect(calls).toEqual([]); - }); - - it.each(['invited', 'joinable'] as const)( - 'joins a %s room on the tap, through the hierarchy servers, and nothing else', - async (state) => { - const { mx, calls, joinRoom } = recordingClient(); - const result = await activateEstateRoom(mx, room(state)); - expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' }); - expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] }); - expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path - } - ); - - it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient( - vi - .fn() - .mockRejectedValue( - Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ) - ); - expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ - kind: 'refused', - message: 'Boom', - }); - const bare = recordingClient( - vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ); - expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ - kind: 'refused', - message: 'Could not join', - }); - }); - - it('reports the server refusal and does not decide access itself', async () => { - const refusal = Object.assign(new Error('M_FORBIDDEN'), { - httpStatus: 403, - errcode: 'M_FORBIDDEN', - data: { error: 'You are not invited to this room.' }, - }); - const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); - const result = await activateEstateRoom(mx, room('joinable')); - expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' }); - expect(joinRoom).toHaveBeenCalledTimes(1); - }); -}); - -/** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { - status?: number; - errcode?: string; - refuse?: boolean; - drop?: boolean; - failWrite?: boolean; - invites: Record; -}) => { - let direct: Record | undefined; - const writes: Record[] = []; - const joined: string[] = []; - const joinOpts: unknown[] = []; - const mx = { - getSafeUserId: () => '@me:test', - getRoom: (roomId: string) => ({ - getMyMembership: () => 'invite', - getMember: () => ({ - events: { - member: { - getContent: () => ({ membership: 'invite', is_direct: true }), - getSender: () => opts.invites[roomId], - }, - }, - }), - }), - getAccountData: () => (direct ? { getContent: () => direct } : undefined), - setAccountData: async (_t: string, content: Record) => { - if (opts.failWrite) throw new Error('account data write failed'); - await new Promise((r) => { - setTimeout(r, 5); - }); - writes.push(structuredClone(content)); - direct = structuredClone(content); - }, - joinRoom: async (roomId: string, joinArgs?: unknown) => { - joinOpts.push(joinArgs); - // the join lands only after m.direct has been written: record what it held - joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) - throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) - throw Object.assign(new Error('Gateway Time-out'), { - httpStatus: opts.status, - errcode: opts.errcode, - }); - if (opts.drop) throw new Error('fetch failed'); - return { roomId }; - }, - }; - return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; -}; -const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); - -describe('activateEstateRoom for a direct message invite', () => { - it('lists the room under the inviter before the join is sent', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('keeps both rooms when two DM joins are tapped together', async () => { - const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } }); - const results = await Promise.all([ - activateEstateRoom(c.mx, tap('!dm2:test')), - activateEstateRoom(c.mx, tap('!dm3:test')), - ]); - expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); - }); - - it('takes the room out again when the join is refused', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' }); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); -}); - -describe('activateEstateRoom when the join gets no answer', () => { - it('keeps the room listed and shows a retryable message', async () => { - const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('activateEstateRoom when the filing cannot be written', () => { - it('reports the failure and sends no join', async () => { - const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); - expect(c.joined).toEqual([]); - }); -}); - -describe('activateEstateRoom when a room that is not a direct message refuses', () => { - it('reports the refusal and leaves m.direct alone', async () => { - const joinRoom = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) - ); - const { mx, calls } = recordingClient(joinRoom); - const result = await activateEstateRoom(mx, room('joinable')); - expect(result.kind).toBe('refused'); - expect(calls).toEqual(['joinRoom']); - }); -}); - -describe('activateEstateRoom refusal bounds', () => { - it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ - status, - errcode: 'M_LIMIT_EXCEEDED', - invites: { '!dm1:test': '@friend:test' }, - }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); - - it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('activateEstateRoom when a gateway answers instead of the server', () => { - it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { - const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); - const result = await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(result.kind).toBe('refused'); - expect((result as { message: string }).message).toMatch(/tap again to retry/); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); - - it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ - status: 500, - errcode: 'M_UNKNOWN', - invites: { '!dm1:test': '@friend:test' }, - }); - await activateEstateRoom(c.mx, tap('!dm1:test')); - expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); - }); -}); - -describe('joinEstateRoomIdOrAlias, the room card and /join', () => { - it('files a direct invite opened by its link before the join is sent', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); - expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('hands the via servers to the join', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); - expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); - }); - - it('joins an alias or an unheld room without touching m.direct', async () => { - const { mx, calls } = recordingClient(); - await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); - expect(calls).toEqual(['joinRoom']); - }); -}); - -describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { - it('files a direct invite and answers the joined room id', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); - expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); - }); - - it('throws the refusal and unfiles the room', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({ '@friend:test': [] }); - }); -}); - -describe('runJoinCommand, /join', () => { - it('reports each refusal and joins the others', async () => { - const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); - const reported: string[] = []; - await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); - expect(reported).toEqual(['M_FORBIDDEN']); - expect(c.joined).toHaveLength(1); - }); - - it('reports nothing when the join is accepted', async () => { - const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); - const reported: string[] = []; - await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); - expect(reported).toEqual([]); - expect(c.joined).toHaveLength(1); - }); -}); - -describe('awaitMyJoin', () => { - /** A store that gains the room when the test says so, emitting like the SDK. */ - const store = () => { - let membership: string | null = null; - const handlers = new Set<(room: { roomId: string }, m: string) => void>(); - return { - mx: { - getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (e: string, h: (room: { roomId: string }, m: string) => void) => { - if (e === 'Room.myMembership') handlers.add(h); - }, - off: (e: string, h: (room: { roomId: string }, m: string) => void) => { - if (e === 'Room.myMembership') handlers.delete(h); - }, - }, - handlers, - become: (roomId: string, m: string) => { - membership = m; - handlers.forEach((h) => h({ roomId }, m)); - }, - }; - }; - - it('resolves at once when the store already holds the joined room', async () => { - const s = store(); - s.become('!r:x', 'join'); - expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true); - }); - - it('stays pending while the store still shows an invite, then resolves on join', async () => { - const s = store(); - let settled = false; - const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => { - settled = true; - return v; - }); - s.become('!r:x', 'invite'); - s.become('!other:x', 'join'); - await Promise.resolve(); - expect(settled).toBe(false); - s.become('!r:x', 'join'); - expect(await p).toBe(true); - expect(s.handlers.size).toBe(0); - }); - - it('gives up after the timeout and stops listening', async () => { - vi.useFakeTimers(); - const s = store(); - const p = awaitMyJoin(s.mx, '!r:x', 500); - await vi.advanceTimersByTimeAsync(500); - expect(await p).toBe(false); - expect(s.handlers.size).toBe(0); - vi.useRealTimers(); - }); -}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts deleted file mode 100644 index 4e1629d0..00000000 --- a/src/app/features/estate/joinEstateRoom.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { EstateRoom } from './buildEstate'; -import { - addRoomIdToMDirect, - isRoomAlias, - isRoomId, - removeRoomIdFromMDirect, -} from '../../utils/matrix'; -import { splitWithSpace } from '../../utils/common'; -import { isDirectInvite } from '../../utils/room'; - -export type EstateActivation = - | { kind: 'open'; roomId: string } - | { kind: 'joined'; roomId: string } - | { kind: 'refused'; message: string }; - -const LOST_JOIN_RESPONSE = - 'The server did not answer the join. It may have gone through; tap again to retry.'; - -type JoinClient = Parameters[0]; - -/** - * What a tap on an estate row does. A joined room opens; any other room is - * joined, and only here (plus a first post in the room view). Opening a room - * never joins it and the client has no peek path. The server decides whether - * the join is allowed; its refusal is reported, the row is not hidden. - * - * An invite marked is_direct carries the mark in the invite event the client - * already holds, and the join event does not repeat it. So the room is listed - * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A - * join that fails any other way (lost response, dropped connection, a gateway's - * 5xx or a status with no errcode) may still have been accepted upstream, so the - * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every client join goes through here: the estate tap, the inbox Accept, - * the room card, /join, the lobby and space Join buttons, the tombstone and - * predecessor links. One owner for joining. - */ -export const activateEstateRoom = async ( - mx: JoinClient, - room: Pick & { via?: string[] } -): Promise => { - if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; - const refusal = (e: unknown): EstateActivation => { - const err = e as { data?: { error?: string }; message?: string }; - return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; - }; - const refusedByServer = (e: unknown) => { - const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; - const status = err.httpStatus; - const errcode = err.errcode ?? err.data?.errcode; - return ( - typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' - ); - }; - const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; - const inviter = isDirectInvite(invite, mx.getSafeUserId()) - ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() - : undefined; - try { - if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter); - } catch (e) { - return refusal(e); - } - try { - const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); - return { kind: 'joined', roomId: joined.roomId }; - } catch (e) { - if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; - if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); - return refusal(e); - } -}; - -/** - * activateEstateRoom for a caller that holds only an id or alias (room card, - * /join): a room the client has an invite for is an invited room. - */ -export const joinEstateRoomIdOrAlias = ( - mx: JoinClient, - roomIdOrAlias: string, - via?: string[] -): Promise => - activateEstateRoom(mx, { - roomId: roomIdOrAlias, - state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', - via, - }); - -/** - * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a - * useAsyncCallback button): the joined room id, or an Error carrying the refusal. - */ -export const joinEstateRoomOrThrow = async ( - mx: JoinClient, - roomIdOrAlias: string, - via?: string[] -): Promise => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); - if (result.kind === 'refused') throw new Error(result.message); - return result.roomId; -}; - -/** - * The /join command: every room id or alias in the payload is joined, and each - * refusal is handed to report, never dropped. - */ -export const runJoinCommand = async ( - mx: JoinClient, - payload: string, - report: (message: string) => void -): Promise => { - const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); - const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); - results.forEach((r) => { - if (r.kind === 'refused') report(r.message); - }); -}; - -type MembershipSource = { - getRoom(roomId: string): { getMyMembership(): string } | null; - on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; - off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; -}; - -/** - * joinRoom resolves when the server accepts the join, before the client store - * holds the room; navigating then lands on the invite card. Resolves true once - * the store says the user's membership is join, false after timeoutMs. - */ -export const awaitMyJoin = ( - mx: MembershipSource, - roomId: string, - timeoutMs: number -): Promise => - new Promise((resolve) => { - if (mx.getRoom(roomId)?.getMyMembership() === 'join') { - resolve(true); - return; - } - let finish: (joined: boolean) => void = () => undefined; - const onMembership = (room: { roomId: string }, membership: string) => { - if (room.roomId === roomId && membership === 'join') finish(true); - }; - const timer = setTimeout(() => finish(false), timeoutMs); - finish = (joined) => { - clearTimeout(timer); - mx.off('Room.myMembership', onMembership); - resolve(joined); - }; - mx.on('Room.myMembership', onMembership); - }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts deleted file mode 100644 index d27d4710..00000000 --- a/src/app/features/estate/useEstate.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import { useAtomValue } from 'jotai'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; -import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room'; -import { useStateEventCallback } from '../../hooks/useStateEventCallback'; -import { allRoomsAtom } from '../../state/room-list/roomList'; -import { allInvitesAtom } from '../../state/room-list/inviteList'; -import { mDirectAtom } from '../../state/mDirectList'; -import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate'; - -const HIERARCHY_DEPTH = 10; - -type AliasedRoom = { - roomId: string; - getCanonicalAlias(): string | null; - getAltAliases(): string[]; -}; - -const findLoadedRoom = ( - mx: ReturnType, - alias: string -): string | undefined => - (mx.getRooms() as AliasedRoom[]).find( - (r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias) - )?.roomId; - -/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ -export const useEstateRootId = (): { rootId?: string; error?: Error } => { - const mx = useMatrixClient(); - const alias = useClientConfig().featuredCommunities?.spaces?.[0]; - const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias); - const resolved = useQuery({ - queryKey: ['estate-root', alias], - queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id, - enabled: !!alias && !loaded, - }); - if (!alias) return { error: new Error('No estate root Space is configured') }; - return { rootId: loaded || resolved.data, error: resolved.error ?? undefined }; -}; - -export type Estate = { - fetching: boolean; - error: Error | null; - tiers: EstateTier[]; -}; - -/** - * Feeds buildEstate: the server hierarchy of the root Space (which also carries - * each room's steward), the user's membership, m.direct and direct invites, and - * her joined rooms outside the Space. Recomputes on membership and Space child changes. - */ -export const useEstate = (rootId: string): Estate => { - const mx = useMatrixClient(); - const queryClient = useQueryClient(); - const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH); - const joined = useAtomValue(allRoomsAtom); - const invited = useAtomValue(allInvitesAtom); - const mDirects = useAtomValue(mDirectAtom); - - const refetchHierarchy = useCallback( - () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), - [queryClient, rootId] - ); - useStateEventCallback( - mx, - useCallback( - (event) => { - if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); - }, - [refetchHierarchy] - ) - ); - // What the server lets this user see changes with invitations. - useEffect(() => { - refetchHierarchy(); - }, [invited.length, refetchHierarchy]); - - const tiers = useMemo(() => { - const membership = new Map(); - invited.forEach((id) => membership.set(id, 'invite')); - joined.forEach((id) => membership.set(id, 'join')); - const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[]; - const domain = mx.getDomain(); - const mindLabels = new Map(); - hierarchy.forEach((r) => - r.children_state?.forEach((c) => { - const mind = stewardOfChild(c.content); - const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName; - if (mind && name) mindLabels.set(mind, name); - }) - ); - const userId = mx.getUserId(); - const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId)); - const directRoomIds = [ - ...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]), - ]; - const strayRoomIds = joined.filter((id) => { - const r = mx.getRoom(id); - return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r); - }); - const roomNames = new Map(); - [...directInvites, ...strayRoomIds].forEach((id) => { - const name = mx.getRoom(id)?.name; - if (name) roomNames.set(id, name); - }); - return buildEstate({ - rootId, - hierarchy, - membership, - directRoomIds, - strayRoomIds, - roomNames, - mindLabels, - }); - }, [mx, rootId, rooms, joined, invited, mDirects]); - - return { fetching, error, tiers }; -}; diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 1802e3b0..c6aff741 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -200,12 +200,16 @@ type HierarchyItemMenuProps = { joined: boolean; powerLevels?: IPowerLevels; canEditChild: boolean; + pinned?: boolean; + onTogglePin?: (roomId: string) => void; }; export function HierarchyItemMenu({ item, joined, powerLevels, canEditChild, + pinned, + onTogglePin, }: HierarchyItemMenuProps) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -260,6 +264,20 @@ export function HierarchyItemMenu({ {joined && ( + {onTogglePin && ( + { + onTogglePin(item.roomId); + handleRequestClose(); + }} + > + + {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'} + + + )} { + const sideSpaces = sidebarItems.flatMap((item) => { + if (typeof item === 'string') return item; + return item.content; + }); + + return new Set(sideSpaces); + }, [sidebarItems]); + const [spacesItems, setSpacesItem] = useState>(() => new Map()); useElementSizeObserver( @@ -395,6 +415,18 @@ export function Lobby() { navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; + const togglePinToSidebar = useCallback( + (rId: string) => { + const newItems = sidebarItemWithout(sidebarItems, rId); + if (!sidebarSpaces.has(rId)) { + newItems.push(rId); + } + const newSpacesContent = makeCinnySpacesContent(mx, newItems); + mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); + }, + [mx, sidebarItems, sidebarSpaces] + ); + return ( @@ -467,6 +499,8 @@ export function Lobby() { disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} + pinned={sidebarSpaces.has(item.space.roomId)} + togglePinToSidebar={togglePinToSidebar} onSpacesFound={handleSpacesFound} onOpenRoom={handleOpenRoom} /> diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 132aaf8c..7de59acd 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, Room } from 'matrix-js-sdk'; +import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,7 +38,6 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -47,8 +46,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -61,8 +60,9 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.message} + {joinState.error.data?.error || joinState.error.message} + {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.message} + aria-label={joinState.error.data?.error || joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 9a283378..280b8a5a 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -35,6 +35,8 @@ type SpaceHierarchyProps = { disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; + pinned: boolean; + togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onOpenRoom: MouseEventHandler; }; @@ -56,6 +58,8 @@ export const SpaceHierarchy = forwardRef( disabledReorder, nextSpaceId, getRoom, + pinned, + togglePinToSidebar, onOpenRoom, onSpacesFound, }, @@ -132,6 +136,8 @@ export const SpaceHierarchy = forwardRef( canEditChild={ !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) } + pinned={pinned} + onTogglePin={togglePinToSidebar} /> ) } diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index e1ffbfe5..ed3eaff9 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { Room } from 'matrix-js-sdk'; +import { MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,7 +38,6 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -113,8 +112,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 3556e6fc..24f0b80e 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,7 +8,6 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; -import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -19,14 +18,16 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return joinEstateRoomOrThrow(mx, replacementRoomId, via); + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); }; return ( diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index d3dcde96..a5f0dbf9 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) { Cinny - v4.12.8 + v4.12.7 Yet another matrix client. diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 11f379dc..c02b0e94 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,6 +15,7 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, + isRoomAlias, isRoomId, isServerName, isUserId, @@ -25,7 +26,6 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -227,7 +227,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), + exe: async (payload) => { + const rawIds = splitWithSpace(payload); + const roomIdOrAliases = rawIds.filter( + (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) + ); + roomIdOrAliases.forEach(async (idOrAlias) => { + await mx.joinRoom(idOrAlias); + }); + }, }, [Command.Leave]: { name: Command.Leave, diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts new file mode 100644 index 00000000..16aba225 --- /dev/null +++ b/src/app/hooks/useSidebarItems.ts @@ -0,0 +1,138 @@ +import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { useMatrixClient } from './useMatrixClient'; +import { getAccountData, isSpace } from '../utils/room'; +import { Membership } from '../../types/matrix/room'; +import { useAccountDataCallback } from './useAccountDataCallback'; + +export type ISidebarFolder = { + name?: string; + id: string; + content: string[]; +}; +export type TSidebarItem = string | ISidebarFolder; +export type SidebarItems = Array; + +export type InCinnySpacesContent = { + shortcut?: string[]; + sidebar?: SidebarItems; +}; + +export const parseSidebar = ( + mx: MatrixClient, + orphanSpaces: string[], + content?: InCinnySpacesContent +) => { + const sidebar = content?.sidebar ?? content?.shortcut ?? []; + const orphans = new Set(orphanSpaces); + + const items: SidebarItems = []; + + const safeToAdd = (spaceId: string): boolean => { + if (typeof spaceId !== 'string') return false; + const space = mx.getRoom(spaceId); + if (space?.getMyMembership() !== Membership.Join) return false; + return isSpace(space); + }; + + sidebar.forEach((item) => { + if (typeof item === 'string') { + if (safeToAdd(item) && !items.includes(item)) { + orphans.delete(item); + items.push(item); + } + return; + } + if ( + typeof item === 'object' && + typeof item.id === 'string' && + Array.isArray(item.content) && + !items.find((i) => (typeof i === 'string' ? false : i.id === item.id)) + ) { + const safeContent = item.content.filter(safeToAdd); + safeContent.forEach((i) => orphans.delete(i)); + items.push({ + ...item, + content: Array.from(new Set(safeContent)), + }); + } + }); + + orphans.forEach((spaceId) => items.push(spaceId)); + return items; +}; + +export const useSidebarItems = ( + orphanSpaces: string[] +): [SidebarItems, Dispatch>] => { + const mx = useMatrixClient(); + + const [sidebarItems, setSidebarItems] = useState(() => { + const inCinnySpacesContent = getAccountData( + mx, + AccountDataEvent.CinnySpaces + )?.getContent(); + return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); + }); + + useEffect(() => { + const inCinnySpacesContent = getAccountData( + mx, + AccountDataEvent.CinnySpaces + )?.getContent(); + setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); + }, [mx, orphanSpaces]); + + useAccountDataCallback( + mx, + useCallback( + (mEvent) => { + if (mEvent.getType() === AccountDataEvent.CinnySpaces) { + const newContent = mEvent.getContent(); + setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); + } + }, + [mx, orphanSpaces] + ) + ); + + return [sidebarItems, setSidebarItems]; +}; + +export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { + const newItems: SidebarItems = items + .map((item) => { + if (typeof item === 'string') { + if (item === roomId) return null; + return item; + } + if (item.content.includes(roomId)) { + const newContent = item.content.filter((id) => id !== roomId); + if (newContent.length === 0) return null; + return { + ...item, + content: newContent, + }; + } + return item; + }) + .filter((item) => item !== null) as SidebarItems; + + return newItems; +}; + +export const makeCinnySpacesContent = ( + mx: MatrixClient, + items: SidebarItems +): InCinnySpacesContent => { + const currentInSpaces = + getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; + + const newSpacesContent: InCinnySpacesContent = { + ...currentInSpaces, + sidebar: items, + }; + + return newSpacesContent; +}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 59889c1c..ad34e3f4 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -276,8 +276,7 @@ export type FetchSpaceHierarchyLevelData = { }; export const useFetchSpaceHierarchyLevel = ( roomId: string, - enable: boolean, - maxDepth = 1 + enable: boolean ): FetchSpaceHierarchyLevelData => { const mx = useMatrixClient(); const pageNoRef = useRef(0); @@ -287,13 +286,13 @@ export const useFetchSpaceHierarchyLevel = ( string[], string | undefined > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam), - [roomId, maxDepth, mx] + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), + [roomId, mx] ); const queryResponse = useInfiniteQuery({ refetchOnMount: enable, - queryKey: [roomId, 'hierarchy_level', maxDepth], + queryKey: [roomId, 'hierarchy_level'], initialPageParam: undefined, queryFn: fetchLevel, getNextPageParam: (result) => { diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 4bfe01da..cdbb9dba 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -1,8 +1,7 @@ import { lightTheme } from 'folds'; import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { onDarkFontWeight, onLightFontWeight } from '../../config.css'; -import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css'; -import '../../gamecult.css'; +import { butterTheme, darkTheme, silverTheme } from '../../colors.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; @@ -39,14 +38,8 @@ export const ButterTheme: Theme = { classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'], }; -export const GameCultTheme: Theme = { - id: 'gamecult-theme', - kind: ThemeKind.Dark, - classNames: ['gamecult-theme', gamecultTheme, onDarkFontWeight, 'prism-dark'], -}; - export const useThemes = (): Theme[] => { - const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []); + const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []); return themes; }; @@ -54,7 +47,6 @@ export const useThemes = (): Theme[] => { export const useThemeNames = (): Record => useMemo( () => ({ - [GameCultTheme.id]: 'GameCult', [LightTheme.id]: 'Light', [SilverTheme.id]: 'Silver', [DarkTheme.id]: 'Dark', @@ -92,15 +84,15 @@ export const useActiveTheme = (): Theme => { const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); if (!systemTheme) { - const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme; + const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; return selectedTheme; } const selectedTheme = systemThemeKind === ThemeKind.Dark - ? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme - : themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme; + ? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme + : themes.find((theme) => theme.id === lightThemeId) ?? LightTheme; return selectedTheme; }; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 225b1b80..ca947ac6 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,6 +1,28 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; +import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; + +type MobileFriendlyClientNavProps = { + children: ReactNode; +}; +export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { + const screenSize = useScreenSizeContext(); + const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); + const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); + const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); + const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); + const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); + + if ( + screenSize === ScreenSize.Mobile && + !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) + ) { + return null; + } + + return children; +} type MobileFriendlyPageNavProps = { path: string; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 159162dd..4de42081 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,19 +40,19 @@ import { getSpaceLobbyPath, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; -import { EstatePageNav } from './client/EstatePageNav'; +import { SidebarNav } from './client/SidebarNav'; import { PageRoot } from '../components/page'; import { ScreenSize } from '../hooks/useScreenSize'; -import { MobileFriendlyPageNav } from './MobileFriendly'; +import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; @@ -127,7 +127,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - + + + + } + > @@ -151,7 +157,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) }> + + + + } + > } @@ -172,7 +184,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) }> + + + + } + > } @@ -192,7 +210,13 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={SPACE_PATH} element={ - }> + + + + } + > diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 983ca684..69d50cdb 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,20 +1,29 @@ import React, { ReactNode, useEffect } from 'react'; import { configClass, varsClass } from 'folds'; import { - GameCultTheme, + DarkTheme, + LightTheme, ThemeContextProvider, + ThemeKind, useActiveTheme, + useSystemThemeKind, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { + const systemThemeKind = useSystemThemeKind(); + useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - // The GameCult identity is single-theme: signed-out pages ignore the system scheme. - document.body.classList.add(...GameCultTheme.classNames); - }, []); + if (systemThemeKind === ThemeKind.Dark) { + document.body.classList.add(...DarkTheme.classNames); + } + if (systemThemeKind === ThemeKind.Light) { + document.body.classList.add(...LightTheme.classNames); + } + }, [systemThemeKind]); return null; } diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index b0b94319..f9445b59 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.12.8 + v4.12.7 Twitter diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index e1d83c95..4a077ba6 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,6 +1,15 @@ import React, { ReactNode } from 'react'; import { Box } from 'folds'; -export function ClientLayout({ children }: { children: ReactNode }) { - return {children}; +type ClientLayoutProps = { + nav: ReactNode; + children: ReactNode; +}; +export function ClientLayout({ nav, children }: ClientLayoutProps) { + return ( + + {nav} + {children} + + ); } diff --git a/src/app/pages/client/EstatePageNav.tsx b/src/app/pages/client/EstatePageNav.tsx deleted file mode 100644 index 470cbdd7..00000000 --- a/src/app/pages/client/EstatePageNav.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { MobileFriendlyPageNav } from '../MobileFriendly'; -import { EstateNavigator } from '../../features/estate'; - -/** - * Where the navigator mounts in a route's nav slot. The wide-screen placement - * question (ratatoskr:question:desktop-navigator-placement) is answered here. - */ -export function EstatePageNav({ path }: { path: string }) { - return ( - - - - ); -} diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx new file mode 100644 index 00000000..a2e1b682 --- /dev/null +++ b/src/app/pages/client/SidebarNav.tsx @@ -0,0 +1,56 @@ +import React, { useRef } from 'react'; +import { Scroll } from 'folds'; + +import { + Sidebar, + SidebarContent, + SidebarStackSeparator, + SidebarStack, +} from '../../components/sidebar'; +import { + DirectTab, + HomeTab, + SpaceTabs, + InboxTab, + ExploreTab, + SettingsTab, + UnverifiedTab, + SearchTab, +} from './sidebar'; +import { CreateTab } from './sidebar/CreateTab'; + +export function SidebarNav() { + const scrollRef = useRef(null); + + return ( + + + + + + + + + + + + + + } + sticky={ + <> + + + + + + + + + } + /> + + ); +} diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 6a25392c..69eead53 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.12.8 + v4.12.7 } diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx new file mode 100644 index 00000000..04edf109 --- /dev/null +++ b/src/app/pages/client/direct/Direct.tsx @@ -0,0 +1,280 @@ +import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { + Avatar, + Box, + Button, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + config, + toRem, +} from 'folds'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import FocusTrap from 'focus-trap-react'; +import { useNavigate } from 'react-router-dom'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { factoryRoomIdByActivity } from '../../../utils/sort'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavEmptyCenter, + NavEmptyLayout, + NavItem, + NavItemContent, +} from '../../../components/nav'; +import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { VirtualTile } from '../../../components/virtualizer'; +import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; +import { useDirectRooms } from './useDirectRooms'; +import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { markAsRead } from '../../../utils/notifications'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; + +type DirectMenuProps = { + requestClose: () => void; +}; +const DirectMenu = forwardRef(({ requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const orphanRooms = useDirectRooms(); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +function DirectHeader() { + const [menuAnchor, setMenuAnchor] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + <> + + + + + Direct Messages + + + + + + + + + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + + ); +} + +function DirectEmpty() { + const navigate = useNavigate(); + + return ( + + } + title={ + + No Direct Messages + + } + content={ + + You do not have any direct messages yet. + + } + options={ + + } + /> + + ); +} + +const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); +export function Direct() { + const mx = useMatrixClient(); + useNavToActivePathMapper('direct'); + const scrollRef = useRef(null); + const directs = useDirectRooms(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const navigate = useNavigate(); + + const createDirectSelected = useDirectCreateSelected(); + + const selectedRoomId = useSelectedRoom(); + const noRoomToDisplay = directs.length === 0; + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const sortedDirects = useMemo(() => { + const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); + if (closedCategories.has(DEFAULT_CATEGORY_ID)) { + return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); + } + return items; + }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); + + const virtualizer = useVirtualizer({ + count: sortedDirects.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 38, + overscan: 10, + }); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + return ( + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getDirectCreatePath())}> + + + + + + + + Create Chat + + + + + + + + + + + Chats + + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedDirects[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; + + return ( + + + + ); + })} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index aaa8c929..d247bbc0 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,2 +1,3 @@ +export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx new file mode 100644 index 00000000..d87db963 --- /dev/null +++ b/src/app/pages/client/home/Home.tsx @@ -0,0 +1,362 @@ +import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Avatar, + Box, + Button, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + config, + toRem, +} from 'folds'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useAtom, useAtomValue } from 'jotai'; +import FocusTrap from 'focus-trap-react'; +import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavEmptyCenter, + NavEmptyLayout, + NavItem, + NavItemContent, + NavLink, +} from '../../../components/nav'; +import { + encodeSearchParamValueArray, + getExplorePath, + getHomeCreatePath, + getHomeRoomPath, + getHomeSearchPath, + withSearchParam, +} from '../../pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { + useHomeCreateSelected, + useHomeSearchSelected, +} from '../../../hooks/router/useHomeSelected'; +import { useHomeRooms } from './useHomeRooms'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { VirtualTile } from '../../../components/virtualizer'; +import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; +import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { markAsRead } from '../../../utils/notifications'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { UseStateProvider } from '../../../components/UseStateProvider'; +import { JoinAddressPrompt } from '../../../components/join-address-prompt'; +import { _RoomSearchParams } from '../../paths'; + +type HomeMenuProps = { + requestClose: () => void; +}; +const HomeMenu = forwardRef(({ requestClose }, ref) => { + const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +function HomeHeader() { + const [menuAnchor, setMenuAnchor] = useState(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + <> + + + + + Home + + + + + + + + + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + + ); +} + +function HomeEmpty() { + const navigate = useNavigate(); + + return ( + + } + title={ + + No Rooms + + } + content={ + + You do not have any rooms yet. + + } + options={ + <> + + + + } + /> + + ); +} + +const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); +export function Home() { + const mx = useMatrixClient(); + useNavToActivePathMapper('home'); + const scrollRef = useRef(null); + const rooms = useHomeRooms(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const navigate = useNavigate(); + + const selectedRoomId = useSelectedRoom(); + const createRoomSelected = useHomeCreateSelected(); + const searchSelected = useHomeSearchSelected(); + const noRoomToDisplay = rooms.length === 0; + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const sortedRooms = useMemo(() => { + const items = Array.from(rooms).sort( + closedCategories.has(DEFAULT_CATEGORY_ID) + ? factoryRoomIdByActivity(mx) + : factoryRoomIdByAtoZ(mx) + ); + if (closedCategories.has(DEFAULT_CATEGORY_ID)) { + return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); + } + return items; + }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); + + const virtualizer = useVirtualizer({ + count: sortedRooms.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 38, + overscan: 10, + }); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + return ( + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getHomeCreatePath())}> + + + + + + + + Create Room + + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + + Join with Address + + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + + + + + + + + + Message Search + + + + + + + + + + + Rooms + + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; + + return ( + + + + ); + })} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 5669f658..80764481 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,2 +1,3 @@ +export * from './Home'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index ca26bac3..20518e56 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -45,8 +45,12 @@ import { } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix'; -import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; +import { + addRoomIdToMDirect, + getMxIdLocalPart, + guessDmRoomUserId, + rateLimitedActions, +} from '../../../utils/matrix'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; @@ -156,20 +160,24 @@ function InviteCard({ hideAvatar, }: InviteCardProps) { const mx = useMatrixClient(); + const userId = mx.getSafeUserId(); + const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { - const result = await activateEstateRoom(mx, { - roomId: invite.roomId, - state: 'invited', - via: [], - }); - if (result.kind === 'refused') throw new Error(result.message); + const dmUserId = isDirectInvite(invite.room, userId) + ? guessDmRoomUserId(invite.room, userId) + : undefined; + + await mx.joinRoom(invite.roomId); + if (dmUserId) { + await addRoomIdToMDirect(mx, invite.roomId, dmUserId); + } onNavigate(invite.roomId, invite.isSpace); - }, [mx, invite, onNavigate]) + }, [mx, invite, userId, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx new file mode 100644 index 00000000..e6575cb4 --- /dev/null +++ b/src/app/pages/client/sidebar/CreateTab.tsx @@ -0,0 +1,134 @@ +import React, { MouseEventHandler, useState } from 'react'; +import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useNavigate } from 'react-router-dom'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { stopPropagation } from '../../../utils/keyboard'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SettingTile } from '../../../components/setting-tile'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { + encodeSearchParamValueArray, + getCreatePath, + getSpacePath, + withSearchParam, +} from '../../pathUtils'; +import { useCreateSelected } from '../../../hooks/router/useCreateSelected'; +import { JoinAddressPrompt } from '../../../components/join-address-prompt'; +import { _RoomSearchParams } from '../../paths'; + +export function CreateTab() { + const createSelected = useCreateSelected(); + + const navigate = useNavigate(); + const [menuCords, setMenuCords] = useState(); + const [joinAddress, setJoinAddress] = useState(false); + + const handleMenu: MouseEventHandler = (evt) => { + setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); + }; + + const handleCreateSpace = () => { + navigate(getCreatePath()); + setMenuCords(undefined); + }; + + const handleJoinWithAddress = () => { + setJoinAddress(true); + setMenuCords(undefined); + }; + + return ( + + + {(triggerRef) => ( + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + + }> + Create Space + + Build a space for your community. + + + + + }> + Join with Address + + Become a part of existing community. + + + + + + + } + > + + + + {joinAddress && ( + setJoinAddress(false)} + onOpen={(roomIdOrAlias, viaServers) => { + setJoinAddress(false); + const path = getSpacePath(roomIdOrAlias); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx new file mode 100644 index 00000000..dcd44e2f --- /dev/null +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -0,0 +1,138 @@ +import React, { MouseEventHandler, forwardRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtomValue } from 'jotai'; +import { useDirects } from '../../../state/hooks/roomList'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { getDirectPath, joinPathComponent } from '../../pathUtils'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, +} from '../../../components/sidebar'; +import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; +import { UnreadBadge } from '../../../components/unread-badge'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; +import { useDirectRooms } from '../direct/useDirectRooms'; +import { markAsRead } from '../../../utils/notifications'; +import { stopPropagation } from '../../../utils/keyboard'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; + +type DirectMenuProps = { + requestClose: () => void; +}; +const DirectMenu = forwardRef(({ requestClose }, ref) => { + const orphanRooms = useDirectRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +export function DirectTab() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + + const mDirects = useAtomValue(mDirectAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const directUnread = useRoomsUnread(directs, roomToUnreadAtom); + const [menuAnchor, setMenuAnchor] = useState(); + + const directSelected = useDirectSelected(); + + const handleDirectClick = () => { + const activePath = navToActivePath.get('direct'); + if (activePath && screenSize !== ScreenSize.Mobile) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getDirectPath()); + }; + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + return ( + + + {(triggerRef) => ( + + + + )} + + {directUnread && ( + 0}> + 0} count={directUnread.total} /> + + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + )} + + ); +} diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx new file mode 100644 index 00000000..a45b5830 --- /dev/null +++ b/src/app/pages/client/sidebar/ExploreTab.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Icon, Icons } from 'folds'; +import { useNavigate } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { useExploreSelected } from '../../../hooks/router/useExploreSelected'; +import { + getExploreFeaturedPath, + getExplorePath, + getExploreServerPath, + joinPathComponent, +} from '../../pathUtils'; +import { useClientConfig } from '../../../hooks/useClientConfig'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { getMxIdServer } from '../../../utils/matrix'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; + +export function ExploreTab() { + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const clientConfig = useClientConfig(); + const navigate = useNavigate(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + + const exploreSelected = useExploreSelected(); + + const handleExploreClick = () => { + if (screenSize === ScreenSize.Mobile) { + navigate(getExplorePath()); + return; + } + + const activePath = navToActivePath.get('explore'); + if (activePath) { + navigate(joinPathComponent(activePath)); + return; + } + + if (clientConfig.featuredCommunities?.openAsDefault) { + navigate(getExploreFeaturedPath()); + return; + } + const userId = mx.getUserId(); + const userServer = userId ? getMxIdServer(userId) : undefined; + if (userServer) { + navigate(getExploreServerPath(userServer)); + return; + } + navigate(getExplorePath()); + }; + + return ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx new file mode 100644 index 00000000..778b729e --- /dev/null +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -0,0 +1,140 @@ +import React, { MouseEventHandler, forwardRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; +import { useAtomValue } from 'jotai'; +import FocusTrap from 'focus-trap-react'; +import { useOrphanRooms } from '../../../state/hooks/roomList'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { getHomePath, joinPathComponent } from '../../pathUtils'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, +} from '../../../components/sidebar'; +import { useHomeSelected } from '../../../hooks/router/useHomeSelected'; +import { UnreadBadge } from '../../../components/unread-badge'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; +import { useHomeRooms } from '../home/useHomeRooms'; +import { markAsRead } from '../../../utils/notifications'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; + +type HomeMenuProps = { + requestClose: () => void; +}; +const HomeMenu = forwardRef(({ requestClose }, ref) => { + const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const mx = useMatrixClient(); + + const handleMarkAsRead = () => { + if (!unread) return; + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); + requestClose(); + }; + + return ( + + + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + + + ); +}); + +export function HomeTab() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + + const mDirects = useAtomValue(mDirectAtom); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); + const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom); + const homeSelected = useHomeSelected(); + const [menuAnchor, setMenuAnchor] = useState(); + + const handleHomeClick = () => { + const activePath = navToActivePath.get('home'); + if (activePath && screenSize !== ScreenSize.Mobile) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getHomePath()); + }; + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + + + {(triggerRef) => ( + + + + )} + + {homeUnread && ( + 0}> + 0} count={homeUnread.total} /> + + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + )} + + ); +} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx new file mode 100644 index 00000000..7ceb5c49 --- /dev/null +++ b/src/app/pages/client/sidebar/SearchTab.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Icon, Icons } from 'folds'; +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; +import { searchModalAtom } from '../../../state/searchModal'; + +export function SearchTab() { + const [opened, setOpen] = useAtom(searchModalAtom); + + const open = () => setOpen(true); + + return ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx new file mode 100644 index 00000000..6fc528a1 --- /dev/null +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -0,0 +1,866 @@ +import React, { + MouseEventHandler, + ReactNode, + RefObject, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Icon, + IconButton, + Icons, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + config, + toRem, +} from 'folds'; +import { useAtom, useAtomValue } from 'jotai'; +import { Room } from 'matrix-js-sdk'; +import { + draggable, + dropTargetForElements, + monitorForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { + attachInstruction, + extractInstruction, + Instruction, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import FocusTrap from 'focus-trap-react'; +import { + useOrphanSpaces, + useRecursiveChildScopeFactory, + useSpaceChildren, +} from '../../../state/hooks/roomList'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, + SidebarStack, + SidebarStackSeparator, + SidebarFolder, + SidebarFolderDropTarget, +} from '../../../components/sidebar'; +import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; +import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; +import { UnreadBadge } from '../../../components/unread-badge'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; +import { RoomAvatar } from '../../../components/room-avatar'; +import { nameInitials, randomStr } from '../../../utils/common'; +import { + ISidebarFolder, + SidebarItems, + TSidebarItem, + makeCinnySpacesContent, + parseSidebar, + sidebarItemWithout, + useSidebarItems, +} from '../../../hooks/useSidebarItems'; +import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; +import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { markAsRead } from '../../../utils/notifications'; +import { copyToClipboard } from '../../../utils/dom'; +import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; +import { getRoomAvatarUrl } from '../../../utils/room'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { InviteUserPrompt } from '../../../components/invite-user-prompt'; + +type SpaceMenuProps = { + room: Room; + requestClose: () => void; + onUnpin?: (roomId: string) => void; +}; +const SpaceMenu = forwardRef( + ({ room, requestClose, onUnpin }, ref) => { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const roomToParents = useAtomValue(roomToParentsAtom); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openSpaceSettings = useOpenSpaceSettings(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const allChild = useSpaceChildren( + allRoomsAtom, + room.roomId, + useRecursiveChildScopeFactory(mx, roomToParents) + ); + const unread = useRoomsUnread(allChild, roomToUnreadAtom); + + const handleMarkAsRead = () => { + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + requestClose(); + }; + + const handleUnpin = () => { + onUnpin?.(room.roomId); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openSpaceSettings(room.roomId); + requestClose(); + }; + + return ( + + {invitePrompt && room && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + + } + radii="300" + disabled={!unread} + > + + Mark as Read + + + {onUnpin && ( + } + > + + Unpin + + + )} + + + + } + radii="300" + aria-pressed={invitePrompt} + disabled={!canInvite} + > + + Invite + + + } + radii="300" + > + + Copy Link + + + } + radii="300" + > + + Space Settings + + + + + ); + } +); + +type InstructionType = Instruction['type']; +type FolderDraggable = { + folder: ISidebarFolder; + spaceId?: string; + open?: boolean; +}; +type SidebarDraggable = string | FolderDraggable; + +const useDraggableItem = ( + item: SidebarDraggable, + targetRef: RefObject, + onDragging: (item?: SidebarDraggable) => void, + dragHandleRef?: RefObject +): boolean => { + const [dragging, setDragging] = useState(false); + + useEffect(() => { + const target = targetRef.current; + const dragHandle = dragHandleRef?.current ?? undefined; + + return !target + ? undefined + : draggable({ + element: target, + dragHandle, + getInitialData: () => ({ item }), + onDragStart: () => { + setDragging(true); + onDragging?.(item); + }, + onDrop: () => { + setDragging(false); + onDragging?.(undefined); + }, + }); + }, [targetRef, dragHandleRef, item, onDragging]); + + return dragging; +}; + +const useDropTarget = ( + item: SidebarDraggable, + targetRef: RefObject +): Instruction | undefined => { + const [dropState, setDropState] = useState(); + + useEffect(() => { + const target = targetRef.current; + if (!target) return undefined; + + return dropTargetForElements({ + element: target, + canDrop: ({ source }) => { + const dragItem = source.data.item as SidebarDraggable; + return dragItem !== item; + }, + getData: ({ input, element }) => { + const block: Instruction['type'][] = ['reparent']; + if (typeof item === 'object' && item.spaceId) block.push('make-child'); + + const insData = attachInstruction( + {}, + { + input, + element, + currentLevel: 0, + indentPerLevel: 0, + mode: 'standard', + block, + } + ); + + const instruction: Instruction | null = extractInstruction(insData); + setDropState(instruction ?? undefined); + + return { + item, + instructionType: instruction ? instruction.type : undefined, + }; + }, + onDragLeave: () => setDropState(undefined), + onDrop: () => setDropState(undefined), + }); + }, [item, targetRef]); + + return dropState; +}; + +function useDropTargetInstruction( + item: SidebarDraggable, + targetRef: RefObject, + instructionType: T +): T | undefined { + const [dropState, setDropState] = useState(); + + useEffect(() => { + const target = targetRef.current; + if (!target) return undefined; + + return dropTargetForElements({ + element: target, + canDrop: ({ source }) => { + const dragItem = source.data.item as SidebarDraggable; + return dragItem !== item; + }, + getData: () => { + setDropState(instructionType); + + return { + item, + instructionType, + }; + }, + onDragLeave: () => setDropState(undefined), + onDrop: () => setDropState(undefined), + }); + }, [item, targetRef, instructionType]); + + return dropState; +} + +const useDnDMonitor = ( + scrollRef: RefObject, + onDragging: (dragItem?: SidebarDraggable) => void, + onReorder: ( + draggable: SidebarDraggable, + container: SidebarDraggable, + instruction: InstructionType + ) => void +) => { + useEffect(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) { + throw Error('Scroll element ref not configured'); + } + + return combine( + monitorForElements({ + onDrop: ({ source, location }) => { + onDragging(undefined); + const { dropTargets } = location.current; + if (dropTargets.length === 0) return; + const item = source.data.item as SidebarDraggable; + const containerItem = dropTargets[0].data.item as SidebarDraggable; + const instructionType = dropTargets[0].data.instructionType as + | InstructionType + | undefined; + if (!instructionType) return; + onReorder(item, containerItem, instructionType); + }, + }), + autoScrollForElements({ + element: scrollElement, + }) + ); + }, [scrollRef, onDragging, onReorder]); +}; + +type SpaceTabProps = { + space: Room; + selected: boolean; + onClick: MouseEventHandler; + folder?: ISidebarFolder; + onDragging: (dragItem?: SidebarDraggable) => void; + disabled?: boolean; + onUnpin?: (roomId: string) => void; +}; +function SpaceTab({ + space, + selected, + onClick, + folder, + onDragging, + disabled, + onUnpin, +}: SpaceTabProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const targetRef = useRef(null); + + const spaceDraggable: SidebarDraggable = useMemo( + () => + folder + ? { + folder, + spaceId: space.roomId, + } + : space.roomId, + [folder, space] + ); + + useDraggableItem(spaceDraggable, targetRef, onDragging); + const dropState = useDropTarget(spaceDraggable, targetRef); + const dropType = dropState?.type; + + const [menuAnchor, setMenuAnchor] = useState(); + + const handleContextMenu: MouseEventHandler = (evt) => { + evt.preventDefault(); + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + + {(unread) => ( + + + {(triggerRef) => ( + + ( + {nameInitials(space.name, 2)} + )} + /> + + )} + + {unread && ( + 0}> + 0} count={unread.total} /> + + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + onUnpin={onUnpin} + /> + + } + /> + )} + + )} + + ); +} + +type OpenedSpaceFolderProps = { + folder: ISidebarFolder; + onClose: MouseEventHandler; + children?: ReactNode; +}; +function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { + const aboveTargetRef = useRef(null); + const belowTargetRef = useRef(null); + + const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); + + const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); + const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); + + return ( + + + + + + + + {children} + + + ); +} + +type ClosedSpaceFolderProps = { + folder: ISidebarFolder; + selected: boolean; + onOpen: MouseEventHandler; + onDragging: (dragItem?: SidebarDraggable) => void; + disabled?: boolean; +}; +function ClosedSpaceFolder({ + folder, + selected, + onOpen, + onDragging, + disabled, +}: ClosedSpaceFolderProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const handlerRef = useRef(null); + + const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); + useDraggableItem(spaceDraggable, handlerRef, onDragging); + const dropState = useDropTarget(spaceDraggable, handlerRef); + const dropType = dropState?.type; + + const tooltipName = + folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; + + return ( + + {(unread) => ( + + + {(tooltipRef) => ( + + {folder.content.map((sId) => { + const space = mx.getRoom(sId); + if (!space) return null; + + return ( + + ( + + {nameInitials(space.name, 2)} + + )} + /> + + ); + })} + + )} + + {unread && ( + 0}> + 0} count={unread.total} /> + + )} + + )} + + ); +} + +type SpaceTabsProps = { + scrollRef: RefObject; +}; +export function SpaceTabs({ scrollRef }: SpaceTabsProps) { + const navigate = useNavigate(); + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); + const navToActivePath = useAtomValue(useNavToActivePathAtom()); + const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); + const [draggingItem, setDraggingItem] = useState(); + + useDnDMonitor( + scrollRef, + setDraggingItem, + useCallback( + (item, containerItem, instructionType) => { + const newItems: SidebarItems = []; + + const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => { + if (typeof sI === 'string' && typeof dI === 'string') { + return sI === dI; + } + if (typeof sI === 'object' && typeof dI === 'object') { + return sI.id === dI.folder.id; + } + return false; + }; + const itemAsFolderContent = (i: SidebarDraggable): string[] => { + if (typeof i === 'string') { + return [i]; + } + if (i.spaceId) { + return [i.spaceId]; + } + return [...i.folder.content]; + }; + + sidebarItems.forEach((i) => { + const sameFolders = + typeof item === 'object' && + typeof containerItem === 'object' && + item.folder.id === containerItem.folder.id; + + // remove draggable space from current position or folder + if (!sameFolders && matchDest(i, item)) { + if (typeof item === 'object' && item.spaceId) { + const folderContent = item.folder.content.filter((s) => s !== item.spaceId); + if (folderContent.length === 0) { + // remove open state from local storage + setOpenedFolder({ type: 'DELETE', id: item.folder.id }); + return; + } + newItems.push({ + ...item.folder, + content: folderContent, + }); + } + return; + } + if (matchDest(i, containerItem)) { + // we can make child only if + // container item is space or closed folder + if (instructionType === 'make-child') { + const child: string[] = itemAsFolderContent(item); + if (typeof containerItem === 'string') { + const folder: ISidebarFolder = { + id: randomStr(), + content: [containerItem].concat(child), + }; + newItems.push(folder); + return; + } + newItems.push({ + ...containerItem.folder, + content: containerItem.folder.content.concat(child), + }); + return; + } + + // drop inside opened folder + // or reordering inside same folder + if (typeof containerItem === 'object' && containerItem.spaceId) { + const child = itemAsFolderContent(item); + const newContent: string[] = []; + containerItem.folder.content + .filter((sId) => !child.includes(sId)) + .forEach((sId) => { + if (sId === containerItem.spaceId) { + if (instructionType === 'reorder-below') { + newContent.push(sId, ...child); + } + if (instructionType === 'reorder-above') { + newContent.push(...child, sId); + } + return; + } + newContent.push(sId); + }); + const folder = { + ...containerItem.folder, + content: newContent, + }; + + newItems.push(folder); + return; + } + + // drop above or below space or closed/opened folder + if (typeof item === 'string') { + if (instructionType === 'reorder-below') newItems.push(i); + newItems.push(item); + if (instructionType === 'reorder-above') newItems.push(i); + } else if (item.spaceId) { + if (instructionType === 'reorder-above') { + newItems.push(item.spaceId); + } + if (sameFolders && typeof i === 'object') { + // remove from folder if placing around itself + const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) }; + if (newI.content.length > 0) newItems.push(newI); + } else { + newItems.push(i); + } + if (instructionType === 'reorder-below') { + newItems.push(item.spaceId); + } + } else { + if (instructionType === 'reorder-below') newItems.push(i); + newItems.push(item.folder); + if (instructionType === 'reorder-above') newItems.push(i); + } + return; + } + newItems.push(i); + }); + + const newSpacesContent = makeCinnySpacesContent(mx, newItems); + localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); + mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); + }, + [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] + ) + ); + + const selectedSpaceId = useSelectedSpace(); + + const handleSpaceClick: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const targetSpaceId = target.getAttribute('data-id'); + if (!targetSpaceId) return; + + const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); + if (screenSize === ScreenSize.Mobile) { + navigate(spacePath); + return; + } + + const activePath = navToActivePath.get(targetSpaceId); + if (activePath && activePath.pathname.startsWith(spacePath)) { + navigate(joinPathComponent(activePath)); + return; + } + + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + }; + + const handleFolderToggle: MouseEventHandler = (evt) => { + const target = evt.currentTarget; + const targetFolderId = target.getAttribute('data-id'); + if (!targetFolderId) return; + + setOpenedFolder({ + type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT', + id: targetFolderId, + }); + }; + + const handleUnpin = useCallback( + (roomId: string) => { + if (orphanSpaces.includes(roomId)) return; + const newItems = sidebarItemWithout(sidebarItems, roomId); + + const newSpacesContent = makeCinnySpacesContent(mx, newItems); + localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); + mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); + }, + [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] + ); + + if (sidebarItems.length === 0) return null; + return ( + <> + + + {sidebarItems.map((item) => { + if (typeof item === 'object') { + if (openedFolder.has(item.id)) { + return ( + + {item.content.map((sId) => { + const space = mx.getRoom(sId); + if (!space) return null; + return ( + + ); + })} + + ); + } + + return ( + + ); + } + + const space = mx.getRoom(item); + if (!space) return null; + + return ( + + ); + })} + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 3cf570d9..d44cfaa2 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -1,3 +1,8 @@ +export * from './HomeTab'; +export * from './DirectTab'; +export * from './SpaceTabs'; export * from './InboxTab'; +export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; +export * from './SearchTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx new file mode 100644 index 00000000..2a7c6199 --- /dev/null +++ b/src/app/pages/client/space/Space.tsx @@ -0,0 +1,540 @@ +import React, { + MouseEventHandler, + forwardRef, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { + Avatar, + Box, + Button, + Icon, + IconButton, + Icons, + Line, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, + color, + config, + toRem, +} from 'folds'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { JoinRule, Room } from 'matrix-js-sdk'; +import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; +import FocusTrap from 'focus-trap-react'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { + NavCategory, + NavCategoryHeader, + NavItem, + NavItemContent, + NavLink, +} from '../../../components/nav'; +import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; +import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; +import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; +import { + useSpaceLobbySelected, + useSpaceSearchSelected, +} from '../../../hooks/router/useSelectedSpace'; +import { useSpace } from '../../../hooks/useSpace'; +import { VirtualTile } from '../../../components/virtualizer'; +import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; +import { useRoomName } from '../../../hooks/useRoomMeta'; +import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; +import { usePowerLevels } from '../../../hooks/usePowerLevels'; +import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { markAsRead } from '../../../utils/notifications'; +import { useRoomsUnread } from '../../../state/hooks/unread'; +import { UseStateProvider } from '../../../components/UseStateProvider'; +import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; +import { copyToClipboard } from '../../../utils/dom'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { Membership, StateEvent } from '../../../../types/matrix/room'; +import { stopPropagation } from '../../../utils/keyboard'; +import { getMatrixToRoom } from '../../../plugins/matrix-to'; +import { getViaServers } from '../../../plugins/via-servers'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { BreakWord } from '../../../styles/Text.css'; +import { InviteUserPrompt } from '../../../components/invite-user-prompt'; +import { useCallEmbed } from '../../../hooks/useCallEmbed'; + +type SpaceMenuProps = { + room: Room; + requestClose: () => void; +}; +const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [developerTools] = useSetting(settingsAtom, 'developerTools'); + const roomToParents = useAtomValue(roomToParentsAtom); + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + + const permissions = useRoomPermissions(creators, powerLevels); + const canInvite = permissions.action('invite', mx.getSafeUserId()); + const openSpaceSettings = useOpenSpaceSettings(); + const { navigateRoom } = useRoomNavigate(); + + const [invitePrompt, setInvitePrompt] = useState(false); + + const allChild = useSpaceChildren( + allRoomsAtom, + room.roomId, + useRecursiveChildScopeFactory(mx, roomToParents) + ); + const unread = useRoomsUnread(allChild, roomToUnreadAtom); + + const handleMarkAsRead = () => { + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); + requestClose(); + }; + + const handleCopyLink = () => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); + copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); + requestClose(); + }; + + const handleInvite = () => { + setInvitePrompt(true); + }; + + const handleRoomSettings = () => { + openSpaceSettings(room.roomId); + requestClose(); + }; + + const handleOpenTimeline = () => { + navigateRoom(room.roomId); + requestClose(); + }; + + return ( + + + {invitePrompt && room && ( + { + setInvitePrompt(false); + requestClose(); + }} + /> + )} + } + radii="300" + disabled={!unread} + > + + Mark as Read + + + + + + } + radii="300" + aria-pressed={invitePrompt} + disabled={!canInvite} + > + + Invite + + + } + radii="300" + > + + Copy Link + + + } + radii="300" + > + + Space Settings + + + {developerTools && ( + } + radii="300" + > + + Event Timeline + + + )} + + + + + {(promptLeave, setPromptLeave) => ( + <> + setPromptLeave(true)} + variant="Critical" + fill="None" + size="300" + after={} + radii="300" + aria-pressed={promptLeave} + > + + Leave Space + + + {promptLeave && ( + setPromptLeave(false)} + /> + )} + + )} + + + + ); +}); + +function SpaceHeader() { + const space = useSpace(); + const spaceName = useRoomName(space); + const [menuAnchor, setMenuAnchor] = useState(); + + const joinRules = useStateEvent( + space, + StateEvent.RoomJoinRules + )?.getContent(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setMenuAnchor((currentState) => { + if (currentState) return undefined; + return cords; + }); + }; + + return ( + <> + + + + + {spaceName} + + {joinRules?.join_rule !== JoinRule.Public && } + + + + + + + + + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} /> + + } + /> + )} + + ); +} + +type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; +export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { + const mx = useMatrixClient(); + const { navigateSpace } = useRoomNavigate(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? getViaServers(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) navigateSpace(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); + }; + + return ( + + + Space Upgraded + This space has been replaced and is no longer active. + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} + + )} + + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + + ); +} + +export function Space() { + const mx = useMatrixClient(); + const space = useSpace(); + useNavToActivePathMapper(space.roomId); + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); + const scrollRef = useRef(null); + const mDirects = useAtomValue(mDirectAtom); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const allRooms = useAtomValue(allRoomsAtom); + const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + + const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); + const selectedRoomId = useSelectedRoom(); + const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); + const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + const callEmbed = useCallEmbed(); + + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const getRoom = useCallback( + (rId: string): Room | undefined => { + if (allJoinedRooms.has(rId)) { + return mx.getRoom(rId) ?? undefined; + } + return undefined; + }, + [mx, allJoinedRooms] + ); + + const hierarchy = useSpaceJoinedHierarchy( + space.roomId, + getRoom, + useCallback( + (parentId, roomId) => { + if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { + return false; + } + const showRoomAnyway = + roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; + return !showRoomAnyway; + }, + [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] + ), + useCallback( + (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), + [closedCategories, space.roomId] + ) + ); + + const virtualizer = useVirtualizer({ + count: hierarchy.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 0, + overscan: 10, + }); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + const getToLink = (roomId: string) => + getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + + return ( + + + + + {tombstoneEvent && ( + + )} + + + + + + + + + + + Lobby + + + + + + + + + + + + + + + + Message Search + + + + + + + + + {virtualizer.getVirtualItems().map((vItem) => { + const { roomId } = hierarchy[vItem.index] ?? {}; + const room = mx.getRoom(roomId); + if (!room) return null; + + if (room.isSpaceRoom()) { + const categoryId = makeNavCategoryId(space.roomId, roomId); + + return ( + +
+ + + {roomId === space.roomId ? 'Rooms' : room?.name} + + +
+
+ ); + } + + return ( + + + + ); + })} +
+
+
+
+ ); +} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 477d60ff..0e32d93b 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,3 +1,4 @@ export * from './SpaceProvider'; +export * from './Space'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 6a894350..9fbad4ff 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -50,8 +50,8 @@ export interface Settings { } const defaultSettings: Settings = { - themeId: 'gamecult-theme', - useSystemTheme: false, + themeId: undefined, + useSystemTheme: true, lightThemeId: undefined, darkThemeId: undefined, monochromeMode: false, @@ -61,10 +61,10 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, - isPeopleDrawer: false, + isPeopleDrawer: true, memberSortFilterIndex: 0, enterForNewline: false, - messageLayout: 2, + messageLayout: 0, messageSpacing: '400', hideMembershipEvents: false, hideNickAvatarEvents: true, diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts deleted file mode 100644 index 1deaf37e..00000000 --- a/src/app/utils/mDirect.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix'; - -type Content = Record; - -/** A client whose account-data write takes a while and lands only when it completes. */ -const fakeClient = (initial?: Content) => { - let direct: Content | undefined = initial; - const writes: Content[] = []; - const mx = { - getAccountData: () => (direct ? { getContent: () => direct } : undefined), - setAccountData: async (_type: string, content: Content) => { - await new Promise((r) => { - setTimeout(r, 5); - }); - writes.push(structuredClone(content)); - direct = structuredClone(content); - }, - }; - return { mx: mx as never, writes, current: () => direct }; -}; - -describe('m.direct edits', () => { - it('moves a room between users and removes it, one write at a time', async () => { - const c = fakeClient({ '@a:test': ['!r:test'] }); - await Promise.all([ - addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), - removeRoomIdFromMDirect(c.mx, '!gone:test'), - ]); - expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] }); - await removeRoomIdFromMDirect(c.mx, '!r:test'); - expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); - }); - - it('serializes overlapping edits so each builds on the content the last one wrote', async () => { - const c = fakeClient(); - await Promise.all([ - addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'), - addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'), - addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'), - ]); - expect(c.current()).toEqual({ - '@friend:test': ['!dm2:test', '!dm3:test'], - '@other:test': ['!dm4:test'], - }); - expect(c.writes).toHaveLength(3); - }); - - it('keeps going after a failed write', async () => { - const c = fakeClient(); - const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise }; - const original = raw.setAccountData; - raw.setAccountData = async () => { - raw.setAccountData = original; - throw new Error('offline'); - }; - await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline'); - await addRoomIdToMDirect(c.mx, '!a:test', '@f:test'); - expect(c.current()).toEqual({ '@f:test': ['!a:test'] }); - }); -}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 2859dbc3..4c86c4e2 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -225,59 +225,57 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -type MDirectContent = Record; - -const mDirectWrites = new WeakMap>(); - -/** - * The only writer of m.direct. m.direct is one account-data event that every - * edit replaces whole, so edits run one at a time, each reading the content the - * previous one wrote. `edit` returns the new content, or undefined for no write. - */ -export const updateMDirect = ( - mx: MatrixClient, - edit: (current: MDirectContent) => MDirectContent | undefined -): Promise => { - const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => { - const event = mx.getAccountData(AccountDataEvent.Direct as any); - const next = edit(event ? structuredClone(event.getContent()) : {}); - if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any); - }); - mDirectWrites.set( - mx, - run.catch(() => undefined) - ); - return run; -}; - -/** A room can be a DM for one person only: it leaves every other user's list. */ -export const addRoomIdToMDirect = ( +export const addRoomIdToMDirect = async ( mx: MatrixClient, roomId: string, userId: string -): Promise => - updateMDirect(mx, (current) => { - const next = structuredClone(current); - Object.keys(next).forEach((targetUserId) => { - if (targetUserId !== userId) { - next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); +): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + // remove it from the lists of any others users + // (it can only be a DM room for one person) + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + + if (targetUserId !== userId) { + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); } - }); - const roomIds = next[userId] ?? []; - if (!roomIds.includes(roomId)) roomIds.push(roomId); - next[userId] = roomIds; - return next; + } }); -export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => - updateMDirect(mx, (current) => { - const next: MDirectContent = {}; - Object.keys(current).forEach((userId) => { - next[userId] = current[userId].filter((id) => id !== roomId); - }); - return next; + const roomIds = userIdToRoomIds[userId] || []; + if (roomIds.indexOf(roomId) === -1) { + roomIds.push(roomId); + } + userIdToRoomIds[userId] = roomIds; + + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); +}; + +export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } }); + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); +}; + export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, diff --git a/src/colors.css.ts b/src/colors.css.ts index 9c03c2cb..268cbf78 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -236,95 +236,3 @@ export const butterTheme = createTheme(color, { OnContainer: '#F2EED3', }, }); - -// GameCult identity (gamecult-site quartz.config.ts): single dark theme on #07111a, -// orange #ff8a2a accent, sky #59b7ff links. The brand defines no status colours, so -// Success/Warning/Critical are invented here and kept distinct from the orange accent. -export const gamecultTheme = createTheme(color, { - Background: { - Container: '#07111a', - ContainerHover: '#0d1a26', - ContainerActive: '#122130', - ContainerLine: '#16212c', - OnContainer: '#b7c7d9', - }, - Surface: { - Container: '#09141f', - ContainerHover: '#0f1c29', - ContainerActive: '#152433', - ContainerLine: '#16212c', - OnContainer: '#b7c7d9', - }, - SurfaceVariant: { - Container: '#0d1a26', - ContainerHover: '#122130', - ContainerActive: '#16273a', - ContainerLine: '#1b2a38', - OnContainer: '#b7c7d9', - }, - Primary: { - Main: '#ff8a2a', - MainHover: '#ff9640', - MainActive: '#ffa155', - MainLine: '#ffab66', - OnMain: '#2a1300', - Container: '#2b1a0e', - ContainerHover: '#35200f', - ContainerActive: '#402611', - ContainerLine: '#4d2d13', - OnContainer: '#ffd3ab', - }, - Secondary: { - Main: '#eef5ff', - MainHover: '#dbe7f6', - MainActive: '#cddced', - MainLine: '#bfd0e4', - OnMain: '#07111a', - Container: '#16212c', - ContainerHover: '#1c2a38', - ContainerActive: '#233342', - ContainerLine: '#2a3b4c', - OnContainer: '#eef5ff', - }, - Success: { - Main: '#5fd0a4', - MainHover: '#72d8b0', - MainActive: '#84deba', - MainLine: '#93e3c2', - OnMain: '#042014', - Container: '#0c2a21', - ContainerHover: '#10342a', - ContainerActive: '#143e32', - ContainerLine: '#19493a', - OnContainer: '#c4f1de', - }, - Warning: { - Main: '#f2d04b', - MainHover: '#f5d963', - MainActive: '#f7e078', - MainLine: '#f9e68b', - OnMain: '#2a2100', - Container: '#2a2410', - ContainerHover: '#352d13', - ContainerActive: '#403616', - ContainerLine: '#4d411a', - OnContainer: '#f8ebb0', - }, - Critical: { - Main: '#ff7a90', - MainHover: '#ff8fa1', - MainActive: '#ffa0af', - MainLine: '#ffb0bc', - OnMain: '#3a0710', - Container: '#2e1118', - ContainerHover: '#391520', - ContainerActive: '#441a27', - ContainerLine: '#51202e', - OnContainer: '#ffd0d8', - }, - Other: { - FocusRing: 'rgba(89, 183, 255, 0.55)', - Shadow: 'rgba(0, 0, 0, 0.6)', - Overlay: 'rgba(3, 7, 13, 0.8)', - }, -}); diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts deleted file mode 100644 index 4aaf3e76..00000000 --- a/src/gamecult.css.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { globalStyle } from '@vanilla-extract/css'; -import { config } from 'folds'; - -// Softer, rounder shape language and an airier line height. Scoped to the GameCult -// theme class so the stock themes keep folds' defaults. -const scope = 'body.gamecult-theme'; - -globalStyle(scope, { - vars: { - [config.radii.R300]: '10px', - [config.radii.R400]: '16px', - [config.radii.R500]: '22px', - [config.lineHeight.T300]: '1.6', - [config.lineHeight.T400]: '1.7', - [config.lineHeight.T500]: '1.7', - [config.lineHeight.B400]: '1.6', - [config.lineHeight.B500]: '1.6', - [config.lineHeight.D400]: '1.7', - }, -}); diff --git a/src/index.css b/src/index.css index f751b94d..d4696367 100644 --- a/src/index.css +++ b/src/index.css @@ -135,67 +135,3 @@ li p::before { content: ''; display: inline-block; } - -/* GameCult identity: Ubuntu 300 prose, Montserrat thin titles, IBM Plex Mono labels. */ -.gamecult-theme { - --tc-link: #59b7ff; - - --mx-uc-1: hsl(208, 100%, 72%); - --mx-uc-2: hsl(280, 100%, 78%); - --mx-uc-3: hsl(163, 70%, 65%); - --mx-uc-4: hsl(343, 100%, 76%); - --mx-uc-5: hsl(28, 100%, 66%); - --mx-uc-6: hsl(181, 70%, 62%); - --mx-uc-7: hsl(243, 100%, 80%); - --mx-uc-8: hsl(94, 70%, 70%); - - --font-secondary: 'Ubuntu', var(--font-emoji), sans-serif; - --font-title: 'Montserrat', 'Ubuntu', sans-serif; - --font-mono: 'IBM Plex Mono', monospace; - --gamecult-rule: rgba(148, 163, 184, 0.22); - --gamecult-wash: 0.5; - - background-color: #07111a; - background-image: radial-gradient( - circle at 78% 14%, - rgb(255 138 42 / calc(0.18 * var(--gamecult-wash))), - transparent 18% - ), - radial-gradient( - circle at 18% 10%, - rgb(109 96 255 / calc(0.16 * var(--gamecult-wash))), - transparent 24% - ), - radial-gradient( - circle at 52% 0%, - rgb(89 183 255 / calc(0.14 * var(--gamecult-wash))), - transparent 28% - ), - linear-gradient(180deg, #03070d 0%, #07111a 44%, #09141f 100%); - background-attachment: fixed; - color-scheme: dark; - font-weight: 300; -} - -.gamecult-theme b, -.gamecult-theme strong { - font-weight: 500; -} - -.gamecult-theme h1, -.gamecult-theme h2, -.gamecult-theme h3 { - font-family: var(--font-title); - font-weight: 300; -} - -.gamecult-theme code, -.gamecult-theme pre { - font-family: var(--font-mono); -} - -/* Quiet secondary metadata: timestamps, edited marks and the like sit back. */ -.gamecult-theme time { - opacity: 0.55; - font-size: 0.78em; -} diff --git a/src/index.tsx b/src/index.tsx index 672ac5c0..6cc0ca38 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,6 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; -import '@fontsource/ubuntu/300.css'; -import '@fontsource/ubuntu/400.css'; -import '@fontsource/ubuntu/500.css'; -import '@fontsource/ubuntu/700.css'; -import '@fontsource/montserrat/200.css'; -import '@fontsource/montserrat/300.css'; -import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds'; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 36c1e21b..98715996 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,6 +3,8 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', + CinnySpaces = 'in.cinny.spaces', + ElementRecentEmoji = 'io.element.recent_emoji', PoniesUserEmotes = 'im.ponies.user_emotes',