Compare commits

...
Author SHA1 Message Date
Meta Vens
b33d8de956 estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:04:14 +01:00
Meta Vens
a4da78edc5 estate tests: an unfiled room leaves its inviter's list empty, not the map
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:00:38 +01:00
Meta Vens
9b66b2b250 estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:52:45 +01:00
Meta Vens
6950da865e estate harness: every drive asserts the outcome it shows and run.sh fails when one does not
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:37:35 +01:00
Meta Vens
8f1bb54b4b estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals
RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom
beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates
to the one path. /join hands each refusal to the user instead of discarding it.
Tests pin the via pass-through and the 400/429/499 refusal bounds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:35:35 +01:00
Meta Vens
b20b958762 estate: prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:34:49 +01:00
Meta Vens
00ec686b1b estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:33:06 +01:00
Meta Vens
873c0e30a2 estate harness: run the s6 route drives last 2026-10-31 22:20:42 +01:00
Meta Vens
7453a0bd32 estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal
The room card's Join (deep links, permalinks, history) and /join joined
through mx.joinRoom and never filed the DM. They now go through
activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown
outcome like a dropped connection: the entry stays and the tap is retryable.
The harness gains Soul's s6 deeplink and gw504 drives.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:20:28 +01:00
Meta Vens
2c04bbefa3 estate harness: the failure driver never waits on a hung browser close
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:51:32 +01:00
Meta Vens
cdfd43fe9f estate harness: the failure driver closes its browser on timeout; the run waits for each fake server to exit; prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:29:03 +01:00
Meta Vens
f6244a4781 estate harness: the join-failure and inbox Accept scenarios run with the committed estate run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:10:50 +01:00
Meta Vens
719f062ef1 estate: the inbox Accept joins through activateEstateRoom; a join with no server answer keeps the DM listed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:10:06 +01:00
Meta Vens
44395328e9 estate harness fake: account data is answered by the client's sync token, as a server does
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:20:37 +01:00
Meta Vens
e59cc93a2a estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:14:19 +01:00
Meta Vens
2827699439 direct messages are listed in m.direct before the join is sent, from the invite event's is_direct; the arrival-time filer, its hook and the member-flag rewrite are deleted (Soul s4 converttoroom-refiled-by-filer, dm-mark-lost-after-member-rewrite)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:11:45 +01:00
Meta Vens
3d4fb161c3 one filer for direct messages: any joined room whose invite was marked is_direct is written to m.direct in one serialized write, on startup and on every sync; the per-tap late filer is gone (Soul s3 late-dm-filings-overwrite-each-other)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:41:38 +01:00
Meta Vens
c91d0339b5 harness: read the timeout message later in the pre-arrival window 2026-10-31 18:44:39 +01:00
Meta Vens
bfb753e3e5 harness: print the unresolved-root message 2026-10-31 18:38:43 +01:00
Meta Vens
eeb9de8f40 estate navigator: the late DM filing returns void 2026-10-31 18:36:44 +01:00
Meta Vens
98d74d564d estate navigator: define EstateError before its use 2026-10-31 18:35:36 +01:00
Meta Vens
d7afdc5208 estate navigator: 44 px tap targets on phones from one stylesheet rule over the navigator 2026-10-31 18:30:54 +01:00
Meta Vens
2e179a7768 harness: print the verdict lines 2026-10-31 18:26:52 +01:00
Meta Vens
44774a3ba1 estate navigator: entry points belong to the navigator, not the tiers; short wrapping errors; a DM joined after the timeout is filed on arrival; 44 px join targets on phones 2026-10-31 18:26:45 +01:00
Meta Vens
0091e5e90d harness: Soul's s2 scenarios (unresolved root, slow DM and room joins, tap-target inventory) 2026-10-31 18:26:18 +01:00
Meta Vens
a058d2a2c6 harness: the fake server echoes account data in the next sync
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:01:32 +01:00
Meta Vens
d07c7f2327 harness: expand Elsewhere and Direct only when still collapsed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:59:53 +01:00
Meta Vens
11f0a4d325 harness: reload instead of dismissing the address prompt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:58:01 +01:00
Meta Vens
1cb18a2795 harness: CommonJS scripts under a type-module package
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:55:38 +01:00
Meta Vens
6eae929cd9 Estate: narrow parentId before the Space route lookup; prettier; ignore harness
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:50:52 +01:00
Meta Vens
1dfe38a33d Estate: lint order fixes, test title, harness out of prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:48:31 +01:00
Meta Vens
0b7ded9c99 Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs
Steward groups read org.gamecult.steward from the m.space.child content the hierarchy
returns, so an invited-only member gets them; root-state stewardships and
GameCultStewardships are deleted. Tapping Join waits for the client store to hold the
room before navigating (no invite-card frame). The header search opens the room finder
on every width. Create room and Join with address are navigator actions; joined non-DM
rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join.
harness/estate holds the fake-homeserver Playwright pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:11 +01:00
Meta Vens
41527f6ba3 config.json: the GameCult Space is the featured root, so a wholesale deploy resolves the estate
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:10 +01:00
Meta Vens
e8efd3ce76 Delete SpaceTombstone: nothing renders it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:10 +01:00
Meta Vens
067cb10a90 Estate: prettier on the new tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:13:32 +01:00
Meta Vens
c848626caa Estate: prettier, and tests that kill the surviving mutants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:11:35 +01:00
Meta Vens
4d0ee78974 Estate: lint and type fixes in the new files
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:10:41 +01:00
Meta Vens
f810a83a28 Estate: keep the new files free of unresolved-SDK type errors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:08:47 +01:00
Meta Vens
e90b70e966 Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join
buildEstate is 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 hierarchy is the access rule. The Home, Direct and Space nav slots
mount it through EstatePageNav.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:06:44 +01:00
Meta Vens
f2e3223c5f Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes
The navigator that replaces them lands next. SpaceTombstone moves to its own
file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with
the rail; ClientLayout loses its nav slot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:06:12 +01:00
Eureka
2924a3f4f3 docs: Ratatoskr map: Yggdrasil scene, orbiting Personas, shared activity vocabulary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:54:09 +01:00
Eureka
841784ea64 docs: Ratatoskr map: build baseline, two-pass scene render
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:50:51 +01:00
Eureka
dd88424e5d docs: Ratatoskr target and prose map (campaign ratatoskr)
Design truths from the rulings in force, body facts from probes on
2026-10-31, the navigator authority map, the reconstructed Minds'
collaboration plan for Atlas to import, and the cut sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:47:19 +01:00
Meta Vens
b8e696c81b Lockfile for fontsource faces and vitest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:05:15 +01:00
Meta Vens
a588ab82b3 GameCult theme, steward-grouped project rooms, preview base override
Adds the gamecult-theme (brand palette, softer radii, Ubuntu/Montserrat/Plex Mono),
makes it the default, hides the member drawer by default, defaults to bubble layout,
groups rooms by org.gamecult.steward in the space nav (collapsed on phones), and lets
CINNY_BASE set Vite's base for a sub-path preview build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:00:32 +01:00
Krishan
1e6412ed5b
chore: Release v4.12.8 (#3135) 2026-10-26 00:17:53 +11:00
renovate[bot]
14e490c896
fix(deps): update dependency sanitize-html v2.17.7 to v2.18.1 (#3133)
* fix(deps): update dependency sanitize-html to v2.18.0 [security]

* chore: update to latest

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2026-10-26 00:02:45 +11:00
dependabot[bot]
eff1f48e3f
chore(deps): bump actions/setup-node from 7.0.0 to 7.1.0 (#3128)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](8207627860...949feb2413)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 17:29:45 +11:00
renovate[bot]
8788f0cf49
fix(deps): update dependency i18next-http-backend 2.5.2 to 4.0.3 (#3127)
* fix(deps): update dependency i18next-http-backend to v4 [security]

* chore: update to latest

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2026-10-15 17:29:08 +11:00
Krishan
8967c13878
chore: Release v4.12.7 (#3105)
Release v4.12.7
2026-09-16 01:55:15 +10:00
Krishan
6578bdf949
chore: Update actionable issues note in CONTRIBUTING.md (#3104)
* chore: Update actionable issues note in CONTRIBUTING.md

Clarify that only issues with numbers greater than #2876 are actionable.

* Update pull request template for clarity

Simplified the pull request template by removing unnecessary details.
2026-09-16 01:54:39 +10:00
renovate[bot]
fcca7c21fb
fix(deps): update dependency folds to v2.7.2 (#3103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 01:35:33 +10:00
renovate[bot]
c143b30060
fix(deps): update dependency react-router-dom to v6.30.6 [security] (#3099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-10 17:08:39 +10:00
renovate[bot]
9a49bf4661
fix(deps): update dependency sanitize-html to v2.17.7 [security] (#3091)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-10 17:04:33 +10:00
dependabot[bot]
5794a88a96
chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.3 (#3097)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](718ea10b13...efb35369e0)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 17:04:10 +10:00
Krishan
22c6f3c1b7
chore: add a checkbox for contribution policy (#3101)
* chore: add a checkbox for contribution policy

Removed type of change section from PR template.

* Update contribution policy checklist item wording
2026-09-10 17:03:54 +10:00
Krishan
61e8f080e4
chore: update contribution guidelines (#2849)
* chore: update contribution guidelines

* reoslve review concerns
2026-09-10 17:03:36 +10:00
dependabot[bot]
cfe186efd5
chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#3090)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](bb05f3f551...37fe631027)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 15:36:27 +10:00
dependabot[bot]
02a1c44c09
chore(deps): bump docker/setup-qemu-action from 4.2.0 to 4.3.0 (#3096)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](96fe6ef7f3...1f40c72289)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 15:35:12 +10:00
dependabot[bot]
e9680fe10c
chore(deps): bump nginx from 1.31.4-alpine to 1.31.5-alpine (#3098)
Bumps nginx from 1.31.4-alpine to 1.31.5-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31.5-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 15:33:46 +10:00
Krishan
cff50dc104
chore: use marocchino/sticky-pull-request-comment for PR comment (#3100) 2026-09-10 15:32:40 +10:00
dependabot[bot]
9a02e5ce67
chore(deps): bump dawidd6/action-download-artifact from 21 to 24 (#3083)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 21 to 24.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](b6e2e70617...d63b86af1b)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '24'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 15:26:14 +10:00
dependabot[bot]
e046757d1b
chore(deps): bump docker/login-action from 4.4.0 to 4.6.0 (#3056)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](af1e73f918...dbcb813823)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 15:56:01 +10:00
dependabot[bot]
49785dbf23
chore(deps): bump nginx from 1.31.2-alpine to 1.31.4-alpine (#3082)
Bumps nginx from 1.31.2-alpine to 1.31.4-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31.4-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 15:42:40 +10:00
dependabot[bot]
3327011ea9
chore(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#3035)
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](9c091bb21b...3d3c42e5aa)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 15:37:03 +10:00
dependabot[bot]
3f9bca3d9d
chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3036)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](48b55a011b...8207627860)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-30 15:36:37 +10:00
wjaaaaaaat
c434e0dda3
fix: emoji autocompletion overwriting preceding element (#3064)
* fix getPrevWorldRange to exclude empty text children

this prevents the range from encroaching on the node of elements like emojis and pings to prevent them from being overwritten

* fix typo of "word" as "world"

* trigger pr checks
2026-08-11 14:23:15 +10:00
Krishan
33f4ba3674
chore: Release v4.12.6 (#3053) 2026-08-01 21:57:30 +10:00
renovate[bot]
90837f7230
fix(deps): update dependency sanitize-html to v2.17.6 [security] (#3051)
* fix(deps): update dependency sanitize-html to v2.17.5 [security]

* update to latest version

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2026-08-01 21:51:49 +10:00
renovate[bot]
730a748aca
fix(deps): update dependency folds to v2.7.1 (#3049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-27 21:10:41 +10:00
dependabot[bot]
ce9ae93212
chore(deps): bump docker/login-action from 4.2.0 to 4.4.0 (#3013)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](650006c6eb...af1e73f918)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 00:11:47 +10:00
dependabot[bot]
2193d258d0
chore(deps): bump docker/setup-qemu-action from 4.1.0 to 4.2.0 (#3014)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](06116385d9...96fe6ef7f3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 00:09:14 +10:00
dependabot[bot]
c46fe6826c
chore(deps): bump docker/build-push-action from 7.2.0 to 7.3.0 (#3015)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](f9f3042f7e...53b7df96c9)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 22:55:57 +10:00
dependabot[bot]
6dbbea8304
chore(deps): bump docker/setup-buildx-action from 4.1.0 to 4.2.0 (#3016)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](d7f5e7f509...bb05f3f551)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 22:55:17 +10:00
dependabot[bot]
cb858da188
chore(deps): bump docker/metadata-action from 6.1.0 to 6.2.0 (#3012)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](80c7e94dd9...dc80280410)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 22:28:46 +10:00
Krishan
a9245c8f46
fix: update device management endpoint (#3022)
When we added the endpoints were session but they got updated when the MSC is merged into spec.
Ref: https://github.com/matrix-org/matrix-spec-proposals/pull/4191#discussion_r2376900233
2026-07-19 22:26:09 +10:00
Krishan
ba742d4f4e
chore: Fix typo in discussion template (#3031)
chore: Fix typo in 'Environement' to 'Environment'
2026-07-19 22:19:51 +10:00
Krishan
5e00d517eb
revert: "chore(deps): lock file maintenance" (#3006)
Revert "chore(deps): lock file maintenance (#2995)"

This reverts commit 70b8d03c02.
2026-06-28 20:12:49 +10:00
dependabot[bot]
126e3860c2
chore(deps): bump actions/checkout from 6.0.2 to 7.0.0 (#3002)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](de0fac2e45...9c091bb21b)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 19:02:03 +10:00
dependabot[bot]
8db07e9324
chore(deps): bump nwtgck/actions-netlify from 3.0.0 to 4.0.0 (#3001)
Bumps [nwtgck/actions-netlify](https://github.com/nwtgck/actions-netlify) from 3.0.0 to 4.0.0.
- [Release notes](https://github.com/nwtgck/actions-netlify/releases)
- [Changelog](https://github.com/nwtgck/actions-netlify/blob/develop/CHANGELOG.md)
- [Commits](4cbaf4c08f...d22a32a27c)

---
updated-dependencies:
- dependency-name: nwtgck/actions-netlify
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 19:00:58 +10:00
dependabot[bot]
b4338d1cca
chore(deps): bump docker/setup-qemu-action from 3.7.0 to 4.1.0 (#3003)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](c7c5346462...06116385d9)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 18:57:14 +10:00
dependabot[bot]
e00625b8f8
chore(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.1 (#3000)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](b430933298...718ea10b13)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 18:56:23 +10:00
dependabot[bot]
b0b1ac6413
chore(deps): bump nginx from 1.29.8-alpine to 1.31.2-alpine (#2999)
Bumps nginx from 1.29.8-alpine to 1.31.2-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31.2-alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 18:54:52 +10:00
Krishan
6280dfa522
chore: Specify tag_name for tarball upload (#2998)
Specify `tag_name` for tarball upload
2026-06-25 12:42:38 +10:00
Krishan
69515e8e81
chore: release v4.12.3 (#2996)
Release v4.12.3
2026-06-22 23:58:25 +10:00
renovate[bot]
70b8d03c02
chore(deps): lock file maintenance (#2995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 23:42:57 +10:00
dependabot[bot]
f8baf3f258
chore(deps): bump docker/build-push-action from 7.1.0 to 7.2.0 (#2967)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](bcafcacb16...f9f3042f7e)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 21:41:36 +10:00
dependabot[bot]
b76f1a6009
chore(deps): bump docker/metadata-action from 5.10.0 to 6.1.0 (#2966)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.10.0 to 6.1.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](c299e40c65...80c7e94dd9)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 21:41:17 +10:00
dependabot[bot]
7841d1dc87
chore(deps): bump docker/login-action from 4.1.0 to 4.2.0 (#2965)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 21:40:47 +10:00
dependabot[bot]
ee421a71e2
chore(deps): bump docker/setup-buildx-action from 3.12.0 to 4.1.0 (#2964)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](8d2750c68a...d7f5e7f509)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 21:35:21 +10:00
Krishan
e37971456a
chore(deps): update dependency element-call from 0.19.1 to v0.20.1 (#2992)
* chore(deps): update dependency @element-hq/element-call-embedded from 0.19.1 to v0.20.1

* fix reaction button click

* fix call settings button query

* fix screenshare and spotlight button state update

* fix incomming call text alignment

* prevent displaying call notification for voice rooms

* disable mic/video icon when toggle in progress

* update matrix-js-sdk

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2026-06-20 21:20:25 +10:00
Yash
762e99a907
fix: space title and description overflow in lobby (#2969)
* fix: update PageHero styles for better text handling and add PageHeroText class

* trigger cla recheck

* refactor: remove unused styles from PageHeroText and LobbyHeroTopic

* fix: remove unnecessary class names from PageHero for cleaner markup
style: add styles to LobbyHeroTopic for improved text display
2026-06-16 09:09:50 +10:00
Krishan
80fd8863c9
chore: Release v4.12.2 (#2956)
Release v4.12.2
2026-05-23 22:10:21 +10:00
Krishan
cf2810b35e
chore: remove semantic-release and keep script (#2946)
* chore: remove semantic-release

* chore: keep workflow to be tag publish based

* chore: fix typo
2026-05-23 22:02:49 +10:00
Ajay Bura
4916efa925
fix: call drop (#2954)
* update matrix-js-sdk and improve call driver

* remove unused room param from call members hook

* downgrade matrix-js-sdk to latest stable release
2026-05-23 21:50:41 +10:00
kfiven
e89b8f7d12 chore(release): 4.12.1 [skip ci] 2026-05-15 07:20:54 +00:00
Krishan
9bc1e7e9ff
fix: null edit for another release (#2942) 2026-05-15 17:19:08 +10:00
kfiven
c05a6be6f2 chore(release): 4.12.0 [skip ci] 2026-05-15 07:02:05 +00:00
Krishan
f7f4a41d61
Revert "chore: Update GITHUB_TOKEN to CLA_PAT in prod workflow" (#2941)
Revert "chore: Update GITHUB_TOKEN to CLA_PAT in prod workflow (#2940)"

This reverts commit 81327678b1.
2026-05-15 16:59:52 +10:00
Krishan
81327678b1
chore: Update GITHUB_TOKEN to CLA_PAT in prod workflow (#2940)
Changed GITHUB_TOKEN secret to CLA_PAT for semantic release due to branch protection.
2026-05-15 16:57:38 +10:00
renovate[bot]
bad1fb609a
fix(deps): update dependency sanitize-html to v2.17.4 (#2937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 16:33:13 +10:00
Ajay Bura
bef267257a
fix: support for stable mutual rooms endpoint (#2939)
* add support for stable mutual rooms endpoint

* add stable mutual room feature check
2026-05-15 16:31:47 +10:00
Ajay Bura
909aa430b8
fix: notification cause crash on android (#2938)
fix notification cause crash on android
2026-05-15 16:30:23 +10:00
Krishan
0b99d85244
docs: Update featured communities in Explore (#2936)
* fix: Update featured communities in Explore

* Add new spaces and rooms to config.json

* Remove #pcapdroid room from configuration

* Update rooms list in config.json

Replaced '#archlinux:archlinux.org' with '#tuwunel:grin.hu' in rooms list.

* Update channel list in config.json
2026-05-14 21:04:30 +10:00
LeaPhant
21bbf4bee0
fix: support audio with ogg filetype (#2924)
fix: ogg audio workaround
2026-05-14 15:16:54 +05:30
Ajay Bura
e5e0b96861
feat: Add option to start video call in DM (#2745)
* add option to start video all in DM

* show speaker icon for dm's in call status name

* show call view if call is active in room

* add Atria call ringtone

* update element call and widget api

* add option to start voice/video call in dms

* only show call button if user have permission

* allow call widget to send call notification event

* show incoming call dialog and play sound

* fix call permission checks

* allow option to start call in all rooms

* send notification when starting call in non-voice rooms

* hide header call button from voice rooms

* prevent call join if call not supported and started by other party

* update call menu style

* show call not supported message on incoming call notification

* improve the incoming call layout

* video call with right click without opening menu

* allow call widget to fetch media url

* add webRTC missing error

* improve call permission label

---------

Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2026-05-14 19:41:12 +10:00
Ajay Bura
02d1001583
feat: allow codeblock plaintext inside codeblock and nested lists markdown (#2930)
* fix crash when editing message with empty trailing heading

* remove unused imports

* allow codeblock plaintext inside codeblock by extending fence count

* allow nested list in markdown
2026-05-14 15:02:54 +10:00
Krishan
64468dfb1b
Merge commit from fork
Updated the validation for PR number extraction to ensure it contains only numeric content, and changed the secret used for Netlify authentication.
2026-05-14 13:01:54 +10:00
dependabot[bot]
2864a5e4b8
chore(deps): bump dawidd6/action-download-artifact from 20 to 21 (#2925)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 20 to 21.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](8305c0f106...b6e2e70617)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '21'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 15:34:41 +10:00
Ajay Bura
735bc15011
fix: empty heading crash on edit msg (#2929)
* fix crash when editing message with empty trailing heading

* remove unused imports
2026-05-11 22:59:43 +10:00
Shea
341fedd932
fix: edit lists crashing and list rendering issue in Firefox (#2920)
* one liner fix editor

* fix firefox rendering lists

* moved fixes

* moved fixes per ajbura
2026-05-03 22:15:08 +10:00
dependabot[bot]
d186d31399
chore(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (#2906)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](53b83947a5...48b55a011b)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 10:12:30 +10:00
James
098684973e
fix: do not attempt to join call on doubleclick if missing permissions (#2798)
* fix: do not attempt to join call on doubleclick if missing permissions

* update comment

* export getPowersLevelFromMatrixEvent for usage elsewhere

* only read vc permissions when actually needed instead of reactively
2026-04-16 22:25:53 +10:00
Krishan
b107109453
chore: remove package group definitions from renovate config (#2898)
Removed group definitions for Slatejs and Call.
2026-04-16 22:00:39 +10:00
dependabot[bot]
a33e8db9a3
chore(deps): bump dawidd6/action-download-artifact from 16 to 20 (#2880)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 16 to 20.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](2536c51d3d...8305c0f106)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '20'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:36:52 +10:00
dependabot[bot]
fb76e3ecb4
chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#2893)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](bbbca2ddaa...043fb46d1a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:33:37 +10:00
dependabot[bot]
3d79293167
chore(deps): bump softprops/action-gh-release from 2.3.3 to 3.0.0 (#2892)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.3 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](6cbd405e2c...b430933298)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:33:01 +10:00
dependabot[bot]
74745edcda
chore(deps): bump nginx from 1.29.5-alpine to 1.29.8-alpine (#2894)
Bumps nginx from 1.29.5-alpine to 1.29.8-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.8-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:31:53 +10:00
dependabot[bot]
0812131a97
chore(deps): bump docker/build-push-action from 6.19.2 to 7.1.0 (#2895)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](10e90e3645...bcafcacb16)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:27:58 +10:00
dependabot[bot]
1068bba5c7
chore(deps): bump docker/login-action from 3.7.0 to 4.1.0 (#2879)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](c94ce9fb46...4907a6ddec)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:26:52 +10:00
DJ Chase
1b5e58a3b4
chore: add matrixrooms.info to directory list (#2844)
* chore: add matrixrooms.info to directory list

matrixrooms.info is a directory of all public Matrix rooms it can find,
regardless of homeserver. It is much larger than the morg directory,
so is more useful as a general search
2026-03-28 17:35:53 +11:00
Krishan
acae043f31
chore: make error more useful and understandable (#2859)
* chore: make error more useful and understandable

* chore: use similar wording
2026-03-27 21:22:46 +11:00
ranidspace
b4299f8f37
feat: add YYYY-MM-DD (ISO 8601) date format to presets (#2712)
* Add YYYY-MM-DD (ISO 8601) date format to presets

* Fix formatting due to added date format
2026-03-27 21:20:10 +11:00
Krishan
b6adac6714
chore: add notice about SDK replacement (#2778) 2026-03-25 12:10:15 +11:00
DJ Chase
1c8f203164
chore: add 'Stickers and Emojis' as featured space (#2842)
* Mention CLA in CONTRIBUTING.md

Closes: #2146

* add: 'Stickers and Emojis' to config.json

Add #stickers-and-emojis:tastytea.de (space) to config.json
2026-03-25 12:09:16 +11:00
Krishan
0c30ece281
fix: remove typo in no rooms UI (#2834) 2026-03-23 16:57:52 +11:00
Krishan
4e559e56d4
chore: group related package update together (#2833) 2026-03-23 16:49:22 +11:00
Krishan
19f28b40ac
chore: use private vulnerability disclosure (#2827) 2026-03-22 18:29:09 +11:00
Krishan
bcaf43a540
chore: fix link in issue triage template (#2826)
* chore: fix link in issue triage template

* chore: delete .github/PULL_REQUEST_TEMPLATE.md
2026-03-22 18:20:33 +11:00
Krishan
9c7b635e7e
chore: add new issue triage discussion template (#2825)
* chore: add new issue triage discussion template

* chore: ask for desktop app version as well

* chore: create preapproved.md
2026-03-22 17:55:53 +11:00
Krishan
65c87dff3a
chore: add git author to the sem release (#2815) 2026-03-21 12:07:52 +11:00
Krishan
132a76df27
chore: add semantic release (#2759)
* chore: install deps related to semantic release

* chore: add husky config

* ci: add a script to update version number on new release

* ci: update ci/cd to include semantic release changes

* chore: merge dev to semantic-release
2026-03-19 16:26:25 +11:00
DJ Chase
b0954eeddc
fix: Mention CLA in CONTRIBUTING.md (#2804)
Mention CLA in CONTRIBUTING.md

Closes: #2146
2026-03-19 11:41:41 +11:00
Ajay Bura
8f1add6059
fix: prevent codeblock filename drop on edit (#2780)
prevent codeblock filename drop on edit
2026-03-15 15:37:14 +11:00
Jan Jurzitza
8a78c9699e
feat: allow using filenames in codeblocks (#2455)
Allow using filenames in codeblocks

- If there is a dot in the language name, we instead treat the first line after ``` as the filename and everything after the last dot as the language
- we use a custom "data-label" attribute on the code block to specify the name of the file (so only compatible with cinny from this point onwards)
2026-03-14 18:54:03 +11:00
Krishan
0721b29a2c
chore: batch slate related deps (#2775) 2026-03-14 17:22:57 +11:00
Ajay Bura
3d354909d6
fix: hover state on url preview image and make it keyboard friendly (#2777)
add hover state on url preview image and make it keyboard friendly
2026-03-14 17:22:18 +11:00
LeaPhant
7570a84dfd
Show image viewer when clicking url preview thumbnail (#2309)
* Show large image overlay when clicking url preview thumbnail

* Move image overlay into its own component

* Move ImageOverlay props into extended type

* Remove export for internal type
2026-03-14 11:04:55 +05:30
Krishan
6a05ff5840
chore(release): v4.11.1 [skip ci] (#2765)
* chore(release): 4.11.0 [skip ci]

* chore(release): 4.11.1 [skip ci]
2026-03-11 23:07:37 +11:00
Krishan
919fe8381b
chore(deps): Update slate deps to 0.123.0 (#2764)
Update slate deps to latest
2026-03-11 23:06:53 +11:00
Krishan
b76ad3caaf chore(release): 4.11.0 [skip ci] 2026-03-11 04:03:48 +00:00
Ajay Bura
409d45857d
chore(deps): update folds to 2.6.2 (#2762)
update folds to 2.6.2
2026-03-10 23:03:03 +11:00
Ajay Bura
0d1566977a
fix: call ui imorovements (#2749)
* fix member button tooltip in call room header

* hide sticker button in room input based on chat window width

* render camera on off data instead of duplicate join messages

* hide duplicate call member changes instead of rendering as video status

* fix prescreen message spacing
2026-03-10 22:45:26 +11:00
Krishan
0cbfbab5ad
chore: add semantic commits to renovate configuration (#2760)
* Add semantic commits to Renovate configuration

* Fix formatting issue in renovate.json
2026-03-10 14:57:20 +11:00
Krishan
37e0c2aaac
chore(deps): continue action if login fails (#2758)
chore(action): continue action if login fails
2026-03-10 12:26:55 +11:00
Krishan
296249de32
chore: enable semantic check on PR title (#2447)
* Enable semantic check on PR title

* Update semantic pull request action version

* Update PR trigger types in workflow configuration

Removed 'reopened' and 'synchronize' types from pull request triggers.
2026-03-10 12:26:25 +11:00
Ajay Bura
4449e7c6e8
Show call support error and disable join button (#2748)
* allow user to end call if error when loading

* show call support missing error if livekit server is not provided

* prevent joining from nav item double click if no livekit support
2026-03-09 21:39:58 +11:00
Ajay Bura
2eb5a9a616
Fix crash with bad location uri (#2746)
fix crash with bad location uri
2026-03-09 18:17:15 +11:00
Ajay Bura
d679e68501
Fix recent emoji does not persist (#2722)
Fix recent emoji are not getting saved

Refactor recent emoji retrieval to ensure structured cloning and proper type checking. The sdk was not updating account data because we are mutating the original and it compare and early return if found same.
2026-03-09 17:34:44 +11:00
Ajay Bura
bc6caddcc8
Add own control buttons for element-call (#2744)
* add mutation observer hok

* add hook to read speaking member by observing iframe content

* display speaking member name in call status bar and improve layout

* fix shrining

* add joined call control bar

* remove chat toggle from room header

* change member speaking icon to mic

* fix joined call control appear in other

* show spinner on end call button

* hide call statusbar for mobile view when room is selected

* make call statusbar more mobile friendly

* fix call status bar item align
2026-03-09 08:34:48 +05:30
Ajay Bura
55e8306576
Display call member speaking status on bottom bar (#2742)
* add mutation observer hok

* add hook to read speaking member by observing iframe content

* display speaking member name in call status bar and improve layout

* fix shrining
2026-03-08 22:00:35 +11:00
Ajay Bura
7953ec80e5
Apply deafen state when call member changes (#2737)
* fix deafen not working

* apply deafen state when call member changes

* remove unnecessary condition
2026-03-08 14:22:11 +11:00
Ajay Bura
c6bb4915bc
Downgrade matrix-widget-api from 1.17.0 to 1.13.0 (#2736)
Some user was having Disconnection Error
2026-03-07 20:47:45 +11:00
Krishan
b050cd01f9
feat: Add voice/video room support (#2680)
* Add users on the nav to showcase call activity and who is in the call

* add check to prevent DCing from the call you're currently in...

* Add avatar and username for the space (needs to be moved into RoomNavItem proper)

* Add background variant to buttons

* Update hook to keep method signature (accepting an array of Rooms instead) to support multiple room event tracking of the same event

* Add state listener so the call activity is real time updated on joins/leaves within the space

* Add RoomNavUser for displaying the user avatar + name in the nav for a visual of call activity and participants

* rename CallNavBottom to CallNavStatus

* Rename callnavbottom and fix linking implementation to actually be correct

* temp fix to allow the status to be cleared in some way

* re-add background to active call link button

* prepare to feed this to child elements for visibility handling

* loosely provide nav handling for testing refactoring

* Add CallView

* Update to funnel Outlet context through for Call handling (might not be the best approach, but removes code replication in PersistentCallContainer where we were remaking the roomview entirely)

* update client layout to funnel outlet the iframes for the call container

* funnel through just iframe for now for testing sake

* Update room to use CallView

* Pass forward the backupIframeRef now

* remove unused params

* Add backupIframeRef so we can re-add the lobby screen for non-joined calls (for viewing their text channels)

* Remove unused imports and restructure to support being parent to clientlayout

* Re-add layout as we're no longer oddly passing outlet context

* swap to using ref provider context from to connect to persistentcallcontainer more directly

* Revert to original code as we've moved calling to be more inline with design

* Revert to original code as we've moved the outlet context passing out and made more direct use of the ref

* Fix unexpected visibility in non-room areas

* correctly provide visibility

* re-add mobile chat handling

* Improve call room view stability

* split into two refs

* add ViewedRoom usage

* Disable

* add roomViewId and related

* (broken) juggle the iframe states proper... still needs fixing

* Conditionals to manage the active iframe state better

* add navigateRoom to be in both conditions for the nav button

* Fix the view to correctly display the active iframe based on which is currently hosting the active call (juggling views)

* Testing the iframe juggling. Seems to work for the first and second joins... so likely on the right path with this

* add url as a param for widget url

* fix backup iframe visibility

* Much closer to the call state handling we want w/ hangups and joins

* Fix the position of the member drawer to its correct location

* Ensure drawer doesn't appear in call room

* Better handling of the isCallActive in the join handler

* Add ideal call room join behavior where text rooms to call room simply joins, but doesn't swap current view

* Fix mobile call room default behavior from auto-join to displaying lobby

* swap call status to be bound to call state and not active call id

* Remove clean room ID and add default handler for if no active call has existed yet, but user clicks on show chat

* Applies the correct changes to the call state and removes listeners of old active widget so we don't trigger hang ups on the new one (the element-call widget likes to spam the hang up response back several times for some reason long after you tell it to hang up)

* Remove superfluous comments and Date.now() that was causing loading... bug when widgetId desynced

* Remove Date.now() that was causing widgetId desync

* add listener clearing, camel case es lint rule exception, remove unneeded else statements

* Remove unused

* Add widgetId as a getWidgetUrl param

* Remove no longer needed files

* revert ternary expression change and add to dependency array

* add widgetId to correct pos in getWidgetUrl usage

* Remove CallActivation

* Move and rename RoomCallNavStatus

* update imports and dependency array

* Rename and clean up

* Moved CallProvider

* Fix spelling mistake

* Fix to use shorthand prop

* Remove unneeded logger.errors

* Fixes element-call embedded support (but it seems to run poorly)

* null the default url so that we fallback to the embedded version (would recommend hosting it until performance issue is determined)

* Fix vite build to place element-call correctly for embedded npm package support

* add vite preview as an npm script

* Move files to more correct location

* Add package-lock changes

* Set dep version to exact

* Fix path issue from moving file locations

* Sets initial states so the iframes don't cause the other to fail with the npm embedded package

* Revert navitem change

* Just check for state on both which should only occur at initial

* Fixes call initializing by default on mobile

* Provides correct behavior when call isn't active and no activeClientWidgetApi exists yet

* Corrects the state for the situations where both iframes are "active" (not necessarily visible)

* Reduce code reuse in handleJoin

* Seems to sort out the hangup status button bug the occurred after joining a call via lobby

* Re-add the default view current active room behavior

* Remove repetitive check

* Add storing widget for comparing with (since we already store room id and the clientWidgetApi anyway)

* Update rendering logic to clear up remaining rendering bug (straight to call -> lobby of another room and joining call from that interface -> lobby of that previous room and joining was leading to duplication of the user in lobbies. This was actually from listening to and acknowledging hangups from the viewed widget in CallProvider)

* Prevent null rooms from ever rendering

* This seems to manage the hangup state with the status bar button well enough that black screens should never be encountered

* Remove viewed room setting here and pass the room to hang up (seems state doesn't update fast enough otherwise)

* Remove unused

* Properly declare new hangup method sig

* Seems to avoid almost all invalid states (hang up while viewing another lobby and hitting join seems to black screen, sets the active call as the previous active room id, but does join the viewed room correctly)

* Fix for cases where you're viewing a lobby and hang up your existing call and try to join lobby (was not rendering for the correct room id, but was joining the correct call prior)

* Re-add intended switching behavior

* More correct filter (viewedRoom can return false on that compare in some cases)

* Seems to shore up the remaining state issues with the status bar hangup

* Fix formatting

* In widget hang up button should be handled correct now

* Solves the CHCH sequence issue, CLJH remains

* Fixes CLJH, found CCH

* Solves CCH. Looks like CLCH left

* A bit of an abomination, but adds a state counter to iteratively handle the diverse potential states (where a user can join from the nav bar or the join button, hang up from either as well, and account for the juggling iframes)
Black screens shouldn't be occurring now.

* Fix dependency array

* Technically corrects the hangup button in the widget, should be more precise though

* Bind the on messaging iframe for easier access in hangup/join handling

* Far cleaner and more sensible handling of the call window... I just really don't like the idea of sending a click event, but right now the element-call code treats preload/skipLobby hangups (sent from our end) as if they had no lobby at all and thus black screens. Other implementation was working around that without just sending a click event on the iframe's hangup button.

* Fixes a bug where if you left a call then went to a lobby and joined it didn't update the actual activeCallRoomId

* Fixes complaints of null contentDocument in iframe

* Update to use new icons (thank you)

* Remove unneeded prop

* Re-arrange more options and add checks for each option to see if it is a call room (probably should manage a state to see if a header is already on screen and provide a slightly modified visual based on that for call rooms)

* Invert icons to show the state instead of the action they will perform (more visual clarity)

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomCallNavStatus.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavItem.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavUser.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavUser.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room-nav/RoomNavUser.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/pages/client/space/Space.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/call/CallView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/call/CallView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/call/CallView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomView.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* adjust room header for calling

* Remove No Active Call text when not in a call

* update element-call version

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Update src/app/features/room/RoomViewHeader.tsx

Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>

* Revert most changes to Space.tsx

* Show call room even if category is collapsed

* changes to RoomNavItem, RoomNavUser and add useCallMembers

* Rename file, sprinkle in the magic one line for matrixRTCSession. and remove comment block

* swap userId to callMembership as a prop and add a nullchecked userId that uses the membership sender

* update references to use callMembership instead

* Simplify RoomNavUser

Discard future functionality since it probably won't exist in time for merging this PR

* Simplify RoomViewHeader.tsx

Remove unused UI elements that don't have implemented functionality. Replace custom function for checking if a room is direct with a standard hook.

* Update Room.tsx to accomodate restructuring of Room, RoomView and CallView

* Update RoomView.tsx to accomodate restructuring of Room, RoomView and CallView

* Update CallView.tsx to accomodate restructuring of Room, RoomView and CallView + suggested changes

* add call related permissions to room permissions

* bump element call to 0.16.3, apply cinny theme to element call ui, replace element call lobby (backup iframe) with custom ui and only use element call for the in-call ui

* update text spacing

* redo roomcallnavstatus ui, force user preferred mute/video states when first joining calls, update variable names and remove unnecessary logic

* set default mic state to enabled

* clean up ts/eslint errors

* remove debug logs

* format using prettier rules from project prettierrc

* fix: show call nav status while active call is ongoing

* fix: clean up call nav/call view console warnings

* fix: keep call media controls visible before joining

* fix: restore header icon button fill behavior

Fixes regression from b074d421b66eb4d8b600dfa55b967e6c4f783044.

* style: blend header and room input button styles in call nav

* fix page header background color on room view header

* fix: permissions and room icon resolution (#2)

* Initialize call state upon room creation for call rooms, remove subsequent useless permission

* handle case of missing call permissions

* use call icon for room item summary when room is call room

* replace previous icon src resolution function with a more robust approach

* replace usages of previous icon resolution function with new implementation

* fix room name not updating for a while when changed

* set up framework for room power level overrides upon room creation

* override join call permission to all members upon room creation

* fix broken usages of RoomIcon

* remove unneeded import

* remove unnecessary logic

* format with prettier

* feat: show connected/connecting call status

* fix: preserve navigation context when opening non-call rooms

* fix: reset room name state when room instance changes

* feat: Disable webcam by default using callIntent='audio'

* Add channel type selecor

* Add option for voice rooms, which for now sets the default selected
option in the creation modal

* Add proper support for room selection from the enu

* Move enums to `types.ts` and change icons selection to use
`getRoomIconSrc`

* fix: group duplicate conditions into one

* fix: typo

* refactor: rename kind/voice to access/type and simplify room creation

- rename CreateRoomVoice to CreateRoomType and modal voice state to type
- rename CreateRoomKind to CreateRoomAccess and KindSelector to AccessSelector
- propagate access/defaultAccess through create room and create space forms
- set voice room power levels via createRoom power_level_content_override

* refactor: unify join rule icon mapping and update call/space icons

- bump folds from 2.5.0 to 2.6.0
- replace separate room/space join-rule icon hooks with useJoinRuleIcons(roomType)
- route join-rule icons through getRoomIconSrc for consistent room type handling
- simplify getRoomIconSrc by removing the locked override path
- use VolumeHighGlobe for public call rooms and VolumeHighLock for private call rooms

* chore(deps): bump matrix-widget-api to 1.17 and remove react-sdk-module-api

* fix: adapt SmallWidget to matrix-widget-api 1.17.0 API

* fix: render call room chat only when chat panel is open

* fix(permissions): show call settings permissions only for call rooms

* refactor: remove redundant room-nav props/guards and minor naming cleanup

* fix: use PhoneDown icon for hang up action

* chore(hooks): remove unused useStateEvents hook

* fix(room): enable members drawer toggle in desktop call rooms

- show filled User icon when the drawer is open

* Revert "fix: adapt SmallWidget to matrix-widget-api 1.17.0 API"

This reverts commit a4c34eff8a53669afa095be41b26f3e54adab0ed.

* fix: semi-revert matrix-widget-api 1.17 bump and migrate to 1.13 API

* fix(call): wait for Element Call contentLoaded before widget handshake

- fixes not working on firefox

* fix missing imports

* improve create room type design and add beta badge for voice room

* add beta badge for voice room in space lobby

* fix create room modal title

* pass missing roomType param to roomicon component

* add roomtype

* Add deafen functionality (#2695)

* feat:(deafen functionality)

* feat:(reworked voice controls for deafen)

* ref:(use muted instead of volume for deafen)

* fix:(backpedal audio_enabled rename)

* ref:(renaming of deafened vars)

* add stack avatar component

* add call status bar - WIP

* remove call status from navigation drawer

* fix deprecated method use in use call members hook

* render new call status bar

* move call widget driver to plugins

* remove old status bar usage from navigation drawer

* add call session and joined hook

* remove unknown changes

* upgrade widget api

* add element call embed plugin

* remove unknown change

* add call embed atom

* add call embed hooks and context

* add call embed provider

* replace old call implementation

* stop joining other call on second click if already in a call

* refactor embed placement hook

* add merge border prop to sequence card

* add call preferences

* add prescreen to call view - WIP

* prevent joining new call if already in call

* make call layout adaptive

* render call chat as right panel

* show call members in prescreen

* render call join leave event in timeline

* remove unknown rewrite in docker-nginx file

* render call event without hidden event enable

---------

Co-authored-by: Gigiaj <gigiaboone@yahoo.com>
Co-authored-by: Jaggar <18173108+GigiaJ@users.noreply.github.com>
Co-authored-by: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
Co-authored-by: Gimle Larpes <gimlelarpes@gmail.com>
Co-authored-by: YoJames2019 <jamesclark1700@gmail.com>
Co-authored-by: YoJames2019 <yobiscuit0@gmail.com>
Co-authored-by: hazre <mail@haz.re>
Co-authored-by: haz <37149950+hazre@users.noreply.github.com>
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Co-authored-by: James <49845975+YoJames2019@users.noreply.github.com>
Co-authored-by: James Reilly <jreilly1821@gmail.com>
Co-authored-by: Tymek <vonautymek@gmail.com>
Co-authored-by: Thedustbuster <92692948+Thedustbustr@users.noreply.github.com>
2026-03-07 18:03:32 +11:00
renovate[bot]
730670cf52
chore(deps): update actions/setup-node action to v6.3.0 (#2727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 20:10:28 +11:00
Krishan
c5c8703699
Update deploy PR workflow name and run name (#2728) 2026-03-04 20:07:16 +11:00
Krishan
2bd1570d6b
Update deploy PR workflow name to include PR number (#2726) 2026-03-04 19:30:36 +11:00
dependabot[bot]
68b6a09697
Bump dawidd6/action-download-artifact from 15 to 16 (#2719)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 15 to 16.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](fe9d59ce33...2536c51d3d)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '16'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 19:08:23 +11:00
dependabot[bot]
7b52c921d5
Bump actions/upload-artifact from 6.0.0 to 7.0.0 (#2718)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6.0.0 to 7.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6.0.0...v7.0.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-04 18:50:22 +11:00
Krishan
bb8b9ab6da
Add more docker related action checks (#2724)
* Pin all the action deps to SHA

* Add more docker related action checks

* Limit Docker build platforms to linux/amd64

Updated Docker build action to target only linux/amd64 platform.
2026-03-04 18:31:54 +11:00
Krishan
971f312b46
Pin all the action deps to SHA (#2725) 2026-03-04 12:31:36 +11:00
Tulir Asokan
e0d5c63dc5
Fix invalid matrix.to event link generation (#2717) 2026-03-03 20:12:45 +11:00
renovate[bot]
85fcbd84fe
chore(deps): update thollander/actions-comment-pull-request from 2.5.0 to 3.0.1 (#2698)
* chore(deps): update thollander/actions-comment-pull-request digest to e4a76dd

* pin to v3.0.1

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Krishan <33421343+kfiven@users.noreply.github.com>
2026-02-24 18:29:40 +11:00
dependabot[bot]
221bc04754
Bump dawidd6/action-download-artifact from 11 to 15 (#2694)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 11 to 15.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](ac66b43f0e...fe9d59ce33)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '15'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 17:54:57 +11:00
195 changed files with 11065 additions and 3906 deletions

View file

@ -0,0 +1,127 @@
labels: ["needs-confirmation"]
body:
- type: markdown #add faqs in future
attributes:
value: |
> [!IMPORTANT]
> Please read through [the Discussion rules](https://github.com/cinnyapp/cinny/discussions/2653) and check for both existing [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc) prior to opening a new Discussion.
- type: markdown
attributes:
value: "# Issue Details"
- type: textarea
attributes:
label: Issue Description
description: |
Provide a detailed description of the issue. Include relevant information, such as:
- The feature or configuration option you encounter the issue with.
- Screenshots, screen recordings, or other supporting media (as needed).
- If this is a regression of an existing issue that was closed or resolved, please include the previous item reference (Discussion, Issue, PR, commit) in your description.
placeholder: |
When I try to send a message in a room, the message doesn't appear in the timeline.
OR
The application crashes when I click on the settings button.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: |
Describe how you expect Cinny to behave in this situation.
placeholder: |
I expected the message to appear in the room timeline immediately after sending.
OR
The settings panel should open smoothly without any crashes.
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: |
Describe how Cinny actually behaves in this situation. If it is not immediately obvious how the actual behavior differs from the expected behavior described above, please be sure to mention the deviation specifically.
placeholder: |
The application freezes for 3 seconds and then shows a white screen.
validations:
required: true
- type: textarea
attributes:
label: Reproduction Steps
description: |
Provide a detailed set of step-by-step instructions for reproducing this issue.
placeholder: |
1. Open Cinny and log in to my account
2. Navigate to the #general room
3. Type a message in the message box
4. Press Enter to send
5. Notice that the message doesn't appear in the timeline
validations:
required: true
- type: textarea
attributes:
label: Environment
description: |
Please provide information about your environment. Include the following:
- OS:
- Browser:
- Cinny Web Version: (app.cinny.in or self hosted)
- Cinny desktop Version: (appimage or deb or flatpak)
- Matrix Homeserver:
placeholder: |
- OS: Windows 11
- Browser: Chrome 120.0.6099.109
- Cinny Web Version: 3.2.0 (app.cinny.in or self hosted)
- Cinny desktop Version: 3.2.0 (appimage or deb or flatpak)
- Matrix Homeserver: matrix.org (Synapse 1.97.0)
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: |
If applicable, add browser console logs to help explain your problem.
**To get browser console logs:**
- Chrome/Edge: Press F12 → Console tab
- Firefox: Press F12 → Console tab
- Safari: Develop → Show Web Inspector → Console
Please wrap large log outputs in code blocks with triple backticks (```).
placeholder: |
```
Error: Failed to send message
at MessageComposer.sendMessage (composer.js:245)
at HTMLButtonElement.onClick (composer.js:189)
TypeError: Cannot read property 'content' of undefined
at RoomTimeline.render (timeline.js:567)
```
render: shell
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: |
Add any other context about the problem here (e.g., when did this start happening, does it happen on different homeservers, etc.)
placeholder: |
- This started happening after I updated to version 3.2.0
- It only happens in encrypted rooms, not in public rooms
- I've tried on both Firefox and Chrome with the same result
- It works fine on my phone using the same account
- This happens on all homeservers I've tested (matrix.org, mozilla.org)
validations:
required: false
- type: markdown
attributes:
value: |
# User Acknowledgements
> [!TIP]
> Use these links to review the existing Cinny [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc).
- type: checkboxes #add faqs in future
attributes:
label: "I acknowledge that:"
options:
- label: I have searched the Cinny repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
required: true
- label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines.
required: true

View file

@ -1,57 +0,0 @@
name: 🐞 Bug Report
description: Report a bug
body:
- type: markdown
attributes:
value: |
## First of all
1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
2. Make sure Cinny is up to date.
3. Make sure it's an issue with Cinny and not something else you are using.
4. Remember to be friendly.
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear description of what the bug is. Include screenshots if applicable.
placeholder: Bug description
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Steps to reproduce the behavior.
placeholder: |
1. Go to ...
2. Click on ...
3. See error
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear description of what you expected to happen.
- type: textarea
id: info
attributes:
label: Platform and versions
description: "Provide OS, browser and Cinny version with your Homeserver."
placeholder: |
1. OS: [e.g. Windows 10, MacOS]
2. Browser: [e.g. chrome 99.5, firefox 97.2]
3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
4. Matrix homeserver: [e.g. matrix.org]
render: shell
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

View file

@ -1,4 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Matrix Chat
url: https://matrix.to/#/#cinny:matrix.org
about: Ask questions and talk to other Cinny users and the maintainers
- name: Features, Bug Reports, Questions
url: https://github.com/cinnyapp/cinny/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about features or behavior.

View file

@ -1,33 +0,0 @@
name: 💡 Feature Request
description: Suggest an idea
body:
- type: textarea
id: problem
attributes:
label: Describe the problem
description: A clear description of the problem this feature would solve
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: A clear description of what change you would like
placeholder: "I would like to..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: "Any alternative solutions you've considered"
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

9
.github/ISSUE_TEMPLATE/preapproved.md vendored Normal file
View file

@ -0,0 +1,9 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**

View file

@ -1,22 +1,9 @@
<!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
<!-- Please include a summary of the change. -->
Fixes #
#### Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
### Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have read and understood the [Contribution policy](https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md).

3
.github/SECURITY.md vendored
View file

@ -1,3 +0,0 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**

14
.github/renovate.json vendored
View file

@ -1,10 +1,22 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":dependencyDashboardApproval"],
"extends": [
"config:recommended",
":dependencyDashboardApproval",
":semanticCommits",
"group:monorepos"
],
"labels": ["Dependencies"],
"rebaseWhen": "conflicted",
"packageRules": [
{
"matchUpdateTypes": ["lockFileMaintenance"]
},
{
"matchPackageNames": ["slate", "slate-dom", "slate-history", "slate-react"]
},
{
"matchPackageNames": ["linkifyjs", "linkify-react"]
}
],
"lockFileMaintenance": {

View file

@ -12,12 +12,12 @@ jobs:
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@v6.2.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.13.1
cache: 'npm'
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: preview
path: dist
@ -33,7 +33,7 @@ jobs:
- name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr
path: ./pr.txt

View file

@ -12,7 +12,7 @@ jobs:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@v2.6.1
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret

View file

@ -1,4 +1,5 @@
name: Deploy PR to Netlify
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
on:
workflow_run:
@ -15,16 +16,22 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Output pr number
- name: Validate and output pr number
id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
run: |
PR_ID=$(<pr.txt)
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
exit 1
fi
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
- name: Download artifact
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@ -32,7 +39,7 @@ jobs:
path: dist
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
@ -41,16 +48,13 @@ jobs:
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1
- name: Comment preview on PR
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
pr_number: ${{ steps.pr.outputs.id }}
comment_tag: ${{ steps.pr.outputs.id }}
number: ${{ steps.pr.outputs.id }}
message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️

View file

@ -10,11 +10,54 @@ on:
jobs:
docker-build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
- name: Build Docker image
uses: docker/build-push-action@v6.19.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to Docker Hub #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
continue-on-error: true
- name: Login to the Github Container registry #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
ajbura/cinny
ghcr.io/${{ github.repository }}
- name: Build Docker image (no push)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Show Docker images
run: docker images

View file

@ -14,9 +14,9 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Optional inputs, can be deleted safely if you are happy with default values.

View file

@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@v6.2.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.13.1
cache: 'npm'
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
@ -24,7 +24,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Dev deploy ${{ github.sha }}'

15
.github/workflows/pr-title.yml vendored Normal file
View file

@ -0,0 +1,15 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- edited
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@v6.2.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24.13.1
cache: 'npm'
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
@ -23,7 +23,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
@ -52,45 +52,46 @@ jobs:
gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ github.ref_name }}
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image:
name: Push Docker image to Docker Hub, ghcr
name: Push Docker image to Docker Hub, GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to Docker Hub
uses: docker/login-action@v3.7.0
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:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Container registry
uses: docker/login-action@v3.7.0
- name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@v5.10.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v6.19.2
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/arm64

3
.husky/pre-commit Normal file
View file

@ -0,0 +1,3 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged

1
.node-version Normal file
View file

@ -0,0 +1 @@
24.13.1

View file

@ -3,4 +3,4 @@ node_modules
package.json
package-lock.json
LICENSE
README.md
harness

View file

@ -2,43 +2,162 @@
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
This document describes the process of contributing to Cinny. It is intended
for anyone considering opening an **issue**, **discussion** or **pull request**.
For people who are interested in developing Cinny and technical details behind
it, please check out our ["Developing Cinny"](HACKING.md) document as well.
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
> And if you like the project, but just don't have time to contribute, that's fine.
> There are other easy ways to support the project and show your
> appreciation, which we would also be very happy about:
>
> - Star the project
> - Tweet about it (tag @cinnyapp)
> - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues
> - [Donate to us](https://cinny.in/#sponsor)
## Bug reports
## The Critical Rule
Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/ajbura/cinny/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
**The most important rule: you must understand your code.** If you can't
explain what your changes do and how they interact with the greater system, do not contribute to this project.
## Pull requests
## AI Usage
The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
## Quick Guide
### I'd like to contribute
> ### Legal Notice
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
>
> When contributing to this project, you must agree that you have authored 100%
> of the content, that you have the necessary rights to the content and that
> the content you contribute may be provided under the project license. You will
> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request.
**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap.
[All issues are actionable](#issues-are-actionable). Pick one and start
working on it. Thank you. If you need help or guidance, comment on the issue.
Issues that are extra friendly to new contributors are tagged with
["contributor friendly"].
**Please use clean, concise titles for your pull requests.** We use commit squashing, so the final commit in the dev branch will carry the title of the pull request. For easier sorting in changelog, start your pull request titles using one of the verbs "Add", "Change", "Remove", or "Fix" (present tense).
["contributor friendly"]: https://github.com/cinnyapp/cinny/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22contributor%20friendly%22
[sign the cla]: https://github.com/cinnyapp/cla
[why CLA?]: https://github.com/cinnyapp/cla#but-why
Example:
### I have a bug! / Something isn't working
|Not ideal|Better|
|---|----|
|Fixed markAllAsRead in RoomTimeline|Fix read marker when paginating room timeline|
First, search the issue tracker and discussions for similar issues. Tip: also
search for [closed issues] and [discussions] — your issue might have already
been fixed!
It is not always possible to phrase every change in such a manner, but it is desired.
> [!NOTE]
>
> If there is an _open_ issue or discussion that matches your problem,
> **please do not comment on it unless you have valuable insight to add**.
>
> GitHub has a very _noisy_ set of default notification settings which
> sends an email to _every participant_ in an issue/discussion every time
> someone adds a comment. Instead, use the handy upvote button for discussions,
> and/or emoji reactions on both discussions and issues, which are a visible
> yet non-disruptive way to show your support.
**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable.
If your issue hasn't been reported already, open an ["Issue Triage"] discussion
and make sure to fill in the template **completely**. They are vital for
maintainers to figure out important details about your setup.
Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it.
> [!WARNING]
>
> A _very_ common mistake is to file a bug report either as a Q&A or a Feature
> Request. **Please don't do this.** Otherwise, maintainers would have to ask
> for your system information again manually, and sometimes they will even ask
> you to create a new discussion because of how few detailed information is
> required for other discussion types compared to Issue Triage.
>
> Because of this, please make sure that you _only_ use the "Issue Triage"
> category for reporting bugs — thank you!
**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
[closed issues]: https://github.com/cinnyapp/cinny/issues?q=is%3Aissue%20state%3Aclosed
[discussions]: https://github.com/cinnyapp/cinny/discussions?discussions_q=is%3Aclosed
["issue triage"]: https://github.com/cinnyapp/cinny/discussions/new?category=issue-triage
## Helpful links
- [BEM methodology](http://getbem.com/introduction/)
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
### I have an idea for a feature
Like bug reports, first search through both issues and discussions and try to
find if your feature has already been requested. Otherwise, open a discussion
in the ["Feature Requests, Ideas"] category.
["feature requests, ideas"]: https://github.com/cinnyapp/cinny/discussions/new?category=feature-requests-ideas
### I've implemented a feature
1. If there is an issue for the feature, open a pull request straight away.
2. If there is no issue, open a discussion and link to your branch.
3. If you want to live dangerously, open a pull request and
[hope for the best](#pull-requests-implement-an-issue).
### I have a question which is neither a bug report nor a feature request
Open an [Q&A discussion], or join our [Matrix Space] and ask away in the
`Cinny Support` room.
Do not use other rooms to ask for help as our rooms are mostly specific
topic only. If you do ask a question there, you will be redirected
to `Cinny Support` room instead.
> [!NOTE]
> If your question is about a missing feature, please open a discussion under
> the ["Feature Requests, Ideas"] category. If Cinny is behaving
> unexpectedly, use the ["Issue Triage"] category.
>
> The "Q&A" category is strictly for other kinds of discussions and do not
> require detailed information unlike the two other categories, meaning that
> maintainers would have to spend the extra effort to ask for basic information
> if you submit a bug report under this category.
>
> Therefore, please **pay attention to the category** before opening
> discussions to save us all some time and energy. Thank you!
[q&a discussion]: https://github.com/cinnyapp/cinny/discussions/new?category=q-a
[matrix space]: https://matrix.to/#/#cinny:matrix.org
## General Patterns
### Issues are Actionable
> [!NOTE]
> This is WIP and only issue with number greater than #2876 are actionable.
The Cinny [issue tracker](https://github.com/cinnyapp/cinny/issues)
is for _actionable items_.
Unlike some other projects, Cinny **does not use the issue tracker for
discussion or feature requests**. Instead, we use GitHub
[discussions](https://github.com/cinnyapp/cinny/discussions) for that.
Once a discussion reaches a point where a well-understood, actionable
item is identified, it is moved to the issue tracker. **This pattern
makes it easier for maintainers or contributors to find issues to work on
since _every issue_ is ready to be worked on.**
If you are experiencing a bug and have clear steps to reproduce it, please
open an issue. If you are experiencing a bug but you are not sure how to
reproduce it or aren't sure if it's a bug, please open a discussion.
If you have an idea for a feature, please open a discussion.
### Pull Requests Implement an Issue
Pull requests should be associated with a previously accepted issue.
**If you open a pull request for something that wasn't previously discussed,**
it may be closed or remain stale for an indefinite period of time. I'm not
saying it will never be accepted, but the odds are stacked against you.
Issues tagged with "feature" represent accepted, well-scoped feature requests.
If you implement an issue tagged with feature as described in the issue, your
pull request will be accepted with a high degree of certainty.
> [!NOTE]
>
> **Pull requests are NOT a place to discuss feature design.** Please do
> not open a WIP pull request to discuss a feature. Instead, use a discussion
> and link to your branch.

View file

@ -11,7 +11,7 @@ RUN npm run build
## App
FROM nginx:1.29.5-alpine
FROM nginx:1.31.5-alpine
COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf

60
HACKING.md Normal file
View file

@ -0,0 +1,60 @@
# Developing Cinny
> [!TIP]
> We recommend using a version manager as versions change very quickly.
> You will likely need to switch between multiple Node.js versions based
> on the needs of different projects you're working on. [NVM-windows]
> on Windows and [nvm] on Linux/macOS are pretty good choices. Recommended
> nodejs version is Krypton LTS (v24.13.1).
[nvm-windows]: https://github.com/coreybutler/nvm-windows#installation--upgrades
[nvm]: https://github.com/nvm-sh/nvm
Execute the following commands to start a development server:
```sh
npm ci # Installs all dependencies
npm start # Serve a development version
```
To build the app:
```sh
npm run build # Compiles the app into the dist/ directory
```
To commit changes:
```sh
npm run commit
```
## Running with Docker
This repository includes a Dockerfile, which builds the application from
source and serves it with Nginx on port 80. To use this locally, you can
build the container like so:
```
docker build -t cinny:latest .
```
You can then run the container you've built with a command similar to this:
```
docker run -p 8080:80 cinny:latest
```
This will forward your `localhost` port 8080 to the container's port 80.
You can visit the app in your browser by navigating to `http://localhost:8080`.
## Code formatting
We use [ESLint](https://eslint.org/) for clean and stylistically
consistent code syntax, so make sure your pull request follow it.
## Helpful links
- [BEM methodology](http://getbem.com/introduction/)
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)

View file

@ -12,27 +12,64 @@
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
A Matrix client focusing primarily on simple, elegant and secure interface.
The main goal is to have an instant messaging application that is easy on
people and has a modern touch.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
> [!IMPORTANT]
We are currently in the process of [replacing] the matrix-js-sdk with our
own SDK. As a result, we will not be accepting any pull requests until
further notice. Thank you for your understanding.
[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Getting started
The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken.
The web app is available at [app.cinny.in] and gets updated on each new
release. The `dev` branch is continuously deployed at [dev.cinny.in]
but keep in mind that it could have things broken.
You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
You can also download our desktop app from the [cinny-desktop repository].
[app.cinny.in]: https://app.cinny.in
[dev.cinny.in]: https://dev.cinny.in
[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
## Contributing and Developing
If you have any ideas, issues, etc. regarding Cinny, or would like to
contribute to Cinny through pull requests, please check out our
["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like
to get involved with Cinny's development as well should also read the
["Developing Cinny"](HACKING.md) document for more technical details.
## Self-hosting
To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny).
To host Cinny on your own, simply download the tarball from
[GitHub releases], and serve the files from `dist/` using your preferred
webserver. Alternatively, you can just pull the docker image from
[DockerHub] or [GitHub Container Registry].
* The default homeservers and explore pages are defined in [`config.json`](config.json).
* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile).
* If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* You need to set up redirects to serve the assests. Example configurations;
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
[caddy](contrib/caddy/caddyfile).
* If you have trouble configuring redirects you can
[enable hash routing](config.json#L35) — the url in the browser will have
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/`
instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`.
* To deploy on subdirectory, you need to rebuild the app youself after
updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then
set `base: '/app'`.
[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest
[DockerHub]: https://hub.docker.com/r/ajbura/cinny
[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny
<details><summary><b>PGP Public Key to verify tarball</b></summary>
@ -81,31 +118,11 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
```
</details>
## Local development
> [!TIP]
> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Krypton LTS (v24.13.1).
## License
Cinny Project
Copyright © 2024–present Ajay Bura
https://cinny.in
Execute the following commands to start a development server:
```sh
npm ci # Installs all dependencies
npm start # Serve a development version
```
To build the app:
```sh
npm run build # Compiles the app into the dist/ directory
```
### Running with Docker
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To
use this locally, you can build the container like so:
```
docker build -t cinny:latest .
```
You can then run the container you've built with a command similar to this:
```
docker run -p 8080:80 cinny:latest
```
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
Cinny is licensed under the GNU Affero General Public License,
Version 3 of the License (AGPL-3.0-only).
You may obtain a copy of the License at https://www.gnu.org/licenses/agpl-3.0.html

View file

@ -1,3 +1,3 @@
export default {
base: '/',
base: process.env.CINNY_BASE ?? '/',
};

View file

@ -1,33 +1,17 @@
{
"defaultHomeserver": 1,
"homeserverList": [
"converser.eu",
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny-space:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"spaces": ["#gamecult:gamecult.org"],
"rooms": [
"#cinny:matrix.org",
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",
"#pcapdroid:matrix.org",
"#gentoo:matrix.org",
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
"#gentoo:matrix.org"
],
"servers": [ "matrix.org", "mozilla.org", "unredacted.org" ]
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
},
"hashRouter": {

View 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
View 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);

View 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); });

View 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
View 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
View 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
View 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" ]

2865
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.10.5",
"version": "4.12.8",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@ -10,11 +10,25 @@
"scripts": {
"start": "vite",
"build": "vite build",
"lint": "yarn check:eslint && yarn check:prettier",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"prepare": "husky install",
"commit": "git-cz",
"bump": "node scripts/update-version.js",
"test": "vitest run"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
"*": "prettier --ignore-unknown --write"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"keywords": [],
"author": "Ajay Bura",
@ -43,18 +57,19 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.6.1",
"folds": "2.7.2",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"i18next-http-backend": "4.0.3",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-js-sdk": "38.2.0",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.16.1",
"millify": "6.1.0",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
@ -67,15 +82,19 @@
"react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.30.3",
"sanitize-html": "2.12.1",
"slate": "0.112.0",
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
"ua-parser-js": "1.0.35"
"react-router-dom": "6.30.6",
"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",
"@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",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
@ -87,12 +106,13 @@
"@types/react": "18.2.39",
"@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.9.0",
"@types/sanitize-html": "2.16.1",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.29.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0",
@ -100,11 +120,14 @@
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"husky": "9.1.7",
"lint-staged": "16.3.2",
"prettier": "2.8.1",
"typescript": "4.9.4",
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4"
"vite-plugin-top-level-await": "1.4.4",
"vitest": "1.6.0"
}
}

BIN
public/sound/call.ogg Normal file

Binary file not shown.

48
scripts/update-version.js Normal file
View file

@ -0,0 +1,48 @@
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const version = process.argv[2];
if (!version) {
console.error("Version argument missing");
process.exit(1);
}
const root = path.resolve(__dirname, "..");
const newVersionTag = `v${version}`;
// Update package.json + package-lock.json safely
execSync(`npm version ${version} --no-git-tag-version`, {
cwd: root,
stdio: "inherit",
});
console.log(`Updated package.json and package-lock.json → ${version}`);
// Update UI version references
const files = [
"src/app/features/settings/about/About.tsx",
"src/app/pages/auth/AuthFooter.tsx",
"src/app/pages/client/WelcomePage.tsx",
];
files.forEach((filePath) => {
const absPath = path.join(root, filePath);
if (!fs.existsSync(absPath)) {
console.warn(`File not found: ${filePath}`);
return;
}
const content = fs.readFileSync(absPath, "utf8");
const updated = content.replace(/v\d+\.\d+\.\d+/g, newVersionTag);
fs.writeFileSync(absPath, updated);
console.log(`Updated ${filePath} → ${newVersionTag}`);
});

View file

@ -0,0 +1,405 @@
/* eslint-disable jsx-a11y/media-has-caption */
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import FocusTrap from 'focus-trap-react';
import {
Avatar,
Box,
Button,
color,
config,
Dialog,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
toRem,
} from 'folds';
import {
EventTimelineSetHandlerMap,
EventType,
RelationType,
Room,
RoomEvent,
} from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
useCallMemberSoundSync,
useCallStart,
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getStateEvent } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
type IncomingCallInfo = {
room: Room;
sender: string;
senderTs: number;
lifetime: number;
intent?: string;
notificationType: RTCNotificationType;
refEventId: string;
};
type IncomingCallProps = {
dm: boolean;
info: IncomingCallInfo;
onIgnore: () => void;
onAnswer: (room: Room, video: boolean) => void;
onReject: (room: Room, eventId: string) => void;
};
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported;
const { room } = info;
const audioRef = useRef<HTMLAudioElement>(null);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
const avatarUrl = roomAvatar
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const session = useCallSession(room);
useCallMembersChange(
session,
useCallback(
(members) => {
if (members.length === 0) {
onIgnore();
}
},
[onIgnore]
)
);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
}, []);
useEffect(() => {
if (info.notificationType === 'ring') {
playSound();
}
}, [playSound, info.notificationType]);
return (
<>
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => onIgnore(),
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
<Avatar size="500" className={CallAvatarAnimation}>
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="400"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
</Box>
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
<Text size="H3" align="Center" truncate>
{roomName}
</Text>
<Text size="T300" align="Center">
Incoming Call
</Text>
</Box>
</Box>
{!livekitSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your homeserver does not support calling.
</Text>
)}
{!webRTCSupported && (
<Text
style={{ margin: 'auto', color: color.Critical.Main }}
size="L400"
align="Center"
>
Your browser does not support WebRTC, which is required for calling.
</Text>
)}
<Box direction="Column" gap="300">
<Button
style={{ flexGrow: 1 }}
variant="Success"
size="400"
radii="400"
onClick={() => onAnswer(room, info.intent === 'video')}
before={
<Icon
size="200"
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
filled
/>
}
disabled={!canAnswer}
>
<Text as="span" size="B400">
Answer
</Text>
</Button>
<Button
style={{ flexGrow: 1 }}
variant="Success"
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.Cross} filled />}
>
<Text as="span" size="B400">
{dm ? 'Reject' : 'Ignore'}
</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
<audio ref={audioRef} loop style={{ display: 'none' }}>
<source src={CallSound} type="audio/ogg" />
</audio>
</>
);
}
type IncomingCallListenerProps = {
callEmbed?: CallEmbed;
joined?: boolean;
};
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events.
// we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
if (room?.isCallRoom()) return;
if (event.isEncrypted()) {
if (!event.isBeingDecrypted()) {
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
}
await event.getDecryptionPromise();
}
if (
!room ||
event.getType() !== EventType.RTCNotification ||
event.getSender() === mx.getSafeUserId() ||
!data.liveEvent
) {
return;
}
const sender = event.getSender();
const content = event.getContent<IRTCNotificationContent>();
const senderTs =
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
const lifetime = Math.min(content.lifetime, 120000);
const notificationType = content.notification_type;
const relation =
event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined;
const refEventId = relation?.event_id;
const mention =
content['m.mentions']?.room ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
if (!hasCallPermission) return;
const info: IncomingCallInfo = {
room,
sender,
senderTs,
lifetime,
intent:
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
? content['m.call.intent']
: undefined,
notificationType,
refEventId,
};
setCallInfo(info);
},
[mx]
);
useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, handleTimelineEvent]);
const handleIgnore = useCallback(() => {
setCallInfo(undefined);
}, []);
const handleReject = useCallback(
(room: Room, eventId: string) => {
mx.sendEvent(room.roomId, EventType.RTCDecline, {
'm.relates_to': {
rel_type: RelationType.Reference,
event_id: eventId,
},
});
setCallInfo(undefined);
},
[mx]
);
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
startCall(room, { microphone: true, video, sound: true });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom]
);
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
return null;
}
return !joined && callInfo ? (
<IncomingCall
dm={dm}
info={callInfo}
onIgnore={handleIgnore}
onAnswer={handleAnswer}
onReject={handleReject}
/>
) : null;
}
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
useCallMemberSoundSync(embed);
useCallThemeSync(embed);
useCallHangupEvent(
embed,
useCallback(() => {
setCallEmbed(undefined);
}, [setCallEmbed])
);
return null;
}
type CallEmbedProviderProps = {
children?: ReactNode;
};
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null);
const joined = useCallJoined(callEmbed);
const selectedRoom = useSelectedRoom();
const chat = useAtomValue(callChatAtom);
const screenSize = useScreenSizeContext();
const chatOnlyView = chat && screenSize !== ScreenSize.Desktop;
const callVisible = callEmbed && selectedRoom === callEmbed.roomId && joined && !chatOnlyView;
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div
data-call-embed-container
style={{
visibility: callVisible ? undefined : 'hidden',
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '50%',
}}
ref={callEmbedRef}
/>
</CallEmbedContextProvider>
);
}

View file

@ -0,0 +1,45 @@
import FocusTrap from 'focus-trap-react';
import { as, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import React, { ReactNode } from 'react';
import { ModalWide } from '../styles/Modal.css';
import { stopPropagation } from '../utils/keyboard';
export type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type ImageOverlayProps = RenderViewerProps & {
viewer: boolean;
renderViewer: (props: RenderViewerProps) => ReactNode;
};
export const ImageOverlay = as<'div', ImageOverlayProps>(
({ src, alt, viewer, requestClose, renderViewer, ...props }, ref) => (
<Overlay {...props} ref={ref} open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src,
alt,
requestClose,
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)
);

View file

@ -16,34 +16,24 @@ import {
import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { getRoomIconSrc } from '../utils/room';
export type ExtraJoinRules = 'knock_restricted';
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
useMemo(
() => ({
[JoinRule.Invite]: Icons.HashLock,
[JoinRule.Knock]: Icons.HashLock,
knock_restricted: Icons.Hash,
[JoinRule.Restricted]: Icons.Hash,
[JoinRule.Public]: Icons.HashGlobe,
[JoinRule.Private]: Icons.HashLock,
[JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite),
[JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock),
knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
[JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
[JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public),
[JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private),
}),
[]
);
export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
useMemo(
() => ({
[JoinRule.Invite]: Icons.SpaceLock,
[JoinRule.Knock]: Icons.SpaceLock,
knock_restricted: Icons.Space,
[JoinRule.Restricted]: Icons.Space,
[JoinRule.Public]: Icons.SpaceGlobe,
[JoinRule.Private]: Icons.SpaceLock,
}),
[]
[roomType]
);
type JoinRuleLabels = Record<ExtendedJoinRules, string>;

View file

@ -2,43 +2,39 @@ import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
import { CreateRoomAccess } from './types';
export enum CreateRoomKind {
Private = 'private',
Restricted = 'restricted',
Public = 'public',
}
type CreateRoomKindSelectorProps = {
value?: CreateRoomKind;
onSelect: (value: CreateRoomKind) => void;
type CreateRoomAccessSelectorProps = {
value?: CreateRoomAccess;
onSelect: (value: CreateRoomAccess) => void;
canRestrict?: boolean;
disabled?: boolean;
getIcon: (kind: CreateRoomKind) => IconSrc;
getIcon: (access: CreateRoomAccess) => IconSrc;
};
export function CreateRoomKindSelector({
export function CreateRoomAccessSelector({
value,
onSelect,
canRestrict,
disabled,
getIcon,
}: CreateRoomKindSelectorProps) {
}: CreateRoomAccessSelectorProps) {
return (
<Box shrink="No" direction="Column" gap="100">
{canRestrict && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Restricted ? 'Primary' : 'SurfaceVariant'}
variant={value === CreateRoomAccess.Restricted ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Restricted}
onClick={() => onSelect(CreateRoomKind.Restricted)}
aria-pressed={value === CreateRoomAccess.Restricted}
onClick={() => onSelect(CreateRoomAccess.Restricted)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Restricted)} />}
after={value === CreateRoomKind.Restricted && <Icon src={Icons.Check} />}
before={<Icon size="400" src={getIcon(CreateRoomAccess.Restricted)} />}
after={value === CreateRoomAccess.Restricted && <Icon src={Icons.Check} />}
>
<Text size="H6">Restricted</Text>
<Text size="T300" priority="300">
@ -49,18 +45,18 @@ export function CreateRoomKindSelector({
)}
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Private ? 'Primary' : 'SurfaceVariant'}
variant={value === CreateRoomAccess.Private ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Private}
onClick={() => onSelect(CreateRoomKind.Private)}
aria-pressed={value === CreateRoomAccess.Private}
onClick={() => onSelect(CreateRoomAccess.Private)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Private)} />}
after={value === CreateRoomKind.Private && <Icon src={Icons.Check} />}
before={<Icon size="400" src={getIcon(CreateRoomAccess.Private)} />}
after={value === CreateRoomAccess.Private && <Icon src={Icons.Check} />}
>
<Text size="H6">Private</Text>
<Text size="T300" priority="300">
@ -70,18 +66,18 @@ export function CreateRoomKindSelector({
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomKind.Public ? 'Primary' : 'SurfaceVariant'}
variant={value === CreateRoomAccess.Public ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomKind.Public}
onClick={() => onSelect(CreateRoomKind.Public)}
aria-pressed={value === CreateRoomAccess.Public}
onClick={() => onSelect(CreateRoomAccess.Public)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomKind.Public)} />}
after={value === CreateRoomKind.Public && <Icon src={Icons.Check} />}
before={<Icon size="400" src={getIcon(CreateRoomAccess.Public)} />}
after={value === CreateRoomAccess.Public && <Icon src={Icons.Check} />}
>
<Text size="H6">Public</Text>
<Text size="T300" priority="300">

View file

@ -0,0 +1,75 @@
import React from 'react';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
import { CreateRoomType } from './types';
import { BetaNoticeBadge } from '../BetaNoticeBadge';
type CreateRoomTypeSelectorProps = {
value?: CreateRoomType;
onSelect: (value: CreateRoomType) => void;
disabled?: boolean;
getIcon: (type: CreateRoomType) => IconSrc;
};
export function CreateRoomTypeSelector({
value,
onSelect,
disabled,
getIcon,
}: CreateRoomTypeSelectorProps) {
return (
<Box shrink="No" direction="Column" gap="100">
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomType.TextRoom ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomType.TextRoom}
onClick={() => onSelect(CreateRoomType.TextRoom)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomType.TextRoom)} />}
after={value === CreateRoomType.TextRoom && <Icon src={Icons.Check} />}
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Chat Room
</Text>
<Text size="T300" priority="300" truncate>
- Messages, photos, and videos.
</Text>
</Box>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomType.VoiceRoom ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomType.VoiceRoom}
onClick={() => onSelect(CreateRoomType.VoiceRoom)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomType.VoiceRoom)} />}
after={value === CreateRoomType.VoiceRoom && <Icon src={Icons.Check} />}
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Voice Room
</Text>
<Text size="T300" priority="300" truncate>
- Live audio and video conversations.
</Text>
<BetaNoticeBadge />
</Box>
</SettingTile>
</SequenceCard>
</Box>
);
}

View file

@ -1,5 +1,6 @@
export * from './CreateRoomKindSelector';
export * from './CreateRoomAccessSelector';
export * from './CreateRoomAliasInput';
export * from './RoomVersionSelector';
export * from './utils';
export * from './AdditionalCreatorInput';
export * from './types';

View file

@ -0,0 +1,10 @@
export enum CreateRoomType {
TextRoom = 'text',
VoiceRoom = 'voice',
}
export enum CreateRoomAccess {
Private = 'private',
Restricted = 'restricted',
Public = 'public',
}

View file

@ -7,10 +7,10 @@ import {
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { CreateRoomKind } from './CreateRoomKindSelector';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix';
import { CreateRoomAccess } from './types';
export const createRoomCreationContent = (
type: RoomType | undefined,
@ -32,7 +32,7 @@ export const createRoomCreationContent = (
};
export const createRoomJoinRulesState = (
kind: CreateRoomKind,
access: CreateRoomAccess,
parent: Room | undefined,
knock: boolean
) => {
@ -40,13 +40,13 @@ export const createRoomJoinRulesState = (
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
};
if (kind === CreateRoomKind.Public) {
if (access === CreateRoomAccess.Public) {
content = {
join_rule: JoinRule.Public,
};
}
if (kind === CreateRoomKind.Restricted && parent) {
if (access === CreateRoomAccess.Restricted && parent) {
content = {
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
allow: [
@ -86,11 +86,23 @@ export const createRoomEncryptionState = () => ({
},
});
export const createRoomCallState = () => ({
type: 'org.matrix.msc3401.call',
state_key: '',
content: {},
});
export const createVoiceRoomPowerLevelsOverride = () => ({
events: {
[StateEvent.GroupCallMemberPrefix]: 0,
},
});
export type CreateRoomData = {
version: string;
type?: RoomType;
parent?: Room;
kind: CreateRoomKind;
access: CreateRoomAccess;
name: string;
topic?: string;
aliasLocalPart?: string;
@ -110,7 +122,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
initialState.push(createRoomParentState(data.parent));
}
initialState.push(createRoomJoinRulesState(data.kind, data.parent, data.knock));
if (data.type === RoomType.Call) {
initialState.push(createRoomCallState());
}
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
const options: ICreateRoomOpts = {
room_version: data.version,
@ -122,6 +138,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation,
data.additionalCreators
),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
initial_state: initialState,
};

View file

@ -169,12 +169,13 @@ export function RoomMentionAutocomplete({
<RoomIcon
size="50"
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
roomType={room.getType()}
filled
/>
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} space={room.isSpaceRoom()} />
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</Avatar>
}

View file

@ -157,10 +157,12 @@ const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): In
return children;
}
return node.childNodes.flatMap((child) => getInlineElement(child, processText));
const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
if (children.length === 0) return [{ text: '' }];
return children;
}
return [];
return [{ text: '' }];
};
const parseBlockquoteNode = (
@ -191,7 +193,7 @@ const parseBlockquoteNode = (
if (child.name === 'p') {
appendLine();
quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
quoteLines.push(getInlineElement(child, processText));
return;
}
@ -228,9 +230,13 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
children: [{ text }],
}));
const childCode = node.children[0];
const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
const attribs =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs : undefined;
const languageClass = attribs?.class;
const customLabel = attribs?.['data-label'];
const prefix = {
text: `${mdSequence}${customLabel ?? languageClass?.replace('language-', '') ?? ''}`,
};
const suffix = { text: mdSequence };
return [
{ type: BlockType.Paragraph, children: [prefix] },
@ -249,10 +255,67 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
},
];
};
const parseListNode = (
const parseListMarkdown = (
node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
processText: ProcessTextCallback,
depth = 0
): ParagraphElement[] => {
const md = isTag(node) && node.name === 'ul' ? '*' : '-';
const prefix = node.attribs['data-md'] ?? md;
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
const [digitOrChar] = prefix.match(/^[\da-zA-Z]/) ?? [];
const digit = digitOrChar ? parseInt(digitOrChar, 10) : undefined;
const lines: ParagraphElement[] = [];
let lineNo = digit === undefined || Number.isNaN(digit) ? digitOrChar ?? 1 : digit;
const pushLine = (line: InlineElement[]) => {
lines.push({
type: BlockType.Paragraph,
children: [
{
text: `${Array(depth + 1).join(' ')}${starOrHyphen ? `${starOrHyphen} ` : `${lineNo}. `}`,
},
...line,
],
});
if (typeof lineNo === 'string') {
lineNo = String.fromCharCode(lineNo.charCodeAt(0) + 1);
} else {
lineNo += 1;
}
};
node.children.forEach((child) => {
if (isText(child)) {
pushLine([{ text: processText(child.data) }]);
return;
}
if (isTag(child)) {
if (child.name === 'ul' || child.name === 'ol') {
lines.push(...parseListMarkdown(child, processText, depth + 1));
return;
}
if (child.name === 'li') {
child.children.forEach((c) => {
if (isTag(c) && (c.name === 'ul' || c.name === 'ol')) {
lines.push(...parseListMarkdown(c, processText, depth + 1));
return;
}
pushLine(getInlineElement(c, processText));
});
return;
}
}
pushLine(getInlineElement(child, processText));
});
return lines;
};
const parseListLines = (children: ChildNode[], processText: ProcessTextCallback) => {
const listLines: Array<InlineElement[]> = [];
let lineHolder: InlineElement[] = [];
@ -263,7 +326,7 @@ const parseListNode = (
lineHolder = [];
};
node.children.forEach((child) => {
children.forEach((child) => {
if (isText(child)) {
lineHolder.push({ text: processText(child.data) });
return;
@ -277,7 +340,7 @@ const parseListNode = (
if (child.name === 'li') {
appendLine();
listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
listLines.push(getInlineElement(child, processText));
return;
}
@ -286,24 +349,23 @@ const parseListNode = (
});
appendLine();
const mdSequence = node.attribs['data-md'];
if (mdSequence !== undefined) {
const prefix = mdSequence || '-';
const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
return listLines.map((lineChildren) => ({
type: BlockType.Paragraph,
children: [
{ text: `${starOrHyphen ? `${starOrHyphen} ` : `${prefix}. `} ` },
...lineChildren,
],
}));
return listLines;
};
const parseListNode = (
node: Element,
processText: ProcessTextCallback
): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
if (node.attribs['data-md'] !== undefined) {
return parseListMarkdown(node, processText);
}
const lines = parseListLines(node.childNodes, processText);
if (node.name === 'ol') {
return [
{
type: BlockType.OrderedList,
children: listLines.map((lineChildren) => ({
children: lines.map((lineChildren) => ({
type: BlockType.ListItem,
children: lineChildren,
})),
@ -314,7 +376,7 @@ const parseListNode = (
return [
{
type: BlockType.UnorderedList,
children: listLines.map((lineChildren) => ({
children: lines.map((lineChildren) => ({
type: BlockType.ListItem,
children: lineChildren,
})),
@ -325,7 +387,7 @@ const parseHeadingNode = (
node: Element,
processText: ProcessTextCallback
): HeadingElement | ParagraphElement => {
const children = node.children.flatMap((child) => getInlineElement(child, processText));
const children = getInlineElement(node, processText);
const headingMatch = node.name.match(/^h([123456])$/);
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
@ -388,7 +450,7 @@ export const domToEditorInput = (
appendLine();
children.push({
type: BlockType.Paragraph,
children: node.children.flatMap((child) => getInlineElement(child, processText)),
children: getInlineElement(node, processText),
});
return;
}

View file

@ -11,7 +11,7 @@ import {
} from '../../plugins/markdown';
import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
import { isUserId } from '../../utils/matrix';
export type OutputOptions = {
allowTextFormatting?: boolean;
@ -215,7 +215,7 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
if (node.name === '@room') {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}

View file

@ -241,15 +241,15 @@ export const getPointUntilChar = (
return targetPoint;
};
export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
export const getPrevWordRange = (editor: Editor): BaseRange | undefined => {
const { selection } = editor;
if (!selection || !Range.isCollapsed(selection)) return undefined;
const [cursorPoint] = Range.edges(selection);
const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
const wordStartPoint = getPointUntilChar(editor, cursorPoint, {
reverse: true,
match: (char) => char === ' ',
match: (char) => char === ' ' || char === '',
});
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
return wordStartPoint && Editor.range(editor, wordStartPoint, cursorPoint);
};
export const isEmptyEditor = (editor: Editor): boolean => {

View file

@ -389,6 +389,8 @@ export function MLocation({ content }: MLocationProps) {
const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
return (
<Box direction="Column" alignItems="Start" gap="100">
<Text size="T400">{geoUri}</Text>

View file

@ -111,6 +111,7 @@ export const PageHeroSection = style([
},
]);
export const PageContentCenter = style([
DefaultReset,
{

View file

@ -2,7 +2,7 @@ import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
import * as css from './RoomAvatar.css';
import { joinRuleToIconSrc } from '../../utils/room';
import { getRoomIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
type RoomAvatarProps = {
@ -44,13 +44,9 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
export const RoomIcon = forwardRef<
SVGSVGElement,
Omit<ComponentProps<typeof Icon>, 'src'> & {
joinRule: JoinRule;
space?: boolean;
joinRule?: JoinRule;
roomType?: string;
}
>(({ joinRule, space, ...props }, ref) => (
<Icon
src={joinRuleToIconSrc(Icons, joinRule, space || false) ?? Icons.Hash}
{...props}
ref={ref}
/>
>(({ joinRule, roomType, ...props }, ref) => (
<Icon src={getRoomIconSrc(Icons, roomType, joinRule)} {...props} ref={ref} />
));

View file

@ -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;

View file

@ -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');

View file

@ -17,6 +17,7 @@ export const SequenceCard = as<
firstChild,
lastChild,
outlined,
mergeBorder,
...props
},
ref
@ -24,7 +25,7 @@ export const SequenceCard = as<
<Box
as={AsSequenceCard}
className={classNames(
css.SequenceCard({ radii, outlined }),
css.SequenceCard({ radii, outlined, mergeBorder }),
ContainerColor({ variant }),
className
)}

View file

@ -11,7 +11,7 @@ export const SequenceCard = recipe({
},
borderStyle: 'solid',
borderWidth: outlinedWidth,
borderBottomWidth: 0,
selectors: {
'&:first-child, :not(&) + &': {
borderTopLeftRadius: [radii],
@ -20,7 +20,6 @@ export const SequenceCard = recipe({
'&:last-child, &:not(:has(+&))': {
borderBottomLeftRadius: [radii],
borderBottomRightRadius: [radii],
borderBottomWidth: outlinedWidth,
},
[`&[data-first-child="true"]`]: {
borderTopLeftRadius: [radii],
@ -74,6 +73,16 @@ export const SequenceCard = recipe({
},
},
},
mergeBorder: {
true: {
borderBottomWidth: 0,
selectors: {
'&:last-child, &:not(:has(+&))': {
borderBottomWidth: outlinedWidth,
},
},
},
},
},
defaultVariants: {
radii: '400',

View file

@ -0,0 +1,18 @@
import React from 'react';
import { as, Avatar } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
type StackedAvatarProps = {
radii?: '0' | '300' | '400' | '500' | 'Pill' | 'Inherit' | undefined;
};
export const StackedAvatar = as<'span', css.StackedAvatarVariants & StackedAvatarProps>(
({ size, variant, className, ...props }, ref) => (
<Avatar
size={size}
className={classNames(css.StackedAvatar({ size, variant }), className)}
{...props}
ref={ref}
/>
)
);

View file

@ -0,0 +1 @@
export * from './StackedAvatar';

View file

@ -0,0 +1,59 @@
import { ComplexStyleRule } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { color, config, ContainerColor, toRem } from 'folds';
const getVariant = (variant: ContainerColor): ComplexStyleRule => ({
outlineColor: color[variant].Container,
});
export const StackedAvatar = recipe({
base: {
backgroundColor: color.Surface.Container,
outlineStyle: 'solid',
selectors: {
'&:first-child': {
marginLeft: 0,
},
'button&': {
cursor: 'pointer',
},
},
},
variants: {
size: {
'200': {
marginLeft: toRem(-6),
outlineWidth: config.borderWidth.B300,
},
'300': {
marginLeft: toRem(-9),
outlineWidth: config.borderWidth.B400,
},
'400': {
marginLeft: toRem(-10.5),
outlineWidth: config.borderWidth.B500,
},
'500': {
marginLeft: toRem(-13),
outlineWidth: config.borderWidth.B600,
},
},
variant: {
Background: getVariant('Background'),
Surface: getVariant('Surface'),
SurfaceVariant: getVariant('SurfaceVariant'),
Primary: getVariant('Primary'),
Secondary: getVariant('Secondary'),
Success: getVariant('Success'),
Warning: getVariant('Warning'),
Critical: getVariant('Critical'),
},
},
defaultVariants: {
size: '400',
variant: 'Surface',
},
});
export type StackedAvatarVariants = RecipeVariants<typeof StackedAvatar>;

View file

@ -23,6 +23,11 @@ export const UrlPreviewImg = style([
objectPosition: 'center',
flexShrink: 0,
overflow: 'hidden',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.8)',
},
},
]);

View file

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
@ -12,6 +13,8 @@ import * as css from './UrlPreviewCard.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
const linkStyles = { color: color.Success.Main };
@ -19,6 +22,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState(false);
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
);
@ -30,7 +34,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mxcUrlToHttp(
const thumbUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
@ -40,9 +44,31 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
false
);
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
return (
<>
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
{thumbUrl && (
<UrlPreviewImg
src={thumbUrl}
alt={prev['og:title']}
title={prev['og:title']}
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
onClick={() => setViewer(true)}
/>
)}
{imgUrl && (
<ImageOverlay
src={imgUrl}
alt={prev['og:title']}
viewer={viewer}
requestClose={() => {
setViewer(false);
}}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
<UrlPreviewContent>
<Text
style={linkStyles}

View file

@ -28,7 +28,11 @@ import { copyToClipboard } from '../../utils/dom';
import { getExploreServerPath } from '../../pages/pathUtils';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
import {
useMutualRooms,
useMutualRoomsSupport,
useUnstableMutualRoomsSupport,
} from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -233,7 +237,9 @@ type MutualRoomsData = {
export function MutualRoomsChip({ userId }: { userId: string }) {
const mx = useMatrixClient();
const mutualRoomSupported = useMutualRoomsSupport();
const mutualRoomUnstable = useUnstableMutualRoomsSupport();
const mutualRoomsState = useMutualRooms(userId);
console.log(mutualRoomSupported, mutualRoomsState);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const closeUserRoomProfile = useCloseUserRoomProfile();
const directs = useDirectRooms();
@ -279,7 +285,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
if (
userId === mx.getSafeUserId() ||
!mutualRoomSupported ||
(!mutualRoomSupported && !mutualRoomUnstable) ||
mutualRoomsState.status === AsyncStatus.Error
) {
return null;
@ -323,7 +329,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} />
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</Avatar>
}

View file

@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.identity_server'?: {
base_url: string;
};
'org.matrix.msc2965.authentication'?: {
account?: string;
issuer?: string;
};
'org.matrix.msc4143.rtc_foci'?: [
{
livekit_service_url: string;
type: 'livekit';
}
];
};
export const autoDiscovery = async (

View file

@ -291,7 +291,11 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
)}
/>
) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} />
<RoomIcon
size="200"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
}

View file

@ -0,0 +1,235 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
disabled?: boolean;
};
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="300"
size="300"
onClick={toggleMic}
outlined
disabled={disabled || loading}
>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
};
function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="300"
size="300"
onClick={() => onToggle()}
outlined
disabled={disabled}
>
<Icon
size="100"
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
filled={!enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type VideoButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
disabled?: boolean;
};
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
const loading = videoState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="300"
size="300"
onClick={toggleVideo}
outlined
disabled={disabled || loading}
>
<Icon
size="100"
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
filled={enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
disabled?: boolean;
};
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="300"
size="300"
onClick={onToggle}
outlined
disabled={disabled}
>
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export function CallControl({
callEmbed,
compact,
callJoined,
}: {
callEmbed: CallEmbed;
compact: boolean;
callJoined: boolean;
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const handleHangup = () => {
if (!callJoined) {
setCallEmbed(undefined);
return;
}
hangup();
};
return (
<Box shrink="No" alignItems="Center" gap="300">
<Box alignItems="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
disabled={!callJoined}
/>
<SoundButton
enabled={sound}
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton
enabled={video}
onToggle={handleVideoToggle}
disabled={!callJoined}
/>
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
/>
)}
</Box>
<StatusDivider />
<Chip
variant="Critical"
radii="Pill"
fill="Soft"
before={
exiting ? (
<Spinner variant="Critical" fill="Soft" size="50" />
) : (
<Icon size="50" src={Icons.PhoneDown} filled />
)
}
disabled={exiting}
outlined
onClick={handleHangup}
>
{!compact && (
<Text as="span" size="L400">
End
</Text>
)}
</Chip>
</Box>
);
}

View file

@ -0,0 +1,59 @@
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { Chip, Icon, Icons, Text } from 'folds';
import { useAtomValue } from 'jotai';
import { useRoomName } from '../../hooks/useRoomMeta';
import { RoomIcon } from '../../components/room-avatar';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { getAllParents, guessPerfectParent } from '../../utils/room';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
type CallRoomNameProps = {
room: Room;
};
export function CallRoomName({ room }: CallRoomNameProps) {
const mx = useMatrixClient();
const name = useRoomName(room);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const mDirects = useAtomValue(mDirectAtom);
const dm = mDirects.has(room.roomId);
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const allParents = getAllParents(roomToParents, room.roomId);
const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o));
const perfectOrphanParent = orphanParents && guessPerfectParent(mx, room.roomId, orphanParents);
const { navigateRoom } = useRoomNavigate();
return (
<Chip
variant="Background"
radii="Pill"
before={
dm ? (
<Icon size="200" src={Icons.VolumeHigh} filled />
) : (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
)
}
onClick={() => navigateRoom(room.roomId)}
>
<Text size="L400" truncate>
{name}
{!dm && perfectOrphanParent && (
<Text as="span" size="T200" priority="300">
{' •'} <b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
</Text>
)}
</Text>
</Chip>
);
}

View file

@ -0,0 +1,81 @@
import React from 'react';
import { Box, Spinner } from 'folds';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
import * as css from './styles.css';
import { CallRoomName } from './CallRoomName';
import { CallControl } from './CallControl';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { MemberGlance } from './MemberGlance';
import { StatusDivider } from './components';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { MemberSpeaking } from './MemberSpeaking';
type CallStatusProps = {
callEmbed: CallEmbed;
};
export function CallStatus({ callEmbed }: CallStatusProps) {
const { room } = callEmbed;
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
const compact = screenSize === ScreenSize.Mobile;
const memberVisible = callJoined && callMembers.length > 0;
return (
<Box
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
shrink="No"
gap="400"
alignItems={compact ? undefined : 'Center'}
direction={compact ? 'Column' : 'Row'}
>
<Box grow="Yes" alignItems="Center" gap="200">
{memberVisible ? (
<Box shrink="No">
<LiveChip count={callMembers.length} room={room} members={callMembers} />
</Box>
) : (
<Spinner variant="Secondary" size="200" />
)}
<Box grow="Yes" alignItems="Center" gap="Inherit">
{!compact && (
<>
<CallRoomName room={room} />
{speakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
</>
)}
</>
)}
</Box>
{memberVisible && (
<Box shrink="No">
<MemberGlance room={room} members={callMembers} speakers={speakers} />
</Box>
)}
</Box>
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<CallRoomName room={room} />
</Box>
)}
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
</Box>
</Box>
);
}

View file

@ -0,0 +1,137 @@
import React, { MouseEventHandler, useState } from 'react';
import {
Avatar,
Badge,
Box,
Chip,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Text,
toRem,
} from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import * as css from './styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
type LiveChipProps = {
room: Room;
members: CallMembership[];
count: number;
};
export function LiveChip({ count, room, members }: LiveChipProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
const [cords, setCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
return (
<PopOut
anchor={cords}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}}
>
<Box grow="Yes">
<Scroll size="0" hideTrack visibility="Hover">
<Box direction="Column" style={{ padding: config.space.S100 }}>
{members.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
return (
<MenuItem
key={callMember.memberId}
size="400"
variant="Surface"
radii="300"
style={{ paddingLeft: config.space.S200 }}
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right'
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Surface"
fill="Soft"
before={<Badge variant="Critical" fill="Solid" size="200" />}
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
radii="Pill"
onClick={handleOpenMenu}
>
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>
</PopOut>
);
}

View file

@ -0,0 +1,75 @@
import { Box, config, Icon, Icons, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React from 'react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
type MemberGlanceProps = {
room: Room;
members: CallMembership[];
speakers: Set<string>;
max?: number;
};
export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
const visibleMembers = members.slice(0, max);
const remainingCount = max && members.length > max ? members.length - max : 0;
return (
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
return (
<StackedAvatar
key={callMember.memberId}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Top'
)
}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
)}
</Box>
);
}

View file

@ -0,0 +1,78 @@
import { Room } from 'matrix-js-sdk';
import React from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
type MemberSpeakingProps = {
room: Room;
speakers: Set<string>;
};
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
const speakingNames = Array.from(speakers).map(
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId
);
return (
<Box alignItems="Center" gap="100">
<Icon size="100" src={Icons.Mic} filled />
<Text size="T200" truncate>
{speakingNames.length === 1 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' is speaking...'}
</Text>
</>
)}
{speakingNames.length === 2 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{speakingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' are speaking...'}
</Text>
</>
)}
{speakingNames.length === 3 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{speakingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{speakingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' are speaking...'}
</Text>
</>
)}
{speakingNames.length > 3 && (
<>
<b>{speakingNames[0]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{speakingNames[1]}</b>
<Text as="span" size="Inherit" priority="300">
{', '}
</Text>
<b>{speakingNames[2]}</b>
<Text as="span" size="Inherit" priority="300">
{' and '}
</Text>
<b>{speakingNames.length - 3} others</b>
<Text as="span" size="Inherit" priority="300">
{' are speaking...'}
</Text>
</>
)}
</Text>
</Box>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
import { Line } from 'folds';
import * as css from './styles.css';
export function StatusDivider() {
return (
<Line variant="Background" size="300" direction="Vertical" className={css.ControlDivider} />
);
}

View file

@ -0,0 +1 @@
export * from './CallStatus';

View file

@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const LiveChipText = style({
color: color.Critical.Main,
});
export const CallStatus = style([
{
padding: `${toRem(6)} ${config.space.S200}`,
borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
},
]);
export const ControlDivider = style({
height: toRem(16),
});
export const SpeakerAvatarOutline = style({
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
});

View file

@ -0,0 +1,206 @@
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
import {
Box,
Button,
config,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import {
ChatButton,
ControlDivider,
MicrophoneButton,
ScreenShareButton,
SoundButton,
VideoButton,
} from './Controls';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
type CallControlsProps = {
callEmbed: CallEmbed;
};
export function CallControls({ callEmbed }: CallControlsProps) {
const controlRef = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
useResizeObserver(
useCallback(() => {
const element = controlRef.current;
if (!element) return;
setCompact(element.clientWidth < 500);
}, []),
useCallback(() => controlRef.current, [])
);
const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
callEmbed.control
);
const [cords, setCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleSpotlightClick = () => {
callEmbed.control.toggleSpotlight();
setCords(undefined);
};
const handleReactionsClick = () => {
callEmbed.control.toggleReactions();
setCords(undefined);
};
const handleSettingsClick = () => {
callEmbed.control.toggleSettings();
setCords(undefined);
};
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
return (
<Box
ref={controlRef}
className={css.CallControlContainer}
justifyContent="Center"
alignItems="Center"
>
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={handleVideoToggle} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
/>
</Box>
</Box>
{!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSpotlightClick}
>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleReactionsClick}
>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSettingsClick}
>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-pressed={!!cords}
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</Box>
<Box shrink="No" direction="Column">
<Button
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={
exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
)
}
disabled={exiting}
>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
</SequenceCard>
</Box>
);
}

View file

@ -0,0 +1,114 @@
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useState } from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { SequenceCard } from '../../components/sequence-card';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useRoom } from '../../hooks/useRoom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
type CallMemberCardProps = {
member: CallMembership;
};
export function CallMemberCard({ member }: CallMemberCardProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const openUserProfile = useOpenUserRoomProfile();
const { userId } = member;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
: undefined;
const audioOnly = member.callIntent === 'audio';
return (
<SequenceCard
as="button"
key={member.memberId}
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={(evt: any) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right'
)
}
>
<Box grow="Yes" gap="300" alignItems="Center">
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<Box grow="Yes">
<Text size="L400" truncate>
{name}
</Text>
</Box>
{audioOnly && <Icon src={Icons.VideoCameraMute} size="100" />}
</Box>
</SequenceCard>
);
}
export function CallMemberRenderer({
members,
max = 4,
}: {
members: CallMembership[];
max?: number;
}) {
const [viewMore, setViewMore] = useState(false);
const truncatedMembers = viewMore ? members : members.slice(0, 4);
const remaining = members.length - truncatedMembers.length;
return (
<>
{truncatedMembers.map((member) => (
<CallMemberCard key={member.memberId} member={member} />
))}
{members.length > max && (
<SequenceCard
as="button"
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={() => setViewMore(!viewMore)}
>
<Box grow="Yes" gap="300" alignItems="Center">
{viewMore ? (
<Text size="L400" truncate>
Collapse
</Text>
) : (
<Text size="L400" truncate>
{remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`}
</Text>
)}
</Box>
<Icon src={viewMore ? Icons.ChevronTop : Icons.ChevronBottom} size="100" />
</SequenceCard>
)}
</>
);
}

View file

@ -0,0 +1,173 @@
import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
function LivekitServerMissingMessage() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling.
</Text>
);
}
function WebRTCMissingError() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your browser does not support WebRTC, which is required for calling.
</Text>
);
}
function JoinMessage({
hasParticipant,
livekitSupported,
rtcSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <WebRTCMissingError />;
}
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
if (hasParticipant) return null;
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chat’s empty — Be the first to hop in!
</Text>
);
}
function NoPermissionMessage() {
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
You don&#39;t have permission to join!
</Text>
);
}
function AlreadyInCallMessage() {
return (
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
Already in another call — End the current call to join!
</Text>
);
}
function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId()
);
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const hasParticipant = callMembers.length > 0;
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && livekitSupported && rtcSupported;
return (
<Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
{hasParticipant && (
<Header size="300">
<Box grow="Yes" alignItems="Center">
<Text size="L400">Participant</Text>
</Box>
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
</Header>
)}
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
) : (
<NoPermissionMessage />
))}
{inOtherCall && <AlreadyInCallMessage />}
</Box>
</Box>
</Box>
</Scroll>
);
}
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
};
function CallJoined({ joined, containerRef }: CallJoinedProps) {
const callEmbed = useCallEmbed();
return (
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
</Box>
);
}
export function CallView() {
const room = useRoom();
const callContainerRef = useRef<HTMLDivElement>(null);
useCallEmbedPlacementSync(callContainerRef);
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && <CallPrescreen />}
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
</Box>
);
}

View file

@ -0,0 +1,186 @@
import React from 'react';
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
import { useAtom } from 'jotai';
import * as css from './styles.css';
import { callChatAtom } from '../../state/callEmbed';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
export function ControlDivider() {
return (
<Line variant="SurfaceVariant" size="300" direction="Vertical" className={css.ControlDivider} />
);
}
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
};
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
onClick={toggleMic}
outlined
disabled={loading}
>
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
type SoundButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Turn Off Sound' : 'Turn On Sound'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon
size="400"
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
filled={!enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type VideoButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
};
export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
const loading = videoState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Camera' : 'Start Camera'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={toggleVideo}
outlined
disabled={loading}
>
<Icon
size="400"
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
filled={enabled}
/>
</IconButton>
)}
</TooltipProvider>
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon size="400" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{chat ? 'Close Chat' : 'Open Chat'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={chat ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => setChat(!chat)}
outlined
>
<Icon size="400" src={Icons.Message} filled={chat} />
</IconButton>
)}
</TooltipProvider>
);
}

View file

@ -0,0 +1,71 @@
import React, { useCallback } from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
type PrescreenControlsProps = {
canJoin?: boolean;
};
export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const room = useRoom();
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const direct = useIsDirectRoom();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const startCall = useCallStart(direct);
const joining = callEmbed?.roomId === room.roomId && !callJoined;
const disabled = inOtherCall || !canJoin;
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
useCallPreferences();
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);
return (
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
wrap="Wrap"
>
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<SoundButton enabled={sound} onToggle={toggleSound} />
</Box>
<ControlDivider />
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<VideoButton enabled={video} onToggle={handleVideoToggle} />
<ChatButton />
</Box>
<Box grow="Yes" direction="Column">
<Button
variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'}
onClick={() => startCall(room, { microphone, video, sound })}
disabled={disabled || joining}
before={
joining ? (
<Spinner variant="Success" fill="Solid" size="200" />
) : (
<Icon src={Icons.Phone} size="200" filled />
)
}
>
<Text size="B400">Join</Text>
</Button>
</Box>
</SequenceCard>
);
}

View file

@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const CallViewContent = style({
padding: config.space.S400,
paddingRight: 0,
minHeight: '100%',
});
export const ControlCard = style({
padding: config.space.S300,
});
export const ControlDivider = style({
height: toRem(24),
});
export const CallMemberCard = style({
padding: config.space.S300,
});
export const CallControlContainer = style({
padding: config.space.S400,
});
export const PrescreenMessage = style({
padding: config.space.S200,
});

View file

@ -6,9 +6,8 @@ import { useAtomValue } from 'jotai';
import {
ExtendedJoinRules,
JoinRulesSwitcher,
useRoomJoinRuleIcon,
useJoinRuleIcons,
useRoomJoinRuleLabel,
useSpaceJoinRuleIcon,
} from '../../../components/JoinRulesSwitcher';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
@ -75,8 +74,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
return r;
}, [allowKnockRestricted, allowRestricted, allowKnock, space]);
const icons = useRoomJoinRuleIcon();
const spaceIcons = useSpaceJoinRuleIcon();
const icons = useJoinRuleIcons(room.getType());
const labels = useRoomJoinRuleLabel();
const [submitState, submit] = useAsyncCallback(
@ -137,7 +135,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
}
after={
<JoinRulesSwitcher
icons={room.isSpaceRoom() ? spaceIcons : icons}
icons={icons}
labels={labels}
rules={joinRules}
value={rule}

View file

@ -199,7 +199,7 @@ export function RoomProfileEdit({
alt={name}
renderFallback={() => (
<RoomIcon
space={room.isSpaceRoom()}
roomType={room.getType()}
size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled
@ -342,7 +342,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
alt={name}
renderFallback={() => (
<RoomIcon
space={room.isSpaceRoom()}
roomType={room.getType()}
size="400"
joinRule={joinRule?.join_rule ?? JoinRule.Invite}
filled

View file

@ -1,5 +1,5 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk';
import { MatrixError, Room, JoinRule } from 'matrix-js-sdk';
import {
Box,
Button,
@ -33,24 +33,43 @@ import {
createRoom,
CreateRoomAliasInput,
CreateRoomData,
CreateRoomKind,
CreateRoomKindSelector,
CreateRoomAccess,
CreateRoomAccessSelector,
RoomVersionSelector,
useAdditionalCreators,
CreateRoomType,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
import { CreateRoomTypeSelector } from '../../components/create-room/CreateRoomTypeSelector';
import { getRoomIconSrc } from '../../utils/room';
const getCreateRoomKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.HashLock;
if (kind === CreateRoomKind.Restricted) return Icons.Hash;
return Icons.HashGlobe;
const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomType) => {
const isVoiceRoom = type === CreateRoomType.VoiceRoom;
let joinRule: JoinRule = JoinRule.Public;
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
if (access === CreateRoomAccess.Private) joinRule = JoinRule.Knock;
return getRoomIconSrc(Icons, isVoiceRoom ? RoomType.Call : undefined, joinRule);
};
const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
if (type === CreateRoomType.VoiceRoom) return Icons.VolumeHigh;
return Icons.Hash;
};
type CreateRoomFormProps = {
defaultKind?: CreateRoomKind;
defaultAccess?: CreateRoomAccess;
defaultType?: CreateRoomType;
space?: Room;
onCreate?: (roomId: string) => void;
};
export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormProps) {
export function CreateRoomForm({
defaultAccess,
defaultType,
space,
onCreate,
}: CreateRoomFormProps) {
const mx = useMatrixClient();
const alive = useAlive();
@ -64,8 +83,9 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
const [kind, setKind] = useState(
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
const [type, setType] = useState(defaultType ?? CreateRoomType.TextRoom);
const [access, setAccess] = useState(
defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private)
);
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
@ -75,13 +95,13 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private);
setAccess(CreateRoomAccess.Private);
}
selectRoomVersion(version);
};
@ -107,19 +127,23 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public;
const publicRoom = access === CreateRoomAccess.Public;
let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) {
if (allowKnock && access === CreateRoomAccess.Private) {
roomKnock = knock;
}
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
roomKnock = knock;
}
let roomType: RoomType | undefined;
if (type === CreateRoomType.VoiceRoom) roomType = RoomType.Call;
create({
version: selectedRoomVersion,
type: roomType,
parent: space,
kind,
access,
name: roomName,
topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
@ -136,21 +160,32 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
{!space && (
<Box direction="Column" gap="100">
<Text size="L400">Type</Text>
<CreateRoomTypeSelector
value={type}
onSelect={setType}
disabled={disabled}
getIcon={getCreateRoomTypeToIcon}
/>
</Box>
)}
<Box direction="Column" gap="100">
<Text size="L400">Access</Text>
<CreateRoomKindSelector
value={kind}
onSelect={setKind}
<CreateRoomAccessSelector
value={access}
onSelect={setAccess}
canRestrict={allowRestricted}
disabled={disabled}
getIcon={getCreateRoomKindToIcon}
getIcon={(roomAccess) => getCreateRoomAccessToIcon(roomAccess, type)}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateRoomKindToIcon(kind)} />}
before={<Icon size="100" src={getCreateRoomAccessToIcon(access, type)} />}
name="nameInput"
autoFocus
size="500"
@ -171,7 +206,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
/>
</Box>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
{access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End">
@ -201,7 +236,7 @@ export function CreateRoomForm({ defaultKind, space, onCreate }: CreateRoomFormP
/>
</SequenceCard>
)}
{kind !== CreateRoomKind.Public && (
{access !== CreateRoomAccess.Public && (
<>
<SequenceCard
style={{ padding: config.space.S300 }}

View file

@ -23,12 +23,13 @@ import {
} from '../../state/hooks/createRoomModal';
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types';
type CreateRoomModalProps = {
state: CreateRoomModalState;
};
function CreateRoomModal({ state }: CreateRoomModalProps) {
const { spaceId } = state;
const { spaceId, type } = state;
const closeDialog = useCloseCreateRoomModal();
const allJoinedRooms = useAllJoinedRoomsSet();
@ -57,7 +58,9 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
}}
>
<Box grow="Yes">
<Text size="H4">New Room</Text>
<Text size="H4">
{type === CreateRoomType.VoiceRoom ? 'New Voice Room' : 'New Chat Room'}
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
@ -74,7 +77,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
direction="Column"
gap="500"
>
<CreateRoomForm space={space} onCreate={closeDialog} />
<CreateRoomForm space={space} onCreate={closeDialog} defaultType={type} />
</Box>
</Scroll>
</Box>

View file

@ -33,25 +33,25 @@ import {
createRoom,
CreateRoomAliasInput,
CreateRoomData,
CreateRoomKind,
CreateRoomKindSelector,
CreateRoomAccess,
CreateRoomAccessSelector,
RoomVersionSelector,
useAdditionalCreators,
} from '../../components/create-room';
import { RoomType } from '../../../types/matrix/room';
const getCreateSpaceKindToIcon = (kind: CreateRoomKind) => {
if (kind === CreateRoomKind.Private) return Icons.SpaceLock;
if (kind === CreateRoomKind.Restricted) return Icons.Space;
const getCreateSpaceAccessToIcon = (access: CreateRoomAccess) => {
if (access === CreateRoomAccess.Private) return Icons.SpaceLock;
if (access === CreateRoomAccess.Restricted) return Icons.Space;
return Icons.SpaceGlobe;
};
type CreateSpaceFormProps = {
defaultKind?: CreateRoomKind;
defaultAccess?: CreateRoomAccess;
space?: Room;
onCreate?: (roomId: string) => void;
};
export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFormProps) {
export function CreateSpaceForm({ defaultAccess, space, onCreate }: CreateSpaceFormProps) {
const mx = useMatrixClient();
const alive = useAlive();
@ -65,8 +65,8 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
const allowRestricted = space && restrictedSupported(selectedRoomVersion);
const [kind, setKind] = useState(
defaultKind ?? allowRestricted ? CreateRoomKind.Restricted : CreateRoomKind.Private
const [access, setAccess] = useState(
defaultAccess ?? (allowRestricted ? CreateRoomAccess.Restricted : CreateRoomAccess.Private)
);
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
@ -76,13 +76,13 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
const [knock, setKnock] = useState(false);
const [advance, setAdvance] = useState(false);
const allowKnock = kind === CreateRoomKind.Private && knockSupported(selectedRoomVersion);
const allowKnock = access === CreateRoomAccess.Private && knockSupported(selectedRoomVersion);
const allowKnockRestricted =
kind === CreateRoomKind.Restricted && knockRestrictedSupported(selectedRoomVersion);
access === CreateRoomAccess.Restricted && knockRestrictedSupported(selectedRoomVersion);
const handleRoomVersionChange = (version: string) => {
if (!restrictedSupported(version)) {
setKind(CreateRoomKind.Private);
setAccess(CreateRoomAccess.Private);
}
selectRoomVersion(version);
};
@ -108,12 +108,12 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
aliasInput && aliasInput.value ? replaceSpaceWithDash(aliasInput.value) : undefined;
if (!roomName) return;
const publicRoom = kind === CreateRoomKind.Public;
const publicRoom = access === CreateRoomAccess.Public;
let roomKnock = false;
if (allowKnock && kind === CreateRoomKind.Private) {
if (allowKnock && access === CreateRoomAccess.Private) {
roomKnock = knock;
}
if (allowKnockRestricted && kind === CreateRoomKind.Restricted) {
if (allowKnockRestricted && access === CreateRoomAccess.Restricted) {
roomKnock = knock;
}
@ -121,7 +121,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
version: selectedRoomVersion,
type: RoomType.Space,
parent: space,
kind,
access,
name: roomName,
topic: roomTopic || undefined,
aliasLocalPart: publicRoom ? aliasLocalPart : undefined,
@ -139,19 +139,19 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box direction="Column" gap="100">
<Text size="L400">Access</Text>
<CreateRoomKindSelector
value={kind}
onSelect={setKind}
<CreateRoomAccessSelector
value={access}
onSelect={setAccess}
canRestrict={allowRestricted}
disabled={disabled}
getIcon={getCreateSpaceKindToIcon}
getIcon={getCreateSpaceAccessToIcon}
/>
</Box>
<Box shrink="No" direction="Column" gap="100">
<Text size="L400">Name</Text>
<Input
required
before={<Icon size="100" src={getCreateSpaceKindToIcon(kind)} />}
before={<Icon size="100" src={getCreateSpaceAccessToIcon(access)} />}
name="nameInput"
autoFocus
size="500"
@ -172,7 +172,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
/>
</Box>
{kind === CreateRoomKind.Public && <CreateRoomAliasInput disabled={disabled} />}
{access === CreateRoomAccess.Public && <CreateRoomAliasInput disabled={disabled} />}
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" alignItems="End">
@ -202,7 +202,7 @@ export function CreateSpaceForm({ defaultKind, space, onCreate }: CreateSpaceFor
/>
</SequenceCard>
)}
{kind !== CreateRoomKind.Public && advance && (allowKnock || allowKnockRestricted) && (
{access !== CreateRoomAccess.Public && advance && (allowKnock || allowKnockRestricted) && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"

View 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,
},
},
});

View 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>
);
}

View 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 },
]);
});
});

View 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] : []),
];
};

View 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');
});
});

View file

@ -0,0 +1 @@
export * from './EstateNavigator';

View 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();
});
});

View 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);
});

View 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 };
};

View file

@ -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}

View file

@ -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}
/>

View file

@ -165,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
<Box shrink="No">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<IconButton fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
@ -218,7 +218,11 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={() => setPeopleDrawer((drawer) => !drawer)}>
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setPeopleDrawer((drawer) => !drawer)}
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
@ -235,7 +239,12 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
}
>
{(triggerRef) => (
<IconButton onClick={handleOpenMenu} ref={triggerRef} aria-pressed={!!menuAnchor}>
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}

View file

@ -6,6 +6,7 @@ export const LobbyHeroTopic = style({
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
':hover': {
cursor: 'pointer',

View file

@ -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>
@ -175,6 +175,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
type RoomProfileProps = {
roomId: string;
roomType?: string;
name: string;
topic?: string;
avatarUrl?: string;
@ -185,6 +186,7 @@ type RoomProfileProps = {
};
function RoomProfile({
roomId,
roomType,
name,
topic,
avatarUrl,
@ -200,9 +202,7 @@ function RoomProfile({
roomId={roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="300" joinRule={joinRule ?? JoinRule.Restricted} filled />
)}
renderFallback={() => <RoomIcon size="300" joinRule={joinRule} roomType={roomType} />}
/>
</Avatar>
<Box grow="Yes" direction="Column">
@ -338,6 +338,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
{(localSummary) => (
<RoomProfile
roomId={roomId}
roomType={localSummary.roomType}
name={localSummary.name}
topic={localSummary.topic}
avatarUrl={
@ -396,6 +397,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
{summary && (
<RoomProfile
roomId={roomId}
roomType={summary.room_type}
name={summary.name || summary.canonical_alias || roomId}
topic={summary.topic}
avatarUrl={

View file

@ -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}
/>
)
}

View file

@ -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';
@ -36,6 +36,9 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenCreateRoomModal } from '../../state/hooks/createRoomModal';
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 (
@ -110,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;
@ -249,8 +252,8 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
setCords(evt.currentTarget.getBoundingClientRect());
};
const handleCreateRoom = () => {
openCreateRoomModal(item.roomId);
const handleCreateRoom = (type?: CreateRoomType) => {
openCreateRoomModal(item.roomId, type);
setCords(undefined);
};
@ -281,9 +284,19 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
radii="300"
variant="Primary"
fill="None"
onClick={handleCreateRoom}
onClick={() => handleCreateRoom(CreateRoomType.TextRoom)}
>
<Text size="T300">New Room</Text>
<Text size="T300">Chat Room</Text>
</MenuItem>
<MenuItem
size="300"
radii="300"
variant="Primary"
fill="None"
onClick={() => handleCreateRoom(CreateRoomType.VoiceRoom)}
after={<BetaNoticeBadge />}
>
<Text size="T300">Voice Room</Text>
</MenuItem>
<MenuItem size="300" radii="300" fill="None" onClick={handleAddExisting}>
<Text size="T300">Existing Room</Text>

View file

@ -29,7 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { joinRuleToIconSrc } from '../../utils/room';
import { getRoomIconSrc } from '../../utils/room';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import {
SearchItemStrGetter,
@ -274,9 +274,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
before={
<Icon
size="50"
src={
joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash
}
src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())}
/>
}
>
@ -392,10 +390,7 @@ export function SearchFilters({
onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))}
radii="Pill"
before={
<Icon
size="50"
src={joinRuleToIconSrc(Icons, room.getJoinRule(), false) ?? Icons.Hash}
/>
<Icon size="50" src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} />
}
after={<Icon size="50" src={Icons.Cross} />}
>

Some files were not shown because too many files have changed in this diff Show more