Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33d8de956 | ||
|
|
a4da78edc5 | ||
|
|
9b66b2b250 | ||
|
|
6950da865e | ||
|
|
8f1bb54b4b | ||
|
|
b20b958762 | ||
|
|
00ec686b1b | ||
|
|
873c0e30a2 | ||
|
|
7453a0bd32 | ||
|
|
2c04bbefa3 | ||
|
|
cdfd43fe9f | ||
|
|
f6244a4781 | ||
|
|
719f062ef1 | ||
|
|
44395328e9 | ||
|
|
e59cc93a2a | ||
|
|
2827699439 | ||
|
|
3d4fb161c3 | ||
|
|
c91d0339b5 | ||
|
|
bfb753e3e5 | ||
|
|
eeb9de8f40 | ||
|
|
98d74d564d | ||
|
|
d7afdc5208 | ||
|
|
2e179a7768 | ||
|
|
44774a3ba1 | ||
|
|
0091e5e90d | ||
|
|
a058d2a2c6 | ||
|
|
d07c7f2327 | ||
|
|
11f0a4d325 | ||
|
|
1cb18a2795 | ||
|
|
6eae929cd9 | ||
|
|
1dfe38a33d | ||
|
|
0b7ded9c99 | ||
|
|
41527f6ba3 | ||
|
|
e8efd3ce76 | ||
|
|
067cb10a90 | ||
|
|
c848626caa | ||
|
|
4d0ee78974 | ||
|
|
f810a83a28 | ||
|
|
e90b70e966 | ||
|
|
f2e3223c5f | ||
|
|
2924a3f4f3 | ||
|
|
841784ea64 | ||
|
|
dd88424e5d | ||
|
|
b8e696c81b | ||
|
|
a588ab82b3 | ||
|
|
1e6412ed5b |
||
|
|
14e490c896 |
||
|
|
eff1f48e3f |
||
|
|
8788f0cf49 |
68 changed files with 3336 additions and 3063 deletions
2
.github/workflows/build-pull-request.yml
vendored
2
.github/workflows/build-pull-request.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
|
|
|
|||
2
.github/workflows/docker-pr.yml
vendored
2
.github/workflows/docker-pr.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
|
|
|
|||
2
.github/workflows/netlify-dev.yml
vendored
2
.github/workflows/netlify-dev.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
|
|
|
|||
4
.github/workflows/prod-deploy.yml
vendored
4
.github/workflows/prod-deploy.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ dist
|
|||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
LICENSE
|
||||
LICENSE
|
||||
harness
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export default {
|
||||
base: '/',
|
||||
base: process.env.CINNY_BASE ?? '/',
|
||||
};
|
||||
|
|
|
|||
12
config.json
12
config.json
|
|
@ -5,17 +5,7 @@
|
|||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#librewolf-community:matrix.org",
|
||||
"#stickers-and-emojis:tastytea.de",
|
||||
"#videogames:waywardinn.com",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"spaces": ["#gamecult:gamecult.org"],
|
||||
"rooms": [
|
||||
"#tuwunel:grin.hu",
|
||||
"#freesoftware:matrix.org",
|
||||
|
|
|
|||
352
docs/gamecult/ratatoskr-target.md
Normal file
352
docs/gamecult/ratatoskr-target.md
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# Ratatoskr: target and prose map
|
||||
|
||||
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
||||
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
|
||||
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
|
||||
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
|
||||
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
||||
exists (`hand_off` is parked); after that every new ruling on it is admitted in
|
||||
Void's mind. Nothing here is written into Void's mind before then.
|
||||
|
||||
The mind holds the questions, rulings, specs, reports and verdicts. This file
|
||||
holds what no document kind carries: the design truths, body facts, the model
|
||||
page, rationale, the reconstructed collaboration plan, and the cut sequence.
|
||||
All times are UTC.
|
||||
|
||||
## Why
|
||||
|
||||
GameCult is the only server this client talks to. A general Matrix client
|
||||
treats it as one community among thousands, so the operator meets GameCult's
|
||||
structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm`
|
||||
10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The
|
||||
client should open on GameCult's estate as data: its spaces, its project rooms
|
||||
grouped by steward, its Minds, and the person's own place in it. It should
|
||||
also be a pleasant place to be in its own right: emotes, sound, and weather in
|
||||
the background.
|
||||
|
||||
## Design truths (from rulings in force)
|
||||
|
||||
- **We own the interface, not the engine.** The fork keeps Cinny's engine
|
||||
(matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every
|
||||
user-facing string and its translation is ours, and upstream locale files
|
||||
are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`).
|
||||
- **Mobile first.** On phones, inactive elements hide, vertical space is
|
||||
cheaper than horizontal, and text never gets squeezed into narrow columns
|
||||
(`eureka-body:ruling:chat-client-base-cinny-mobile-first`).
|
||||
- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote
|
||||
slot on every bubble (a pose id carried on the event); room and participant
|
||||
lists tiered (category, then items) and reached from the header menu, not
|
||||
side columns; participants grouped by estate (Minds, Members, Users,
|
||||
Patrons); rooms grouped by kind (general, estates, projects, work rooms);
|
||||
Minds indexed by stewardship, read from Matrix state projected from the
|
||||
stewardship records in the Minds' own minds (the minds stay the truth); one
|
||||
room per project, a Space child, with the steward joined.
|
||||
- **Rooms are visible without a join dance.** No invite screen in the way; a
|
||||
room the server lets you join shows in the same list as joined ones; opening
|
||||
one shows its name and a one-tap Join; joining happens on that tap or a first
|
||||
post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12
|
||||
"Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost").
|
||||
Rooms you may not enter are refused by the server, not hidden by the client
|
||||
(Heimdall 04:10, accepted by Void 04:11 and 04:15).
|
||||
- **Language.** A user's language is the BCP 47 tag in profile field
|
||||
`org.gamecult.language`, owned by the user through the client's language
|
||||
picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships
|
||||
localized; people talk with stewards in their own language; rooms are made
|
||||
for a language, with no per-language mirrors
|
||||
(`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One
|
||||
host Mind sits in a language room, Weksa for `#pt-br`
|
||||
(`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this
|
||||
must work for is a non-English, non-technical patron (placeholder
|
||||
`patron-pt1`) whose account we set up and walk through login
|
||||
(`gamecult-chat:ruling:patron-in-his-own-language`).
|
||||
- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for
|
||||
now; Web Push to the installed web app is a different thing and is open
|
||||
(question `ratatoskr:question:pwa-web-push`).
|
||||
- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in
|
||||
Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not
|
||||
deployed, so the plan below is its interim home; Atlas imports it later.
|
||||
|
||||
## Body facts
|
||||
|
||||
Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
|
||||
|
||||
1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main
|
||||
`1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile):
|
||||
a GameCult theme as default, member drawer hidden by default, bubble
|
||||
layout, and steward grouping in `src/app/pages/client/space/Space.tsx`
|
||||
(`useStewardGroups` at 429-437, group headers at 505-524) over
|
||||
`useSpaceJoinedHierarchy` (409). Read by `git diff`.
|
||||
2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the
|
||||
client API, counts only). Root Space `#gamecult:gamecult.org`, room version
|
||||
12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52
|
||||
children; 50 readable rooms, all room version 12. Server `/hierarchy` from
|
||||
the root returns 59 rooms, 2 of them spaces. **Every room's join rule is
|
||||
`invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward`
|
||||
with content `{mind, repo, source, steward: <mxid>}`. 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
|
||||
`#<repo lowercased>` (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 | `<instance>:stewardship:<repo>.n<seq>` 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/<mxid-localpart>/<motif>.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.
|
||||
46
harness/estate/check.cjs
Normal file
46
harness/estate/check.cjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Assertions for the estate drives: every drive states the outcome it exists to show and exits
|
||||
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||
const fs = require('fs');
|
||||
const fails = [];
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed. */
|
||||
exports.done = () => (fails.length ? 4 : 0);
|
||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||
exports.mDirectPuts = () =>
|
||||
exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
|
||||
/** Join requests the server saw for a room id. */
|
||||
exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
|
||||
const tierOf = (pg, name) =>
|
||||
pg.evaluate((n) => {
|
||||
const s = document.body.innerText.split('\n').map((x) => x.trim());
|
||||
const i = s.indexOf(n);
|
||||
if (i < 0) return 'absent';
|
||||
for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
|
||||
return 'none-above';
|
||||
}, name);
|
||||
/**
|
||||
* The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
|
||||
* opened only if the row is not already showing, so reading never closes what is open.
|
||||
*/
|
||||
exports.revealedTier = async (pg, name) => {
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/');
|
||||
await pg.waitForTimeout(2500);
|
||||
let t = await tierOf(pg, name);
|
||||
for (const h of HEADERS) {
|
||||
if (t !== 'absent') break;
|
||||
const hl = pg.getByText(h, { exact: true });
|
||||
if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
|
||||
}
|
||||
return t;
|
||||
};
|
||||
/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
|
||||
exports.sawText = (pg, re, ms) =>
|
||||
pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
|
||||
exports.isDm = (tier) => /^direct messages$/i.test(tier);
|
||||
72
harness/estate/drive-failures.cjs
Normal file
72
harness/estate/drive-failures.cjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
|
||||
// against fakehs.cjs with the env flag each mode needs.
|
||||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
|
||||
const MODE = process.env.MODE;
|
||||
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b;
|
||||
// Close the browser, but never wait on it: a hung close must not hang the run.
|
||||
const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
|
||||
const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await open();
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
|
||||
const tierOf = (name) => revealedTier(pg, name);
|
||||
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
|
||||
const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
|
||||
const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
|
||||
const acceptInInbox = async (name) => {
|
||||
// reach the inbox through the navigator's own Inbox tab
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
|
||||
const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
|
||||
if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
|
||||
const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
|
||||
log('URL', pg.url());
|
||||
if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
|
||||
{ const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
|
||||
for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
|
||||
const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
|
||||
const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
|
||||
await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
|
||||
};
|
||||
if (MODE === 'pjfail') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(3000);
|
||||
const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
|
||||
expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
|
||||
expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
|
||||
expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
|
||||
expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'jdrop') {
|
||||
await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
|
||||
expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
|
||||
await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
|
||||
expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
|
||||
expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
|
||||
} else if (MODE === 'admin') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
|
||||
expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'inboxadmin') {
|
||||
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
|
||||
expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'inboxreload') {
|
||||
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
|
||||
await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
|
||||
expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
|
||||
} else if (MODE === 'pjreload') {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
|
||||
await pg.waitForTimeout(8000); await tiers('pjreload');
|
||||
const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
|
||||
expect('a join cut off by a reload stays joinable', n === 1, String(n));
|
||||
}
|
||||
await quit(done());
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
42
harness/estate/drive-routes.cjs
Normal file
42
harness/estate/drive-routes.cjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
|
||||
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
const pg = await ctx.newPage();
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
|
||||
let joined = false;
|
||||
if (MODE === 'deeplink') {
|
||||
for (const route of ['direct', 'home']) {
|
||||
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
|
||||
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
|
||||
if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
|
||||
}
|
||||
const f = await tiers('after deeplink join');
|
||||
const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
|
||||
const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
|
||||
expect('the deep link offers one Join button', joined, 'no Join button');
|
||||
expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
|
||||
expect('the DM is filed under Direct Messages', isDm(f), f);
|
||||
} else if (MODE === 'gw504') {
|
||||
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
|
||||
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
|
||||
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
|
||||
const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
|
||||
expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
|
||||
await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
|
||||
expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
|
||||
expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
|
||||
}
|
||||
await quit(done());
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
48
harness/estate/drive.cjs
Normal file
48
harness/estate/drive.cjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, revealedTier } = require('./check.cjs');
|
||||
const MODE = process.env.MODE; // twodm | reload
|
||||
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let tierBefore;
|
||||
(async () => {
|
||||
const b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
|
||||
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await open();
|
||||
const text = () => pg.evaluate(() => document.body.innerText);
|
||||
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
|
||||
const tierOf = (name) => revealedTier(pg, name);
|
||||
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
|
||||
if (MODE === 'twodm') {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
|
||||
await pg.waitForTimeout(45000);
|
||||
} else if (MODE === 'convert') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000);
|
||||
tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
|
||||
await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
|
||||
const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
|
||||
await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
|
||||
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
|
||||
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
|
||||
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
|
||||
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
|
||||
await pg.waitForTimeout(8000);
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
|
||||
} else {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(3000);
|
||||
log('RELOAD', t()); await open();
|
||||
await pg.waitForTimeout(30000);
|
||||
}
|
||||
const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
|
||||
log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
|
||||
if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
|
||||
else if (MODE === 'convert') {
|
||||
expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
|
||||
expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
|
||||
} else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
|
||||
await b.close(); process.exitCode = done();
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });
|
||||
127
harness/estate/fakehs.cjs
Normal file
127
harness/estate/fakehs.cjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
|
||||
// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
|
||||
// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
|
||||
// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
|
||||
const http = require('http'), fs = require('fs'), path = require('path');
|
||||
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
|
||||
const NOROOT = process.env.NOROOT === '1';
|
||||
const log = [];
|
||||
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
|
||||
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
|
||||
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
|
||||
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
|
||||
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
|
||||
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
|
||||
PROJ.forEach((r) => (NAMES[r.id] = r.name));
|
||||
const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0;
|
||||
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
|
||||
const strip = (id, name, space, direct) => [
|
||||
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
|
||||
{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
|
||||
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
|
||||
...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
|
||||
{ type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
|
||||
];
|
||||
const joinedRoom = (id, name, extra = [], space = false) => ({
|
||||
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
|
||||
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
|
||||
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
|
||||
});
|
||||
const invites = () => {
|
||||
const o = {};
|
||||
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
|
||||
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
|
||||
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
|
||||
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
|
||||
o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
|
||||
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
|
||||
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
|
||||
return o;
|
||||
};
|
||||
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
|
||||
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
|
||||
const hierarchy = () => [
|
||||
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }),
|
||||
hroom(GEN, 'General', '#general:test'),
|
||||
hroom(OP, 'Operator', '#operator:test'),
|
||||
hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
|
||||
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
|
||||
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
|
||||
];
|
||||
let syncN = 1;
|
||||
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
|
||||
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
||||
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
|
||||
if (req.method === 'OPTIONS') return json(res, 200, {});
|
||||
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
|
||||
let f = path.join(DIST, p === '/' ? 'index.html' : p);
|
||||
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
|
||||
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
|
||||
return fs.createReadStream(f).pipe(res);
|
||||
}
|
||||
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
|
||||
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
|
||||
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
|
||||
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
|
||||
if (p.endsWith('/sync')) {
|
||||
const since = u.searchParams.get('since');
|
||||
if (!since) {
|
||||
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
|
||||
return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
|
||||
}
|
||||
const respond = () => {
|
||||
const now = Date.now(); const join = {};
|
||||
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
|
||||
const id = pending[i].id; pending.splice(i, 1);
|
||||
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
|
||||
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
|
||||
}
|
||||
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
|
||||
const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
|
||||
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
|
||||
return json(res, 200, { next_batch: since, rooms: {} });
|
||||
};
|
||||
return setTimeout(respond, 300);
|
||||
}
|
||||
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
|
||||
if (req.method === 'POST' && jm) {
|
||||
const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`);
|
||||
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
|
||||
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
|
||||
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
|
||||
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
|
||||
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);
|
||||
35
harness/estate/run.sh
Normal file
35
harness/estate/run.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
|
||||
# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
|
||||
set -e
|
||||
cd /src
|
||||
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
|
||||
npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
|
||||
echo BUILD_OK $(git rev-parse HEAD)
|
||||
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
|
||||
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
|
||||
cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
|
||||
set +e
|
||||
for m in twodm reload convert; do
|
||||
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
|
||||
done
|
||||
failure() { m=$1; shift
|
||||
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
|
||||
failure pjfail FAIL_MDIRECT=1
|
||||
failure pjreload DELAY_MDIRECT=1
|
||||
failure jdrop JOIN_DROP=1
|
||||
failure admin INVITER_ADMIN=1
|
||||
failure inboxadmin INVITER_ADMIN=1
|
||||
failure inboxreload JOIN_DELAY=1
|
||||
DRIVER=drive-routes failure deeplink
|
||||
DRIVER=drive-routes failure gw504 JOIN_504=1
|
||||
echo "FAILED_DRIVES:${FAILED:- none}"
|
||||
[ -z "$FAILED" ]
|
||||
779
package-lock.json
generated
779
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "4.12.7",
|
||||
"version": "4.12.8",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"bump": "node scripts/update-version.js"
|
||||
"bump": "node scripts/update-version.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
|
|
@ -61,7 +62,7 @@
|
|||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-http-backend": "2.5.2",
|
||||
"i18next-http-backend": "4.0.3",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
|
|
@ -82,12 +83,15 @@
|
|||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.30.6",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sanitize-html": "2.18.1",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35"
|
||||
"ua-parser-js": "1.0.35",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
|
|
@ -123,6 +127,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"
|
||||
"vite-plugin-top-level-await": "1.4.4",
|
||||
"vitest": "1.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
|
|
@ -31,6 +31,7 @@ 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';
|
||||
|
||||
|
|
@ -197,8 +198,10 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
)
|
||||
);
|
||||
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
|
||||
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
|
||||
}, [mx, roomIdOrAlias, viaServers])
|
||||
);
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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;
|
||||
|
|
@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
const prevRoomId = createContent?.predecessor?.room_id;
|
||||
|
||||
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
|
||||
);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
|
|
|
|||
14
src/app/features/estate/EstateNavigator.css.ts
Normal file
14
src/app/features/estate/EstateNavigator.css.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
326
src/app/features/estate/EstateNavigator.tsx
Normal file
326
src/app/features/estate/EstateNavigator.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
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<typeof useMatrixClient>, 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 (
|
||||
<NavItem variant="Background" radii="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
|
||||
<NavItemContent>
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</NavItemContent>
|
||||
{state.status === AsyncStatus.Error && (
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{(state.error as Error).message}
|
||||
</Text>
|
||||
)}
|
||||
<Chip
|
||||
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
before={busy ? <Spinner size="100" /> : <Icon src={Icons.Plus} size="50" />}
|
||||
onClick={join}
|
||||
disabled={busy}
|
||||
aria-label={`Join ${room.name}`}
|
||||
>
|
||||
<Text size="B300">Join</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
||||
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 <JoinRow room={room} />;
|
||||
return (
|
||||
<RoomNavItem
|
||||
room={mxRoom}
|
||||
selected={selectedRoomId === room.roomId}
|
||||
direct={room.direct}
|
||||
linkPath={estateRoomPath(mx, room)}
|
||||
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLButtonElement> = () =>
|
||||
setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key });
|
||||
|
||||
return (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
||||
{label}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{!closed && children}
|
||||
</NavCategory>
|
||||
);
|
||||
}
|
||||
|
||||
function Tier({ tier }: { tier: EstateTier }) {
|
||||
const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]);
|
||||
return (
|
||||
<Collapsible categoryId={tier.id} label={tier.label} rooms={allRooms}>
|
||||
{tier.loose.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
{tier.groups.map((g) => (
|
||||
<Collapsible
|
||||
key={g.mind}
|
||||
categoryId={`${tier.id}|${g.mind}`}
|
||||
label={g.label}
|
||||
rooms={g.rooms}
|
||||
>
|
||||
{g.rooms.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
</Collapsible>
|
||||
))}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryPoints() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</Avatar>
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Create Room
|
||||
</Text>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
<UseStateProvider initial={false}>
|
||||
{(open, setOpen) => (
|
||||
<>
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavButton onClick={() => setOpen(true)}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Link} size="100" />
|
||||
</Avatar>
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Join with Address
|
||||
</Text>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
{open && (
|
||||
<JoinAddressPrompt
|
||||
onCancel={() => setOpen(false)}
|
||||
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
||||
setOpen(false);
|
||||
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
||||
navigate(
|
||||
viaServers
|
||||
? withSearchParam<_RoomSearchParams>(path, {
|
||||
viaServers: encodeSearchParamValueArray(viaServers),
|
||||
})
|
||||
: path
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</NavCategory>
|
||||
);
|
||||
}
|
||||
|
||||
/** A short message that wraps inside the navigator; never a raw request error. */
|
||||
function EstateError({ message }: { message: string }) {
|
||||
return (
|
||||
<Text size="T300" style={{ color: color.Critical.Main, padding: 16, overflowWrap: 'anywhere' }}>
|
||||
{message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function EstateTiers({ rootId }: { rootId: string }) {
|
||||
const { fetching, error, tiers } = useEstate(rootId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tiers.map((tier) => (
|
||||
<Tier key={tier.id} tier={tier} />
|
||||
))}
|
||||
{fetching && (
|
||||
<Box justifyContent="Center" style={{ padding: 16 }}>
|
||||
<Spinner />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<EstateError message="Could not load the room list. Check your connection and reload." />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement>(null);
|
||||
const mx = useMatrixClient();
|
||||
const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult';
|
||||
|
||||
return (
|
||||
<PageNav>
|
||||
<PageNavHeader>
|
||||
<Box alignItems="Center" grow="Yes" gap="300" className={css.Targets}>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant="Background"
|
||||
aria-label="Find a room"
|
||||
onClick={() => openRoomFinder(true)}
|
||||
>
|
||||
<Icon src={Icons.Search} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box direction="Column" gap="300" className={css.Targets}>
|
||||
<EntryPoints />
|
||||
{rootId && <EstateTiers rootId={rootId} />}
|
||||
{!rootId && error && (
|
||||
<EstateError message="Could not find the GameCult space. Check your connection and reload." />
|
||||
)}
|
||||
{!rootId && !error && (
|
||||
<Box justifyContent="Center" style={{ padding: 16 }}>
|
||||
<Spinner />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="200"
|
||||
style={{ padding: 8 }}
|
||||
className={css.Targets}
|
||||
>
|
||||
<UnverifiedTab />
|
||||
<InboxTab />
|
||||
<SettingsTab />
|
||||
</Box>
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
326
src/app/features/estate/buildEstate.test.ts
Normal file
326
src/app/features/estate/buildEstate.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
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<string, string> = {}
|
||||
): 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> = {}): EstateInput => ({
|
||||
rootId: ROOT,
|
||||
hierarchy,
|
||||
membership: new Map(),
|
||||
directRoomIds: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const find = (tiers: ReturnType<typeof buildEstate>, 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<string, 'join' | 'invite'>([
|
||||
[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<EstateInput>) =>
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
186
src/app/features/estate/buildEstate.ts
Normal file
186
src/app/features/estate/buildEstate.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* 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<string, 'join' | 'invite'>;
|
||||
/** 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<string, string>;
|
||||
/** Display names for Minds, keyed by localpart. */
|
||||
mindLabels?: ReadonlyMap<string, string>;
|
||||
};
|
||||
|
||||
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<string, 'join' | 'invite'>
|
||||
): 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<string, EstateGroup>();
|
||||
|
||||
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] : []),
|
||||
];
|
||||
};
|
||||
9
src/app/features/estate/config.test.ts
Normal file
9
src/app/features/estate/config.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
1
src/app/features/estate/index.ts
Normal file
1
src/app/features/estate/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './EstateNavigator';
|
||||
352
src/app/features/estate/joinEstateRoom.test.ts
Normal file
352
src/app/features/estate/joinEstateRoom.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
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<string, unknown>, {
|
||||
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<string, string>;
|
||||
}) => {
|
||||
let direct: Record<string, string[]> | undefined;
|
||||
const writes: Record<string, string[]>[] = [];
|
||||
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<string, string[]>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
151
src/app/features/estate/joinEstateRoom.ts
Normal file
151
src/app/features/estate/joinEstateRoom.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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<typeof addRoomIdToMDirect>[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<EstateRoom, 'roomId' | 'state'> & { via?: string[] }
|
||||
): Promise<EstateActivation> => {
|
||||
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<EstateActivation> =>
|
||||
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<string> => {
|
||||
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<void> => {
|
||||
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<boolean> =>
|
||||
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);
|
||||
});
|
||||
122
src/app/features/estate/useEstate.ts
Normal file
122
src/app/features/estate/useEstate.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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<typeof useMatrixClient>,
|
||||
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<string, 'join' | 'invite'>();
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
[...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 };
|
||||
};
|
||||
|
|
@ -200,16 +200,12 @@ 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<RectCords>();
|
||||
|
|
@ -264,20 +260,6 @@ export function HierarchyItemMenu({
|
|||
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
||||
{joined && (
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{onTogglePin && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
onTogglePin(item.roomId);
|
||||
handleRequestClose();
|
||||
}}
|
||||
>
|
||||
<Text as="span" size="T300" truncate>
|
||||
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
<InviteMenuItem
|
||||
item={item}
|
||||
requestClose={handleRequestClose}
|
||||
|
|
|
|||
|
|
@ -42,14 +42,6 @@ import { CanDropCallback, useDnDMonitor } from './DnD';
|
|||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||
import { getStateEvent } from '../../utils/room';
|
||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
||||
import {
|
||||
makeCinnySpacesContent,
|
||||
sidebarItemWithout,
|
||||
useSidebarItems,
|
||||
} from '../../hooks/useSidebarItems';
|
||||
import { useOrphanSpaces } from '../../state/hooks/roomList';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { SpaceHierarchy } from './SpaceHierarchy';
|
||||
import { useGetRoom } from '../../hooks/useGetRoom';
|
||||
|
|
@ -167,18 +159,6 @@ export function Lobby() {
|
|||
const screenSize = useScreenSizeContext();
|
||||
const [onTop, setOnTop] = useState(true);
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
||||
const [sidebarItems] = useSidebarItems(
|
||||
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
|
||||
);
|
||||
const sidebarSpaces = useMemo(() => {
|
||||
const sideSpaces = sidebarItems.flatMap((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
return item.content;
|
||||
});
|
||||
|
||||
return new Set(sideSpaces);
|
||||
}, [sidebarItems]);
|
||||
|
||||
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
|
||||
|
||||
useElementSizeObserver(
|
||||
|
|
@ -415,18 +395,6 @@ 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 (
|
||||
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||
<Box grow="Yes">
|
||||
|
|
@ -499,8 +467,6 @@ export function Lobby() {
|
|||
disabledReorder={reordering}
|
||||
nextSpaceId={nextSpaceId}
|
||||
getRoom={getRoom}
|
||||
pinned={sidebarSpaces.has(item.space.roomId)}
|
||||
togglePinToSidebar={togglePinToSidebar}
|
||||
onSpacesFound={handleSpacesFound}
|
||||
onOpenRoom={handleOpenRoom}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { JoinRule, 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,6 +38,7 @@ 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;
|
||||
|
|
@ -46,8 +47,8 @@ type RoomJoinButtonProps = {
|
|||
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
||||
const [joinState, join] = useAsyncCallback<string, Error, []>(
|
||||
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||
);
|
||||
|
||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||
|
|
@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
|||
<Tooltip variant="Critical" style={{ maxWidth: toRem(200) }}>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text style={{ wordBreak: 'break-word' }} size="T400">
|
||||
{joinState.error.data?.error || joinState.error.message}
|
||||
{joinState.error.message}
|
||||
</Text>
|
||||
<Text size="T200">{joinState.error.name}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
|||
size="400"
|
||||
filled
|
||||
tabIndex={0}
|
||||
aria-label={joinState.error.data?.error || joinState.error.message}
|
||||
aria-label={joinState.error.message}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ type SpaceHierarchyProps = {
|
|||
disabledReorder?: boolean;
|
||||
nextSpaceId?: string;
|
||||
getRoom: (roomId: string) => Room | undefined;
|
||||
pinned: boolean;
|
||||
togglePinToSidebar: (roomId: string) => void;
|
||||
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
|
||||
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
|
@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||
disabledReorder,
|
||||
nextSpaceId,
|
||||
getRoom,
|
||||
pinned,
|
||||
togglePinToSidebar,
|
||||
onOpenRoom,
|
||||
onSpacesFound,
|
||||
},
|
||||
|
|
@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||
canEditChild={
|
||||
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
||||
}
|
||||
pinned={pinned}
|
||||
onTogglePin={togglePinToSidebar}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { 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,6 +38,7 @@ 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 (
|
||||
|
|
@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({
|
|||
}: UnjoinedSpaceProfileProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||
useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via])
|
||||
const [joinState, join] = useAsyncCallback<string, Error, []>(
|
||||
useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via])
|
||||
);
|
||||
|
||||
const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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) {
|
||||
|
|
@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
|
|||
useCallback(() => {
|
||||
const currentRoom = mx.getRoom(roomId);
|
||||
const via = currentRoom ? getViaServers(currentRoom) : [];
|
||||
return mx.joinRoom(replacementRoomId, {
|
||||
viaServers: via,
|
||||
});
|
||||
return joinEstateRoomOrThrow(mx, replacementRoomId, via);
|
||||
}, [mx, roomId, replacementRoomId])
|
||||
);
|
||||
const replacementRoom = mx.getRoom(replacementRoomId);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (replacementRoom) navigateRoom(replacementRoom.roomId);
|
||||
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId);
|
||||
if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function About({ requestClose }: AboutProps) {
|
|||
<Box direction="Column" gap="100">
|
||||
<Box gap="100" alignItems="End">
|
||||
<Text size="H3">Cinny</Text>
|
||||
<Text size="T200">v4.12.7</Text>
|
||||
<Text size="T200">v4.12.8</Text>
|
||||
</Box>
|
||||
<Text>Yet another matrix client.</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
addRoomIdToMDirect,
|
||||
getDMRoomFor,
|
||||
guessDmRoomUserId,
|
||||
isRoomAlias,
|
||||
isRoomId,
|
||||
isServerName,
|
||||
isUserId,
|
||||
|
|
@ -26,6 +25,7 @@ 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,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
[Command.Join]: {
|
||||
name: Command.Join,
|
||||
description: 'Join room with address. Example: /join address1 address2',
|
||||
exe: async (payload) => {
|
||||
const rawIds = splitWithSpace(payload);
|
||||
const roomIdOrAliases = rawIds.filter(
|
||||
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
|
||||
);
|
||||
roomIdOrAliases.forEach(async (idOrAlias) => {
|
||||
await mx.joinRoom(idOrAlias);
|
||||
});
|
||||
},
|
||||
exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)),
|
||||
},
|
||||
[Command.Leave]: {
|
||||
name: Command.Leave,
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
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<TSidebarItem>;
|
||||
|
||||
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<SetStateAction<SidebarItems>>] => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const [sidebarItems, setSidebarItems] = useState(() => {
|
||||
const inCinnySpacesContent = getAccountData(
|
||||
mx,
|
||||
AccountDataEvent.CinnySpaces
|
||||
)?.getContent<InCinnySpacesContent>();
|
||||
return parseSidebar(mx, orphanSpaces, inCinnySpacesContent);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const inCinnySpacesContent = getAccountData(
|
||||
mx,
|
||||
AccountDataEvent.CinnySpaces
|
||||
)?.getContent<InCinnySpacesContent>();
|
||||
setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent));
|
||||
}, [mx, orphanSpaces]);
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(mEvent) => {
|
||||
if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
|
||||
const newContent = mEvent.getContent<InCinnySpacesContent>();
|
||||
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<InCinnySpacesContent>() ?? {};
|
||||
|
||||
const newSpacesContent: InCinnySpacesContent = {
|
||||
...currentInSpaces,
|
||||
sidebar: items,
|
||||
};
|
||||
|
||||
return newSpacesContent;
|
||||
};
|
||||
|
|
@ -276,7 +276,8 @@ export type FetchSpaceHierarchyLevelData = {
|
|||
};
|
||||
export const useFetchSpaceHierarchyLevel = (
|
||||
roomId: string,
|
||||
enable: boolean
|
||||
enable: boolean,
|
||||
maxDepth = 1
|
||||
): FetchSpaceHierarchyLevelData => {
|
||||
const mx = useMatrixClient();
|
||||
const pageNoRef = useRef(0);
|
||||
|
|
@ -286,13 +287,13 @@ export const useFetchSpaceHierarchyLevel = (
|
|||
string[],
|
||||
string | undefined
|
||||
> = useCallback(
|
||||
({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam),
|
||||
[roomId, mx]
|
||||
({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam),
|
||||
[roomId, maxDepth, mx]
|
||||
);
|
||||
|
||||
const queryResponse = useInfiniteQuery({
|
||||
refetchOnMount: enable,
|
||||
queryKey: [roomId, 'hierarchy_level'],
|
||||
queryKey: [roomId, 'hierarchy_level', maxDepth],
|
||||
initialPageParam: undefined,
|
||||
queryFn: fetchLevel,
|
||||
getNextPageParam: (result) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
|
||||
import { butterTheme, darkTheme, gamecultTheme, silverTheme } from '../../colors.css';
|
||||
import '../../gamecult.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
||||
|
|
@ -38,8 +39,14 @@ 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(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
||||
const themes: Theme[] = useMemo(() => [GameCultTheme, LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
||||
|
||||
return themes;
|
||||
};
|
||||
|
|
@ -47,6 +54,7 @@ export const useThemes = (): Theme[] => {
|
|||
export const useThemeNames = (): Record<string, string> =>
|
||||
useMemo(
|
||||
() => ({
|
||||
[GameCultTheme.id]: 'GameCult',
|
||||
[LightTheme.id]: 'Light',
|
||||
[SilverTheme.id]: 'Silver',
|
||||
[DarkTheme.id]: 'Dark',
|
||||
|
|
@ -84,15 +92,15 @@ export const useActiveTheme = (): Theme => {
|
|||
const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
|
||||
if (!systemTheme) {
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? GameCultTheme;
|
||||
|
||||
return selectedTheme;
|
||||
}
|
||||
|
||||
const selectedTheme =
|
||||
systemThemeKind === ThemeKind.Dark
|
||||
? themes.find((theme) => theme.id === darkThemeId) ?? DarkTheme
|
||||
: themes.find((theme) => theme.id === lightThemeId) ?? LightTheme;
|
||||
? themes.find((theme) => theme.id === darkThemeId) ?? GameCultTheme
|
||||
: themes.find((theme) => theme.id === lightThemeId) ?? GameCultTheme;
|
||||
|
||||
return selectedTheme;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,6 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -40,19 +40,19 @@ import {
|
|||
getSpaceLobbyPath,
|
||||
} from './pathUtils';
|
||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||
import { HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||
import { DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||
import { RouteSpaceProvider, 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 { SidebarNav } from './client/SidebarNav';
|
||||
import { EstatePageNav } from './client/EstatePageNav';
|
||||
import { PageRoot } from '../components/page';
|
||||
import { ScreenSize } from '../hooks/useScreenSize';
|
||||
import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
|
||||
import { MobileFriendlyPageNav } from './MobileFriendly';
|
||||
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
|
||||
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
|
||||
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
|
||||
|
|
@ -127,13 +127,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<ClientBindAtoms>
|
||||
<ClientNonUIFeatures>
|
||||
<CallEmbedProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<ClientLayout>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
<CallStatusRenderer />
|
||||
|
|
@ -157,13 +151,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<Route
|
||||
path={HOME_PATH}
|
||||
element={
|
||||
<PageRoot
|
||||
nav={
|
||||
<MobileFriendlyPageNav path={HOME_PATH}>
|
||||
<Home />
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<PageRoot nav={<EstatePageNav path={HOME_PATH} />}>
|
||||
<Outlet />
|
||||
</PageRoot>
|
||||
}
|
||||
|
|
@ -184,13 +172,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<Route
|
||||
path={DIRECT_PATH}
|
||||
element={
|
||||
<PageRoot
|
||||
nav={
|
||||
<MobileFriendlyPageNav path={DIRECT_PATH}>
|
||||
<Direct />
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<PageRoot nav={<EstatePageNav path={DIRECT_PATH} />}>
|
||||
<Outlet />
|
||||
</PageRoot>
|
||||
}
|
||||
|
|
@ -210,13 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
path={SPACE_PATH}
|
||||
element={
|
||||
<RouteSpaceProvider>
|
||||
<PageRoot
|
||||
nav={
|
||||
<MobileFriendlyPageNav path={SPACE_PATH}>
|
||||
<Space />
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<PageRoot nav={<EstatePageNav path={SPACE_PATH} />}>
|
||||
<Outlet />
|
||||
</PageRoot>
|
||||
</RouteSpaceProvider>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,20 @@
|
|||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { configClass, varsClass } from 'folds';
|
||||
import {
|
||||
DarkTheme,
|
||||
LightTheme,
|
||||
GameCultTheme,
|
||||
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);
|
||||
if (systemThemeKind === ThemeKind.Dark) {
|
||||
document.body.classList.add(...DarkTheme.classNames);
|
||||
}
|
||||
if (systemThemeKind === ThemeKind.Light) {
|
||||
document.body.classList.add(...LightTheme.classNames);
|
||||
}
|
||||
}, [systemThemeKind]);
|
||||
// The GameCult identity is single-theme: signed-out pages ignore the system scheme.
|
||||
document.body.classList.add(...GameCultTheme.classNames);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function AuthFooter() {
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
v4.12.7
|
||||
v4.12.8
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||
Twitter
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box } from 'folds';
|
||||
|
||||
type ClientLayoutProps = {
|
||||
nav: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||
return (
|
||||
<Box grow="Yes">
|
||||
<Box shrink="No">{nav}</Box>
|
||||
<Box grow="Yes">{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
export function ClientLayout({ children }: { children: ReactNode }) {
|
||||
return <Box grow="Yes">{children}</Box>;
|
||||
}
|
||||
|
|
|
|||
15
src/app/pages/client/EstatePageNav.tsx
Normal file
15
src/app/pages/client/EstatePageNav.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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 (
|
||||
<MobileFriendlyPageNav path={path}>
|
||||
<EstateNavigator />
|
||||
</MobileFriendlyPageNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent
|
||||
scrollable={
|
||||
<Scroll ref={scrollRef} variant="Background" size="0">
|
||||
<SidebarStack>
|
||||
<HomeTab />
|
||||
<DirectTab />
|
||||
</SidebarStack>
|
||||
<SpaceTabs scrollRef={scrollRef} />
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<ExploreTab />
|
||||
<CreateTab />
|
||||
</SidebarStack>
|
||||
</Scroll>
|
||||
}
|
||||
sticky={
|
||||
<>
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<SearchTab />
|
||||
<UnverifiedTab />
|
||||
<InboxTab />
|
||||
<SettingsTab />
|
||||
</SidebarStack>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ export function WelcomePage() {
|
|||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
v4.12.7
|
||||
v4.12.8
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
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<HTMLDivElement, DirectMenuProps>(({ 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 (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
function DirectHeader() {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageNavHeader>
|
||||
<Box alignItems="Center" grow="Yes" gap="300">
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
Direct Messages
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||
<Icon src={Icons.VerticalDots} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectEmpty() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Mention} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
No Direct Messages
|
||||
</Text>
|
||||
}
|
||||
content={
|
||||
<Text size="T300" align="Center">
|
||||
You do not have any direct messages yet.
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
|
||||
<Text size="B300" truncate>
|
||||
Direct Message
|
||||
</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
|
||||
export function Direct() {
|
||||
const mx = useMatrixClient();
|
||||
useNavToActivePathMapper('direct');
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<PageNav>
|
||||
<DirectHeader />
|
||||
{noRoomToDisplay ? (
|
||||
<DirectEmpty />
|
||||
) : (
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box direction="Column" gap="300">
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
|
||||
<NavButton onClick={() => navigate(getDirectCreatePath())}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Create Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
Chats
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedDirects[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
showAvatar
|
||||
direct
|
||||
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
|
|
|
|||
|
|
@ -1,362 +0,0 @@
|
|||
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<HTMLDivElement, HomeMenuProps>(({ 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 (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
function HomeHeader() {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageNavHeader>
|
||||
<Box alignItems="Center" grow="Yes" gap="300">
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
Home
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||
<Icon src={Icons.VerticalDots} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeEmpty() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
No Rooms
|
||||
</Text>
|
||||
}
|
||||
content={
|
||||
<Text size="T300" align="Center">
|
||||
You do not have any rooms yet.
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
<>
|
||||
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
|
||||
<Text size="B300" truncate>
|
||||
Create Room
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(getExplorePath())}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
Explore Community Rooms
|
||||
</Text>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||
export function Home() {
|
||||
const mx = useMatrixClient();
|
||||
useNavToActivePathMapper('home');
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<PageNav>
|
||||
<HomeHeader />
|
||||
{noRoomToDisplay ? (
|
||||
<HomeEmpty />
|
||||
) : (
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box direction="Column" gap="300">
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
|
||||
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Create Room
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
<UseStateProvider initial={false}>
|
||||
{(open, setOpen) => (
|
||||
<>
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavButton onClick={() => setOpen(true)}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Link} size="100" />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Join with Address
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
{open && (
|
||||
<JoinAddressPrompt
|
||||
onCancel={() => setOpen(false)}
|
||||
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
||||
setOpen(false);
|
||||
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
||||
navigate(
|
||||
viaServers
|
||||
? withSearchParam<_RoomSearchParams>(path, {
|
||||
viaServers: encodeSearchParamValueArray(viaServers),
|
||||
})
|
||||
: path
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
|
||||
<NavLink to={getHomeSearchPath()}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Search} size="100" filled={searchSelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Message Search
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './Home';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
|
|
|||
|
|
@ -45,12 +45,8 @@ import {
|
|||
} from '../../../utils/room';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { RoomAvatar } from '../../../components/room-avatar';
|
||||
import {
|
||||
addRoomIdToMDirect,
|
||||
getMxIdLocalPart,
|
||||
guessDmRoomUserId,
|
||||
rateLimitedActions,
|
||||
} from '../../../utils/matrix';
|
||||
import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix';
|
||||
import { activateEstateRoom } from '../../../features/estate/joinEstateRoom';
|
||||
import { Time } from '../../../components/message';
|
||||
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
|
||||
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
|
||||
|
|
@ -160,24 +156,20 @@ 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<void, MatrixError, []>(
|
||||
useCallback(async () => {
|
||||
const dmUserId = isDirectInvite(invite.room, userId)
|
||||
? guessDmRoomUserId(invite.room, userId)
|
||||
: undefined;
|
||||
|
||||
await mx.joinRoom(invite.roomId);
|
||||
if (dmUserId) {
|
||||
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
|
||||
}
|
||||
const result = await activateEstateRoom(mx, {
|
||||
roomId: invite.roomId,
|
||||
state: 'invited',
|
||||
via: [],
|
||||
});
|
||||
if (result.kind === 'refused') throw new Error(result.message);
|
||||
onNavigate(invite.roomId, invite.isSpace);
|
||||
}, [mx, invite, userId, onNavigate])
|
||||
}, [mx, invite, onNavigate])
|
||||
);
|
||||
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
||||
useCallback(() => mx.leave(invite.roomId), [mx, invite])
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
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<RectCords>();
|
||||
const [joinAddress, setJoinAddress] = useState(false);
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleCreateSpace = () => {
|
||||
navigate(getCreatePath());
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
const handleJoinWithAddress = () => {
|
||||
setJoinAddress(true);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarItem active={createSelected}>
|
||||
<SidebarItemTooltip tooltip="Add Space">
|
||||
{(triggerRef) => (
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
position="Right"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
returnFocusOnDeactivate: false,
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column">
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant="Surface"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
radii="0"
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={handleCreateSpace}
|
||||
>
|
||||
<SettingTile before={<Icon size="400" src={Icons.Space} />}>
|
||||
<Text size="H6">Create Space</Text>
|
||||
<Text size="T300" priority="300">
|
||||
Build a space for your community.
|
||||
</Text>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
style={{ padding: config.space.S300 }}
|
||||
variant="Surface"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
radii="0"
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={handleJoinWithAddress}
|
||||
>
|
||||
<SettingTile before={<Icon size="400" src={Icons.Link} />}>
|
||||
<Text size="H6">Join with Address</Text>
|
||||
<Text size="T300" priority="300">
|
||||
Become a part of existing community.
|
||||
</Text>
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<SidebarAvatar
|
||||
className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
|
||||
as="button"
|
||||
ref={triggerRef}
|
||||
outlined
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Icon src={Icons.Plus} />
|
||||
</SidebarAvatar>
|
||||
{joinAddress && (
|
||||
<JoinAddressPrompt
|
||||
onCancel={() => setJoinAddress(false)}
|
||||
onOpen={(roomIdOrAlias, viaServers) => {
|
||||
setJoinAddress(false);
|
||||
const path = getSpacePath(roomIdOrAlias);
|
||||
navigate(
|
||||
viaServers
|
||||
? withSearchParam<_RoomSearchParams>(path, {
|
||||
viaServers: encodeSearchParamValueArray(viaServers),
|
||||
})
|
||||
: path
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopOut>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
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<HTMLDivElement, DirectMenuProps>(({ 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 (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
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<RectCords>();
|
||||
|
||||
const directSelected = useDirectSelected();
|
||||
|
||||
const handleDirectClick = () => {
|
||||
const activePath = navToActivePath.get('direct');
|
||||
if (activePath && screenSize !== ScreenSize.Mobile) {
|
||||
navigate(joinPathComponent(activePath));
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(getDirectPath());
|
||||
};
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<SidebarItem active={directSelected}>
|
||||
<SidebarItemTooltip tooltip="Direct Messages">
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
ref={triggerRef}
|
||||
outlined
|
||||
onClick={handleDirectClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Icon src={Icons.User} filled={directSelected} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{directUnread && (
|
||||
<SidebarItemBadge hasCount={directUnread.total > 0}>
|
||||
<UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Right"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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 (
|
||||
<SidebarItem active={exploreSelected}>
|
||||
<SidebarItemTooltip tooltip="Explore Community">
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleExploreClick}>
|
||||
<Icon src={Icons.Explore} filled={exploreSelected} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
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<HTMLDivElement, HomeMenuProps>(({ 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 (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
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<RectCords>();
|
||||
|
||||
const handleHomeClick = () => {
|
||||
const activePath = navToActivePath.get('home');
|
||||
if (activePath && screenSize !== ScreenSize.Mobile) {
|
||||
navigate(joinPathComponent(activePath));
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(getHomePath());
|
||||
};
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarItem active={homeSelected}>
|
||||
<SidebarItemTooltip tooltip="Home">
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
ref={triggerRef}
|
||||
outlined
|
||||
onClick={handleHomeClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Icon src={Icons.Home} filled={homeSelected} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{homeUnread && (
|
||||
<SidebarItemBadge hasCount={homeUnread.total > 0}>
|
||||
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Right"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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 (
|
||||
<SidebarItem active={opened}>
|
||||
<SidebarItemTooltip tooltip="Search">
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}>
|
||||
<Icon src={Icons.Search} filled={opened} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,866 +0,0 @@
|
|||
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<HTMLDivElement, SpaceMenuProps>(
|
||||
({ 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 (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
{invitePrompt && room && (
|
||||
<InviteUserPrompt
|
||||
room={room}
|
||||
requestClose={() => {
|
||||
setInvitePrompt(false);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{onUnpin && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleUnpin}
|
||||
after={<Icon size="100" src={Icons.Pin} />}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unpin
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.UserPlus} />}
|
||||
radii="300"
|
||||
aria-pressed={invitePrompt}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Invite
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleCopyLink}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Link} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Copy Link
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleRoomSettings}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Setting} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Space Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type InstructionType = Instruction['type'];
|
||||
type FolderDraggable = {
|
||||
folder: ISidebarFolder;
|
||||
spaceId?: string;
|
||||
open?: boolean;
|
||||
};
|
||||
type SidebarDraggable = string | FolderDraggable;
|
||||
|
||||
const useDraggableItem = (
|
||||
item: SidebarDraggable,
|
||||
targetRef: RefObject<HTMLElement>,
|
||||
onDragging: (item?: SidebarDraggable) => void,
|
||||
dragHandleRef?: RefObject<HTMLElement>
|
||||
): 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<HTMLElement>
|
||||
): Instruction | undefined => {
|
||||
const [dropState, setDropState] = useState<Instruction>();
|
||||
|
||||
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<T extends InstructionType>(
|
||||
item: SidebarDraggable,
|
||||
targetRef: RefObject<HTMLElement>,
|
||||
instructionType: T
|
||||
): T | undefined {
|
||||
const [dropState, setDropState] = useState<T>();
|
||||
|
||||
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<HTMLElement>,
|
||||
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<HTMLButtonElement>;
|
||||
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<HTMLDivElement>(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<RectCords>();
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<RoomUnreadProvider roomId={space.roomId}>
|
||||
{(unread) => (
|
||||
<SidebarItem
|
||||
active={selected}
|
||||
ref={targetRef}
|
||||
aria-disabled={disabled}
|
||||
data-drop-child={dropType === 'make-child'}
|
||||
data-drop-above={dropType === 'reorder-above'}
|
||||
data-drop-below={dropType === 'reorder-below'}
|
||||
data-inside-folder={!!folder}
|
||||
>
|
||||
<SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
data-id={space.roomId}
|
||||
ref={triggerRef}
|
||||
size={folder ? '300' : '400'}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<RoomAvatar
|
||||
roomId={space.roomId}
|
||||
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
|
||||
alt={space.name}
|
||||
renderFallback={() => (
|
||||
<Text size={folder ? 'H6' : 'H4'}>{nameInitials(space.name, 2)}</Text>
|
||||
)}
|
||||
/>
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{unread && (
|
||||
<SidebarItemBadge hasCount={unread.total > 0}>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Right"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<SpaceMenu
|
||||
room={space}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
onUnpin={onUnpin}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
)}
|
||||
</RoomUnreadProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type OpenedSpaceFolderProps = {
|
||||
folder: ISidebarFolder;
|
||||
onClose: MouseEventHandler<HTMLButtonElement>;
|
||||
children?: ReactNode;
|
||||
};
|
||||
function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) {
|
||||
const aboveTargetRef = useRef<HTMLDivElement>(null);
|
||||
const belowTargetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]);
|
||||
|
||||
const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above');
|
||||
const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below');
|
||||
|
||||
return (
|
||||
<SidebarFolder
|
||||
state="Open"
|
||||
data-drop-above={orderAbove === 'reorder-above'}
|
||||
data-drop-below={orderBelow === 'reorder-below'}
|
||||
>
|
||||
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
|
||||
<SidebarAvatar size="300">
|
||||
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
|
||||
<Icon size="400" src={Icons.ChevronTop} filled />
|
||||
</IconButton>
|
||||
</SidebarAvatar>
|
||||
{children}
|
||||
<SidebarFolderDropTarget ref={belowTargetRef} position="Bottom" />
|
||||
</SidebarFolder>
|
||||
);
|
||||
}
|
||||
|
||||
type ClosedSpaceFolderProps = {
|
||||
folder: ISidebarFolder;
|
||||
selected: boolean;
|
||||
onOpen: MouseEventHandler<HTMLButtonElement>;
|
||||
onDragging: (dragItem?: SidebarDraggable) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function ClosedSpaceFolder({
|
||||
folder,
|
||||
selected,
|
||||
onOpen,
|
||||
onDragging,
|
||||
disabled,
|
||||
}: ClosedSpaceFolderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const handlerRef = useRef<HTMLDivElement>(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 (
|
||||
<RoomsUnreadProvider rooms={folder.content}>
|
||||
{(unread) => (
|
||||
<SidebarItem
|
||||
active={selected}
|
||||
ref={handlerRef}
|
||||
aria-disabled={disabled}
|
||||
data-drop-child={dropType === 'make-child'}
|
||||
data-drop-above={dropType === 'reorder-above'}
|
||||
data-drop-below={dropType === 'reorder-below'}
|
||||
>
|
||||
<SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
|
||||
{(tooltipRef) => (
|
||||
<SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
|
||||
{folder.content.map((sId) => {
|
||||
const space = mx.getRoom(sId);
|
||||
if (!space) return null;
|
||||
|
||||
return (
|
||||
<SidebarAvatar key={sId} size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={space.roomId}
|
||||
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
|
||||
alt={space.name}
|
||||
renderFallback={() => (
|
||||
<Text size="Inherit">
|
||||
<b>{nameInitials(space.name, 2)}</b>
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</SidebarAvatar>
|
||||
);
|
||||
})}
|
||||
</SidebarFolder>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{unread && (
|
||||
<SidebarItemBadge hasCount={unread.total > 0}>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
</SidebarItem>
|
||||
)}
|
||||
</RoomsUnreadProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SpaceTabsProps = {
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
};
|
||||
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<SidebarDraggable>();
|
||||
|
||||
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<HTMLButtonElement> = (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<HTMLButtonElement> = (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 (
|
||||
<>
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
{sidebarItems.map((item) => {
|
||||
if (typeof item === 'object') {
|
||||
if (openedFolder.has(item.id)) {
|
||||
return (
|
||||
<OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
|
||||
{item.content.map((sId) => {
|
||||
const space = mx.getRoom(sId);
|
||||
if (!space) return null;
|
||||
return (
|
||||
<SpaceTab
|
||||
key={space.roomId}
|
||||
space={space}
|
||||
selected={space.roomId === selectedSpaceId}
|
||||
onClick={handleSpaceClick}
|
||||
folder={item}
|
||||
onDragging={setDraggingItem}
|
||||
disabled={
|
||||
typeof draggingItem === 'object'
|
||||
? draggingItem.spaceId === space.roomId
|
||||
: false
|
||||
}
|
||||
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</OpenedSpaceFolder>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClosedSpaceFolder
|
||||
key={item.id}
|
||||
folder={item}
|
||||
selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
|
||||
onOpen={handleFolderToggle}
|
||||
onDragging={setDraggingItem}
|
||||
disabled={
|
||||
typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const space = mx.getRoom(item);
|
||||
if (!space) return null;
|
||||
|
||||
return (
|
||||
<SpaceTab
|
||||
key={space.roomId}
|
||||
space={space}
|
||||
selected={space.roomId === selectedSpaceId}
|
||||
onClick={handleSpaceClick}
|
||||
onDragging={setDraggingItem}
|
||||
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
|
||||
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarStack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
export * from './HomeTab';
|
||||
export * from './DirectTab';
|
||||
export * from './SpaceTabs';
|
||||
export * from './InboxTab';
|
||||
export * from './ExploreTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './UnverifiedTab';
|
||||
export * from './SearchTab';
|
||||
|
|
|
|||
|
|
@ -1,540 +0,0 @@
|
|||
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<HTMLDivElement, SpaceMenuProps>(({ 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 (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{invitePrompt && room && (
|
||||
<InviteUserPrompt
|
||||
room={room}
|
||||
requestClose={() => {
|
||||
setInvitePrompt(false);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Mark as Read
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.UserPlus} />}
|
||||
radii="300"
|
||||
aria-pressed={invitePrompt}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Invite
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleCopyLink}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Link} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Copy Link
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleRoomSettings}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Setting} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Space Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{developerTools && (
|
||||
<MenuItem
|
||||
onClick={handleOpenTimeline}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Terminal} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Event Timeline
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Leave Space
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveSpacePrompt
|
||||
roomId={room.roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
function SpaceHeader() {
|
||||
const space = useSpace();
|
||||
const spaceName = useRoomName(space);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const joinRules = useStateEvent(
|
||||
space,
|
||||
StateEvent.RoomJoinRules
|
||||
)?.getContent<RoomJoinRulesEventContent>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageNavHeader>
|
||||
<Box alignItems="Center" grow="Yes" gap="300">
|
||||
<Box grow="Yes" alignItems="Center" gap="100">
|
||||
<Text size="H4" truncate>
|
||||
{spaceName}
|
||||
</Text>
|
||||
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||
<Icon src={Icons.VerticalDots} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
borderWidth: config.borderWidth.B300,
|
||||
}}
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Text size="L400">Space Upgraded</Text>
|
||||
<Text size="T200">This space has been replaced and is no longer active.</Text>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
|
||||
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box direction="Column" shrink="No">
|
||||
{replacementRoom?.getMyMembership() === Membership.Join ||
|
||||
joinState.status === AsyncStatus.Success ? (
|
||||
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="B300">Open New Space</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
before={
|
||||
joinState.status === AsyncStatus.Loading && (
|
||||
<Spinner size="100" variant="Primary" fill="Solid" />
|
||||
)
|
||||
}
|
||||
disabled={joinState.status === AsyncStatus.Loading}
|
||||
>
|
||||
<Text size="B300">Join New Space</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Space() {
|
||||
const mx = useMatrixClient();
|
||||
const space = useSpace();
|
||||
useNavToActivePathMapper(space.roomId);
|
||||
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<PageNav>
|
||||
<SpaceHeader />
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box direction="Column" gap="300">
|
||||
{tombstoneEvent && (
|
||||
<SpaceTombstone
|
||||
roomId={space.roomId}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
)}
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
|
||||
<NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Flag} size="100" filled={lobbySelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Lobby
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
|
||||
<NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Search} size="100" filled={searchSelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
Message Search
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
<NavCategory
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
data-category-id={categoryId}
|
||||
onClick={handleCategoryClick}
|
||||
closed={closedCategories.has(categoryId)}
|
||||
>
|
||||
{roomId === space.roomId ? 'Rooms' : room?.name}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
</div>
|
||||
</VirtualTile>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selectedRoomId === roomId}
|
||||
showAvatar={mDirects.has(roomId)}
|
||||
direct={mDirects.has(roomId)}
|
||||
linkPath={getToLink(roomId)}
|
||||
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from './SpaceProvider';
|
||||
export * from './Space';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ export interface Settings {
|
|||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: undefined,
|
||||
useSystemTheme: true,
|
||||
themeId: 'gamecult-theme',
|
||||
useSystemTheme: false,
|
||||
lightThemeId: undefined,
|
||||
darkThemeId: undefined,
|
||||
monochromeMode: false,
|
||||
|
|
@ -61,10 +61,10 @@ const defaultSettings: Settings = {
|
|||
pageZoom: 100,
|
||||
hideActivity: false,
|
||||
|
||||
isPeopleDrawer: true,
|
||||
isPeopleDrawer: false,
|
||||
memberSortFilterIndex: 0,
|
||||
enterForNewline: false,
|
||||
messageLayout: 0,
|
||||
messageLayout: 2,
|
||||
messageSpacing: '400',
|
||||
hideMembershipEvents: false,
|
||||
hideNickAvatarEvents: true,
|
||||
|
|
|
|||
61
src/app/utils/mDirect.test.ts
Normal file
61
src/app/utils/mDirect.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix';
|
||||
|
||||
type Content = Record<string, string[]>;
|
||||
|
||||
/** 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<void> };
|
||||
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'] });
|
||||
});
|
||||
});
|
||||
|
|
@ -225,57 +225,59 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
|
|||
return member1?.userId ?? myUserId;
|
||||
};
|
||||
|
||||
export const addRoomIdToMDirect = async (
|
||||
type MDirectContent = Record<string, string[]>;
|
||||
|
||||
const mDirectWrites = new WeakMap<MatrixClient, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* 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<void> => {
|
||||
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 = (
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
userId: string
|
||||
): Promise<void> => {
|
||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
||||
let userIdToRoomIds: Record<string, string[]> = {};
|
||||
|
||||
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);
|
||||
): Promise<void> =>
|
||||
updateMDirect(mx, (current) => {
|
||||
const next = structuredClone(current);
|
||||
Object.keys(next).forEach((targetUserId) => {
|
||||
if (targetUserId !== userId) {
|
||||
next[targetUserId] = next[targetUserId].filter((id) => id !== roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
const roomIds = next[userId] ?? [];
|
||||
if (!roomIds.includes(roomId)) roomIds.push(roomId);
|
||||
next[userId] = roomIds;
|
||||
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<void> => {
|
||||
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
|
||||
let userIdToRoomIds: Record<string, string[]> = {};
|
||||
|
||||
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);
|
||||
}
|
||||
export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise<void> =>
|
||||
updateMDirect(mx, (current) => {
|
||||
const next: MDirectContent = {};
|
||||
Object.keys(current).forEach((userId) => {
|
||||
next[userId] = current[userId].filter((id) => id !== roomId);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
|
||||
};
|
||||
|
||||
export const mxcUrlToHttp = (
|
||||
mx: MatrixClient,
|
||||
mxcUrl: string,
|
||||
|
|
|
|||
|
|
@ -236,3 +236,95 @@ 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)',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
20
src/gamecult.css.ts
Normal file
20
src/gamecult.css.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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',
|
||||
},
|
||||
});
|
||||
|
|
@ -135,3 +135,67 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ 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';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ 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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue